{"slug": "the-generic-mcp-toolbox-tools-that-register-themselves", "title": "The Generic MCP Toolbox: Tools That Register Themselves", "summary": "Cleaniquecoders released an update to its Laravel MCP Kit that introduces a generic toolbox of ops tools that register themselves only when their backing packages are installed. The toolbox uses a static `isAvailable()` check to filter tools, ensuring the MCP server exposes exactly the tools the host stack supports, avoiding phantom entries or fatal errors. The registry is centralized in `ToolRegistry::tools()`, and each tool also re-verifies availability inside its `handle()` method for safety.", "body_md": "I've now built MCP servers into enough Laravel apps to notice the pattern: I keep rewriting the same tools. Every server needs a `whoami`\n\n. Every server needs to tail logs, peek at failed jobs, retry one, check whether the queue is alive. None of that is domain logic — it's the same generic ops surface, copy-pasted and lightly mutated, in project after project.\n\nSo I pulled it into the kit. Today `cleaniquecoders/laravel-mcp-kit`\n\ngot a generic, opt-in toolbox — the handful of tools that have *zero* domain coupling and therefore belong in a package, not in your app. The interesting part isn't the tools themselves. It's the two rules that decide *whether a tool even exists* in a given install.\n\nA toolbox package has a tension baked in. You want to ship `list_audits`\n\n, `issue_mcp_token`\n\n, `list_roles`\n\n— genuinely useful tools. But `list_audits`\n\nonly makes sense if the host installed `owen-it/laravel-auditing`\n\n. `issue_mcp_token`\n\nneeds Sanctum. `list_roles`\n\nneeds `spatie/laravel-permission`\n\n.\n\nThe naive approach — register them all — blows up the moment the agent calls a tool whose backing package isn't there: a fatal `Class not found`\n\n, or worse, a tool that *looks* available in the MCP tool list and then errors only when invoked. An agent has no way to know that `list_roles`\n\nis a lie until it's mid-task.\n\nSo the toolbox needs to be honest about itself. A host should get *exactly* the tools its stack can support — no half-wired features, no phantom entries.\n\nThe fix is that a tool registers only when its backing package (and, where it matters, its table) actually exist. Each conditional tool answers one static question:\n\n```\nclass ListAuditsTool extends McpKitTool\n{\n    public static function isAvailable(): bool\n    {\n        return class_exists(static::model());\n    }\n\n    protected static function model(): string\n    {\n        // resolved from the package's own config, so a host's\n        // custom Audit model is honoured, not hard-coded.\n        return config('audit.implementation', 'OwenIt\\\\Auditing\\\\Models\\\\Audit');\n    }\n}\n```\n\nAnd the registry — the single source of truth for what the server exposes — filters on it:\n\n```\npublic static function tools(): array\n{\n    return array_values(array_filter([\n        // Tier 1 — pure-generic, always on (zero dependencies).\n        WhoAmITool::class,\n        SystemHealthTool::class,\n        TailLogsTool::class,\n        ListFailedJobsTool::class,\n        RetryFailedJobTool::class,\n        QueueStatusTool::class,\n\n        // Tier 2 — registered only when the backing package is present.\n        ListAuditsTool::isAvailable() ? ListAuditsTool::class : null,\n        IssueMcpTokenTool::isAvailable() ? IssueMcpTokenTool::class : null,\n        ListRolesTool::isAvailable() ? ListRolesTool::class : null,\n        GetUserPermissionsTool::isAvailable() ? GetUserPermissionsTool::class : null,\n    ]));\n}\n```\n\n`array_filter`\n\ndrops the nulls; the server boots with whatever survived. Install Sanctum later and the token tools appear on the next boot with no code change. Uninstall auditing and `list_audits`\n\nquietly vanishes — the server degrades gracefully instead of throwing.\n\nThe key design choice is keeping this in *one place*. `TaskServer::boot()`\n\nreads `ToolRegistry::tools()`\n\nand nothing else decides registration. When you want to know \"what does this server actually expose on this install?\", there's exactly one list to read. Scatter the `class_exists`\n\nchecks across twenty tool constructors and you've built a system nobody can reason about.\n\nThere's a subtlety worth naming: `isAvailable()`\n\nis a *static* gate on capability, and the tool *also* re-checks inside `handle()`\n\n:\n\n```\npublic function handle(Request $request): Response\n{\n    if (! static::isAvailable()) {\n        return Response::error('Audit reading is unavailable — owen-it/laravel-auditing is not installed.');\n    }\n    // ...\n}\n```\n\nBelt and suspenders. The registry should mean an unavailable tool never reaches `handle()`\n\n— but if something ever wires it up directly, the tool refuses cleanly rather than fatalling. Think of it like a contract: the registry promises not to call you when you can't work, and you still check the promise was kept.\n\nPresence decides whether a tool *exists*. Authorization decides whether *this caller* may use it. Those are different axes and the toolbox keeps them separate.\n\nEvery tool reads its required ability from config rather than hard-coding a permission name:\n\n``` php\nprotected function ability(): string\n{\n    return $this->configuredAbility('view-audits');\n}\n```\n\nThat indirection matters more than it looks. Your app probably doesn't call its permissions `view-audits`\n\n— it has its own scheme, its own guard, its own role names. By routing every ability through `config('mcp-kit.abilities.*')`\n\n, the host remaps the kit's generic ability onto whatever its permission system actually uses. The package ships an opinion about *what* needs guarding; the host keeps full control over *who* clears the gate.\n\nThe other half of the rule: reads are tagged `#[IsReadOnly]`\n\nand writes funnel through an invokable Action — never inline in the tool. `retry_failed_job`\n\ndoesn't re-dispatch a job itself; it calls a `RetryFailedJob`\n\naction. The tool is the MCP-shaped doorway; the Action is the thing that actually mutates state, and it's independently testable and reusable outside MCP entirely.\n\nAnd identity stays uuid-only. Tools emit and accept public UUIDs, never the internal auto-increment id. An agent — or anyone reading the transcript — never sees your sequential primary keys, which leak row counts and make enumeration trivial. The public id *is* the uuid; the internal id stays internal.\n\nOne tool worth calling out because it generalizes well. `export_logs`\n\ndoesn't inline a giant blob of log text into the MCP response — that's how you blow a context window. Instead it writes the slice to disk and hands back a short-lived **signed download URL**:\n\n``` php\n// inside the tool:\nreturn $this->download($contents, 'failed-jobs-export.json');\n```\n\nThe signature *is* the capability. Laravel's `signed`\n\nmiddleware rejects a tampered or expired link, so you don't need a second auth layer on the download route — the URL either verifies or it doesn't. The agent gets a link, the human (or the calling system) fetches it once, and the link dies on a timer. Large payloads leave through a side door instead of clogging the conversation.\n\nThe thing most worth a test here isn't any single tool — it's the *registration logic*, because that's the part with branches. A tool's happy path you'll notice when it breaks; a tool silently missing from the registry you won't.\n\n```\nit('registers tier-2 tools only when the backing package is present', function () {\n    expect(ListAuditsTool::isAvailable())\n        ->toBe(class_exists(\\OwenIt\\Auditing\\Models\\Audit::class));\n});\n\nit('keeps the server bootable when an optional package is absent', function () {\n    $tools = ToolRegistry::tools();\n\n    // Tier-1 tools are unconditional — always there.\n    expect($tools)\n        ->toContain(WhoAmITool::class)\n        ->toContain(SystemHealthTool::class);\n});\n\nit('never exposes an unavailable tool', function () {\n    foreach (ToolRegistry::tools() as $tool) {\n        if (method_exists($tool, 'isAvailable')) {\n            expect($tool::isAvailable())->toBeTrue();\n        }\n    }\n});\n```\n\nThat last test is the one I'd fight to keep. It asserts the invariant the whole design rests on: *if it's in the registry, it works.* Everything else is downstream of that promise.\n\nThe toolbox stops at generic. Anything domain-coupled — identity sync, gateway provisioning, directory-presence checks, your business rules — stays in the host app behind project-specific Actions. The kit gives you the generic spine: ops, jobs, logs, tokens, the gate-first pattern, the signed-URL helper. The domain is yours, and it should be, because the day a \"generic\" package starts knowing about your business logic is the day it stops being reusable.\n\nThat's the whole philosophy in one sentence: **ship the spine, not the organs.** A toolbox that registers itself, gates itself, and refuses to pretend it can do things it can't — and leaves your domain exactly where it belongs.\n\nIt's open source, so the tier tables and the full tool list are in the repo: [github.com/cleaniquecoders/laravel-mcp-kit](https://github.com/cleaniquecoders/laravel-mcp-kit).", "url": "https://wpnews.pro/news/the-generic-mcp-toolbox-tools-that-register-themselves", "canonical_source": "https://dev.to/nasrulhazim/the-generic-mcp-toolbox-tools-that-register-themselves-13m8", "published_at": "2026-06-19 11:21:00+00:00", "updated_at": "2026-06-19 11:36:48.908134+00:00", "lang": "en", "topics": ["developer-tools", "ai-agents", "mlops"], "entities": ["Cleaniquecoders", "Laravel MCP Kit", "Sanctum", "Spatie", "OwenIt"], "alternates": {"html": "https://wpnews.pro/news/the-generic-mcp-toolbox-tools-that-register-themselves", "markdown": "https://wpnews.pro/news/the-generic-mcp-toolbox-tools-that-register-themselves.md", "text": "https://wpnews.pro/news/the-generic-mcp-toolbox-tools-that-register-themselves.txt", "jsonld": "https://wpnews.pro/news/the-generic-mcp-toolbox-tools-that-register-themselves.jsonld"}}