Search by

stvnrlnd / laravel-mcp-guard

stvnrlnd

A governance layer for Laravel MCP servers: scoped permissions, rate limiting, audit logging, and human approval for mutating tool calls.

Package info

github.com/stvnrlnd/laravel-mcp-guard

pkg:composer/stvnrlnd/laravel-mcp-guard

Statistics

Installs: 0

Dependents: 0

Suggesters: 0

Stars: 0

v0.1.0 2026-07-28 03:03 UTC

This package is auto-updated.

Last update: 2026-08-28 03:20:46 UTC


README

A governance layer for Laravel MCP servers.

Laravel MCP lets you expose application capabilities to AI agents as tools. What it does not give you is a way to say which agent may call which tool, how often, or what happened afterwards. Its authorization story is HTTP middleware on the server route — all-or-nothing for the whole server — plus whatever Gate checks you remember to write inside each tool.

MCP Guard adds the missing layer:

  • Scoped permissions — per-agent tool allowlists, wildcards, and deny lists.
  • Rate limiting — per caller, per tool, on Laravel's own rate limiter.
  • Audit logging — every call, allowed or denied, with arguments and timing.
  • Approval workflow — hold mutating tool calls until a human approves them.

It integrates by substituting Laravel MCP's tools/call handler in the container. Your servers, tools, and routes stay exactly as they are.

MCP Guard is feature-complete: interception, scoped permissions, per-caller per-tool rate limiting, audit logging, and the human-approval workflow are all built and tested. An optional dashboard (a separate app) is intentionally not part of the package.

Requirements

  • PHP 8.2+ (PHP 8.3+ with Laravel 13, matching Laravel's own support policy)
  • Laravel 12 or 13
  • laravel/mcp ^0.9.1

Installation

composer require stvnrlnd/laravel-mcp-guard

The service provider is auto-discovered. Publish the config if you want to edit it:

php artisan vendor:publish --tag=mcp-guard-config

Quickstart

Wrapping an existing Laravel MCP server takes about five minutes. You don't change your server, tools, or routes — MCP Guard slots in front of them, and nothing is enforced until you configure it, so you can adopt one layer at a time.

1. Install and publish the config.

composer require stvnrlnd/laravel-mcp-guard
php artisan vendor:publish --tag=mcp-guard-config
php artisan migrate

The audit migration ships with the package and loads automatically, so migrate is all you need.

2. Authenticate the caller. MCP Guard identifies agents by the bearer token your auth middleware validates, so make sure your MCP route is authenticated (see Laravel MCP's auth docs):

// routes/ai.php
Mcp::web('/mcp/demo', DemoServer::class)->middleware('auth:sanctum');

3. Define your agents and their scopes in config/mcp-guard.php:

'agents' => [
    'reporting-bot' => [
        'token' => env('MCP_AGENT_REPORTING_TOKEN'),
        'mode' => 'read-only',
        'allow' => ['orders.*', 'customers.get'],
    ],
],

4. Turn on the layers you want (all optional, all inert by default):

'rate_limits' => [
    'default' => ['limit' => 60, 'per' => 'minute'],
],

'approval' => [
    'tools' => ['orders.delete' => ['requires_approval' => true]],
],

That's it. Every tool call is now scoped, rate limited, and audited, and the tools you gate are held for human approval — with no changes to your tool code.

How interception works

Laravel\Mcp\Server resolves its JSON-RPC method handlers out of the container by class name. MCP Guard binds Laravel\Mcp\Server\Methods\CallTool to its own subclass, so every tools/call request passes through the guard before the tool's handle() method is reached — with no changes to user code.

Denials are returned as MCP tool errors (a successful JSON-RPC result carrying isError: true), not as transport-level errors, so the calling agent actually receives and can reason about the reason it was blocked.

use McpGuard\Decision;
use McpGuard\Facades\McpGuard;

McpGuard::before(fn ($call, $tool) => $call->tool === 'delete_customer'
    ? Decision::deny('This agent may not delete customers.')
    : null);

Set MCP_GUARD_ENABLED=false to take the guard out of the path entirely.

Scopes

Agents are defined in config/mcp-guard.php and identified by the bearer token they present:

'agents' => [
    'reporting-bot' => [
        'label' => 'Reporting Bot',
        'token' => env('MCP_AGENT_REPORTING_TOKEN'),
        'mode' => 'read-only',
        'allow' => ['orders.*', 'customers.get'],
        'deny' => ['orders.delete'],
    ],
],

Rules, in the order they are applied:

  1. An explicit deny always wins, even over allow => ['*'].
  2. Anything allow does not match is denied. Scopes are an allowlist.
  3. mode => 'read-only' blocks mutating tools that would otherwise be allowed.

Scope is checked before the read-only rule so a tool the agent could never call is reported as out of scope, and "read-only" is reserved for tools it would otherwise have reached. Wildcards use Laravel's Str::is, so orders.* matches orders.list but not a tool literally named orders. Matching is case-sensitive, per the MCP spec's guidance on tool names.

While agents is empty, no scope policy is applied at all — installing the package never breaks a working server. Enforcement begins with your first agent. From then on, a caller with no token or an unrecognised one is denied; set unidentified_callers => 'allow' if you'd rather they pass.

Which tools are "mutating"?

MCP Guard reads Laravel MCP's own annotations first, so a well-annotated server needs no extra config:

#[Name('orders.delete')]
#[IsDestructive]
class OrdersDeleteTool extends Tool { /* ... */ }

#[IsReadOnly] marks a tool as non-mutating, #[IsDestructive] as mutating. A tool with neither is assumed to mutate, so a read-only agent cannot reach an unannotated tool by accident. Override per tool in config, or flip the default with assume_mutating => false:

'tools' => [
    'reports.generate' => ['mutating' => false],
],

Identifying callers

The default resolver reads the request's bearer token. Local (stdio) servers have no HTTP request, so either name a console_agent in config or register your own resolver:

McpGuard::resolveAgentUsing(fn () => request()->header('X-Agent-Id'));

Return an agent id, an Agent, or null.

Gate-style API

The same policy is callable directly, which is useful inside a tool handler or anywhere else you already do authorization:

McpGuard::authorize($token, 'orders.delete');   // throws ToolAuthorizationException
McpGuard::allows($token, 'orders.list');        // bool
McpGuard::denies($token, 'orders.delete');      // bool
McpGuard::check($token, 'orders.delete');       // Decision, never throws

ToolAuthorizationException extends Laravel's AuthorizationException, which laravel/mcp already converts into a spec-shaped tool error — so calling authorize() inside a tool needs no extra wiring.

Rate limiting

Each caller keeps an independent counter for each tool it calls, enforced with Laravel's own rate limiter — the same cache-backed primitive behind the throttle: middleware. A rejected call is returned as an MCP tool error carrying the limit and the seconds until it frees up, so the agent knows to back off.

'rate_limits' => [
    'enabled' => true,

    // Applied when no per-tool or per-agent limit matches. Null ships inert.
    'default' => ['limit' => 60, 'per' => 'minute'],

    'agents' => [
        'reporting-bot' => ['limit' => 1000, 'per' => 'hour'],
    ],

    'tools' => [
        'orders.delete' => ['limit' => 5, 'per' => 'minute'],
    ],
],

The limit for a given call is resolved by precedence: a per-tool limit wins over a per-agent limit, which wins over default. So a sensitive tool stays capped even for an otherwise-generous agent. Windows are second, minute, hour, or day.

Like scopes, rate limiting ships inert — with default => null and no overrides, nothing is throttled, so installing the package never slows a working server. It also runs after the scope check, so a call the agent wasn't allowed to make in the first place never consumes quota. Set enabled => false to switch the whole layer off.

Audit logging

Every intercepted call — allowed, denied, rate limited, or errored — is recorded to the mcp_calls table: caller, tool, status, reason, (redacted) arguments, (truncated) result, and duration in milliseconds. The migration ships with the package and loads automatically; publish it if you want to edit it:

php artisan vendor:publish --tag=mcp-guard-migrations

Query the trail with the McpCall model's scopes, which compose for the questions you actually ask:

use McpGuard\Audit\McpCall;

McpCall::forAgent('reporting-bot')->today()->get();   // this agent, today
McpCall::denied()->thisWeek()->get();                 // everything blocked this week
McpCall::slowest()->limit(10)->get();                 // the ten slowest calls

A denied call still records the arguments the agent tried to send, so the log answers "what did it attempt?", not just "it was blocked".

Redaction

Sensitive argument keys are masked before anything is written. Matching is case-insensitive and recurses through nested arrays:

'audit' => [
    'redact' => ['password', 'token', 'api_key', 'ssn'],
    'redaction_placeholder' => '[redacted]',
],

Results are JSON-encoded and truncated at max_result_length so a chatty tool can't bloat the table. Set store_arguments or store_results to false to drop those columns entirely, or enabled => false to turn the trail off.

Retention

McpCall is prunable. Rows older than retention_days (30 by default; null keeps them forever) are removed by Laravel's scheduler when you register the prune command:

// bootstrap/app.php  (or your console kernel)
use Illuminate\Support\Facades\Schedule;
use McpGuard\Audit\McpCall;

Schedule::command('model:prune', ['--model' => [McpCall::class]])->daily();

Approval workflow

A tool that requires approval isn't run on the first call. Instead the call is held in mcp_pending_calls, the agent is told it's queued, and a human approves or rejects it. Once approved, the agent retries the same call and it runs.

'approval' => [
    'enabled' => true,

    // Any tool classified as mutating requires approval...
    'approve_all_mutating' => false,

    // ...or gate specific tools (this overrides approve_all_mutating).
    'tools' => [
        'orders.delete' => ['requires_approval' => true],
    ],
],

The flow, end to end:

1. Agent calls orders.delete            -> held: "queued as [a1b2c3]. Retry once approved."
2. php artisan mcp-guard:pending        -> lists the waiting call
3. php artisan mcp-guard:approve a1b2c3 -> a human approves it
4. Agent retries the same orders.delete -> runs, and returns the result

A retry is matched to its approval by a fingerprint of the caller, tool, and arguments — so the agent doesn't thread a ticket through the tool's own arguments; it just calls again. Approval is one-shot: each approved call runs once, and an identical later call is held again.

The held call's arguments are stored redacted (same rules as the audit log), and the hold is recorded in the audit trail as pending_approval.

Approvals can be actioned from the (separate) dashboard, from the model ($pendingCall->approve() / ->reject()), or from the command line:

php artisan mcp-guard:pending                        # list what's waiting
php artisan mcp-guard:approve {ticket} --by=you       # approve
php artisan mcp-guard:reject {ticket} --reason=""    # reject

Nothing is held until you opt in — with approve_all_mutating off and no tools listed, every call runs as before.

Threat model

MCP Guard is a governance layer, not a security boundary on its own. It assumes your MCP route is already authenticated: it identifies agents by the token your auth middleware validates, and trusts the token-to-agent mapping you configure.

What it protects against

  • Over-broad agent access. By default a Laravel MCP server exposes every registered tool to any authenticated caller. Scopes reduce each agent to the tools it actually needs; deny lists and read-only mode add guardrails on top.
  • Runaway loops and abuse. Per-caller, per-tool rate limits cap how fast an agent can call a tool, containing a stuck agent or a misbehaving client.
  • Silent, unaudited mutations. Every call — allowed, denied, rate limited, or held — is recorded with caller, tool, arguments, and outcome, so there is an answer to "what did this agent actually do?".
  • Unsupervised destructive actions. Mutating tools can be held for explicit human approval before they run.

What it does not do

  • It is not authentication. It does not issue, validate, or rotate tokens. Put real auth (Sanctum, Passport, or your own middleware) in front of your MCP route; MCP Guard governs what an already-authenticated agent may do.
  • It does not sandbox tool code. An allowed call runs your tool with its full privileges. Scopes limit which tools run, not what a tool can reach once it does.
  • Redaction is best-effort. Argument redaction matches configured key names; a secret passed under an unexpected key, or embedded inside a larger value, is not caught.
  • Approval matches exact calls. A held call is matched to its approval by a fingerprint of caller, tool, and arguments; changing the arguments creates a new call that needs its own approval.

Treat it as defense in depth over a properly authenticated MCP server, not a replacement for one.

Testing

composer test

License

MIT. See LICENSE.