cd /news/developer-tools/the-generic-mcp-toolbox-tools-that-r… Β· home β€Ί topics β€Ί developer-tools β€Ί article
[ARTICLE Β· art-33850] src=dev.to β†— pub= topic=developer-tools verified=true sentiment=↑ positive

The Generic MCP Toolbox: Tools That Register Themselves

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.

read6 min views1 publishedJun 19, 2026

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.

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.

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');
    }
}

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,
    ]));
}

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.');
    }
    // ...
}

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.

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');
}

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.

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');

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.

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();
        }
    }
});

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

── more in #developer-tools 4 stories Β· sorted by recency
── more on @cleaniquecoders 3 stories trending now
sponsored brought to you by zahid.host 4,200+ EU-deployed projects
reading about agents? ship yours in a single git push.

Run your AI side-project on zahid.host

EU-based hosting, git-push deploys, automatic HTTPS, no cold starts. Free tier with a custom domain β€” perfect for shipping the agent you just read about.

$git push zahid main
β†’ Live at https://your-agent.zahid.host βœ“
Get free account β†’ Pricing
from €0/mo Β· no card required
LIVE [news/the-generic-mcp-tool…] indexed:0 read:6min 2026-06-19 Β· β€”