stvnrlnd / laravel-mcp-guard
A governance layer for Laravel MCP servers: scoped permissions, rate limiting, audit logging, and human approval for mutating tool calls.
Requires
- php: ^8.2
- illuminate/contracts: ^12.41.1|^13.0
- illuminate/support: ^12.41.1|^13.0
- laravel/mcp: ^0.9.1
Requires (Dev)
- larastan/larastan: ^3.0
- laravel/pint: ^1.20
- orchestra/testbench: ^10.8|^11.0
- pestphp/pest: ^3.8.5|^4.4
- pestphp/pest-plugin-laravel: ^3.2|^4.1
Suggests
None
Provides
None
Conflicts
None
Replaces
None
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:
- An explicit
denyalways wins, even overallow => ['*']. - Anything
allowdoes not match is denied. Scopes are an allowlist. 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.