Laravel 13 · Model Context Protocol

An AI client can do everything a human can here, and an automated test keeps it that way.

Invoices, customers, tenancy and roles, built the ordinary way, then exposed through MCP with the exact same policies, validation and audit trail as the browser. If the two surfaces ever drift apart, CI fails.

The idea

One set of rules, two front doors

The web app and the MCP server are two views onto the same domain layer, and neither one is a special case.

Your app

A Laravel invoicing product with real login, tenancy, roles and a PDF pipeline; nothing about it is scaffolding for the demo.

The MCP layer

Every tool, resource and prompt goes through the same policies and Form Request validation as the controllers, kept in lockstep by a declared capability map.

Any AI client

Claude Desktop, Claude Code or Cursor connect over the same Sanctum/OAuth-protected endpoint and see exactly what the signed-in user is allowed to see.

Architecture

Two paths in, one set of policies

The browser and every MCP client converge on the same actions, policies and audit log: there is no side door with weaker rules.

Architecture diagram The browser and MCP clients (Claude Desktop, Claude Code, Cursor) both send requests through Laravel's HTTP and MCP layers, converging on shared Policies, Form Requests and Actions, which read and write one database and write to one audit log. Browser Claude Desktop OAuth 2.1 Claude Code / Cursor Sanctum bearer token Local agent stdio, same machine HTTP routes MCP server tools / resources / prompts Shared layer Form Requests Policies Actions Tenancy scopes Database tenant-scoped Audit log every MCP call

Live catalogue

Every tool, resource and prompt on the server

Rendered at build time from docs/data/capability-map.json, written by artisan docs:capability-map straight from the running server, so this list cannot go stale.

Resources

Prompts

Parity matrix

Every route, mapped or exempt

If a controller action ships without a matching tool (or a reasoned exemption), this table goes red and CI fails. The same data backs McpParityTest and the in-app /settings/mcp console.

Loading…
RouteMCP tool / exemptionCovered

CI runs this exact check on every push and fails the build if any row is not covered.

Security

The MCP layer is not a side door

Every write goes through the same guardrails, whether it came from a click or a tool call.

Tenancy

A global Eloquent scope keyed to the authenticated user's organisation applies identically to controllers and tools, so there is no separate MCP query path to forget to scope.

Per-tool authorization

Every write tool calls the same Laravel policy the matching controller action calls, via one shared authorizeTool() signal that laravel-auth-audit can scan for.

Prompt injection

Tool descriptions use defensive wording and never interpolate user data into instructions; free-text fields returned to the model are wrapped in explicit delimiters.

Confirmation gates

Destructive tools (invoices.delete, invoices.void) return a structured "are you sure" response instead of acting, until the caller explicitly confirms.

Idempotency

Every write tool accepts an optional idempotency key; a retried call with the same key replays the original result instead of repeating the mutation.

Audit trail

Every tool call is logged with who, what, arguments, outcome and duration, and surfaced at /audit/mcp: the answer to "what did the AI actually do last night."

Auth coverage

Closing the MCP blind spot

laravel-auth-audit scans routes, controllers and policies out of the box. MCP tools are invokable classes it has never heard of, so this repo teaches it to scan app/Mcp/Tools too, meaning a perfectly-covered HTTP surface can no longer hide an unguarded MCP one.

Auth-audit HTML report showing 100% coverage across HTTP and MCP routes

The audit runs in CI on every push, covering both surfaces, with the full HTML report published as a build artifact.

CI status

Auth-audit: 100% coverage (26/79 routes)

Latest CI run →

Quickstart

Running in a few minutes

git clone https://github.com/phoenix1331/laravel-mcp-invoicing-playground.git
cd laravel-mcp-invoicing-playground
make build
make up
make migrate
make seed
1. cloudflared tunnel --url http://localhost:8000
2. Set TUNNEL_HOSTNAME=http://xxxx.trycloudflare.com:8000 in .env, then make up
3. Claude Desktop -> Settings -> Connectors -> Add custom connector
4. Server URL: https://xxxx.trycloudflare.com/mcp/invoicing
5. Approve the OAuth consent screen when prompted
{
  "mcpServers": {
    "invoicing": {
      "command": "docker",
      "args": ["compose", "exec", "-T", "app", "php", "artisan", "mcp:start", "invoicing"]
    }
  }
}

Screenshots

A few screens

Dashboard screenshot
Invoice editor screenshot
MCP console screenshot
Rendered invoice PDF screenshot