{
  "$type": "site.standard.document",
  "bskyPostRef": {
    "cid": "bafyreiha6h3zrvps3mtlq2tovddurkwe7dtdy52ub4gytifjq3lrk7t6e4",
    "uri": "at://did:plc:25rdn5elo5izoxrmtis34zuk/app.bsky.feed.post/3monb3ss4hbg2"
  },
  "coverImage": {
    "$type": "blob",
    "ref": {
      "$link": "bafkreiedgugjuyyebyzmo6fercydydtmw3jguwmojd52rc6d4ciwqojdje"
    },
    "mimeType": "image/webp",
    "size": 89308
  },
  "path": "/nasrulhazim/the-generic-mcp-toolbox-tools-that-register-themselves-13m8",
  "publishedAt": "2026-06-19T11:21:00.000Z",
  "site": "https://dev.to",
  "tags": [
    "laravel",
    "php",
    "opensource",
    "architecture",
    "github.com/cleaniquecoders/laravel-mcp-kit"
  ],
  "textContent": "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`. 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` got 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\n##  The problem with shipping a toolbox\n\nA toolbox package has a tension baked in. You want to ship `list_audits`, `issue_mcp_token`, `list_roles` — genuinely useful tools. But `list_audits` only makes sense if the host installed `owen-it/laravel-auditing`. `issue_mcp_token` needs Sanctum. `list_roles` needs `spatie/laravel-permission`.\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`, 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` is 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\n##  Rule one: opt-in by presence\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\n\n    class 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\n\n    public 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` drops 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` quietly vanishes — the server degrades gracefully instead of throwing.\n\nThe key design choice is keeping this in _one place_. `TaskServer::boot()` reads `ToolRegistry::tools()` and 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` checks across twenty tool constructors and you've built a system nobody can reason about.\n\nThere's a subtlety worth naming: `isAvailable()` is a _static_ gate on capability, and the tool _also_ re-checks inside `handle()`:\n\n\n\n    public 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()` — 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\n##  Rule two: gated and uuid-only, every single tool\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\n\n    protected 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` — it has its own scheme, its own guard, its own role names. By routing every ability through `config('mcp-kit.abilities.*')`, 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]` and writes funnel through an invokable Action — never inline in the tool. `retry_failed_job` doesn't re-dispatch a job itself; it calls a `RetryFailedJob` action. 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\n##  A nice payoff: the export-by-signature pattern\n\nOne tool worth calling out because it generalizes well. `export_logs` doesn'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\n\n    // inside the tool:\n    return $this->download($contents, 'failed-jobs-export.json');\n\n\nThe signature _is_ the capability. Laravel's `signed` middleware 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\n##  Testing the registry, not just the tools\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\n\n    it('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\n    it('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\n    it('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\n##  The line I won't cross\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.",
  "title": "The Generic MCP Toolbox: Tools That Register Themselves"
}