DEV Community

Cover image for The Generic MCP Toolbox: Tools That Register Themselves
Nasrul Hazim Bin Mohamad
Nasrul Hazim Bin Mohamad

Posted on

The Generic MCP Toolbox: Tools That Register Themselves

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.

So 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.

The problem with shipping a toolbox

A 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.

The 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.

So 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.

Rule one: opt-in by presence

The 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:

class ListAuditsTool extends McpKitTool
{
    public static function isAvailable(): bool
    {
        return class_exists(static::model());
    }

    protected static function model(): string
    {
        // resolved from the package's own config, so a host's
        // custom Audit model is honoured, not hard-coded.
        return config('audit.implementation', 'OwenIt\\Auditing\\Models\\Audit');
    }
}
Enter fullscreen mode Exit fullscreen mode

And the registry — the single source of truth for what the server exposes — filters on it:

public static function tools(): array
{
    return array_values(array_filter([
        // Tier 1 — pure-generic, always on (zero dependencies).
        WhoAmITool::class,
        SystemHealthTool::class,
        TailLogsTool::class,
        ListFailedJobsTool::class,
        RetryFailedJobTool::class,
        QueueStatusTool::class,

        // Tier 2 — registered only when the backing package is present.
        ListAuditsTool::isAvailable() ? ListAuditsTool::class : null,
        IssueMcpTokenTool::isAvailable() ? IssueMcpTokenTool::class : null,
        ListRolesTool::isAvailable() ? ListRolesTool::class : null,
        GetUserPermissionsTool::isAvailable() ? GetUserPermissionsTool::class : null,
    ]));
}
Enter fullscreen mode Exit fullscreen mode

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.

The 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.

There's a subtlety worth naming: isAvailable() is a static gate on capability, and the tool also re-checks inside handle():

public function handle(Request $request): Response
{
    if (! static::isAvailable()) {
        return Response::error('Audit reading is unavailable — owen-it/laravel-auditing is not installed.');
    }
    // ...
}
Enter fullscreen mode Exit fullscreen mode

Belt 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.

Rule two: gated and uuid-only, every single tool

Presence decides whether a tool exists. Authorization decides whether this caller may use it. Those are different axes and the toolbox keeps them separate.

Every tool reads its required ability from config rather than hard-coding a permission name:

protected function ability(): string
{
    return $this->configuredAbility('view-audits');
}
Enter fullscreen mode Exit fullscreen mode

That 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.

The 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.

And 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.

A nice payoff: the export-by-signature pattern

One 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:

// inside the tool:
return $this->download($contents, 'failed-jobs-export.json');
Enter fullscreen mode Exit fullscreen mode

The 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.

Testing the registry, not just the tools

The 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.

it('registers tier-2 tools only when the backing package is present', function () {
    expect(ListAuditsTool::isAvailable())
        ->toBe(class_exists(\OwenIt\Auditing\Models\Audit::class));
});

it('keeps the server bootable when an optional package is absent', function () {
    $tools = ToolRegistry::tools();

    // Tier-1 tools are unconditional — always there.
    expect($tools)
        ->toContain(WhoAmITool::class)
        ->toContain(SystemHealthTool::class);
});

it('never exposes an unavailable tool', function () {
    foreach (ToolRegistry::tools() as $tool) {
        if (method_exists($tool, 'isAvailable')) {
            expect($tool::isAvailable())->toBeTrue();
        }
    }
});
Enter fullscreen mode Exit fullscreen mode

That 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.

The line I won't cross

The 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.

That'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.

It's open source, so the tier tables and the full tool list are in the repo: github.com/cleaniquecoders/laravel-mcp-kit.

Top comments (0)