# The Generic MCP Toolbox: Tools That Register Themselves

> Source: <https://dev.to/nasrulhazim/the-generic-mcp-toolbox-tools-that-register-themselves-13m8>
> Published: 2026-06-19 11:21:00+00:00

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:

``` php
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**:

``` php
// 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](https://github.com/cleaniquecoders/laravel-mcp-kit).
