decocode / laravel-mcp
Read-only MCP server for Laravel apps — safe production data diagnostics for Claude (claude.ai + Claude Code).
Requires
- php: ^8.2
- illuminate/console: ^11.45|^12.0|^13.0
- illuminate/contracts: ^11.45|^12.0|^13.0
- illuminate/database: ^11.45|^12.0|^13.0
- illuminate/http: ^11.45|^12.0|^13.0
- illuminate/support: ^11.45|^12.0|^13.0
- laravel/mcp: ^0.8
- laravel/passport: ^12.0|^13.0
Requires (Dev)
- orchestra/testbench: ^9.5|^10.0
- pestphp/pest: ^3.5
- pestphp/pest-plugin-laravel: ^3.0
README
Read-only MCP server for Laravel applications — safe production data diagnostics for Claude
(claude.ai + Claude Code). Built on the official laravel/mcp.
Requirements
- PHP
^8.2 - Laravel
^11.45 | ^12 | ^13(Laravel 10 must be upgraded first) - Laravel Passport (OAuth guard for claude.ai custom connectors)
Infrastructure — three MySQL accounts (per project)
Read-only is enforced at the database level, not just in code.
| Account | Grants | Scope | When |
|---|---|---|---|
mcp_ctl |
SELECT, INSERT, UPDATE, DELETE | only mcp_*, oauth_*, mcp_audit_log |
now |
mcp_ro |
SELECT | business tables minus blocklist | now |
mcp_rw |
SELECT, INSERT, UPDATE (no DELETE/DDL) | business tables in scope | when write is enabled |
Generate the exact grant statements with php artisan mcp:grants:print (hand them to a DBA — the
command only prints, never executes). Two read-grant modes:
--ro-mode=per-table(default) —mcp_rogetsSELECTon business tables only; secret tables (mcp_*,oauth_*,sessions, tokens) are excluded at the database level. This is the pilot / high-PII posture (DoD §13.7). MySQL cannot subtract a table from adb.*grant, so this is per-table.--ro-mode=schema— a singleGRANT SELECT ON db.*, relying only on the app-level blocklist. Simpler, but it grants DB-level SELECT on auth/token/session tables too — not for high-PII / pilot.
Per-table means new tables aren't readable until granted. After adding a business table, run
php artisan mcp:grants:diff — it reads mcp_ro's current grants and prints only the missing
GRANT lines (one or two), so you don't re-run the whole script. To move from schema to per-table:
REVOKE SELECT ON db.* FROM mcp_ro first, then apply mcp:grants:print (apply in one session — mcp_ro
loses read in between).
.env
MCP_DB_HOST=127.0.0.1 MCP_DB_PORT=3306 MCP_DB_DATABASE=your_database MCP_DB_CTL_USERNAME=mcp_ctl MCP_DB_CTL_PASSWORD= MCP_DB_RO_USERNAME=mcp_ro MCP_DB_RO_PASSWORD= # Public exposure (channel B, for claude.ai) — off by default MCP_HTTP_ENABLED=false MCP_HTTP_DOMAIN=mcp.example.com MCP_ROUTE_PREFIX=mcp # Channel B OAuth — service account (token `sub`) + operator hook MCP_OAUTH_ACCOUNT=diag # provisioned via mcp:account:create MCP_OAUTH_OPERATOR_GUARD=web # guard holding the human operator MCP_OAUTH_OPERATOR_GATE=mcp-operator # Gate ability; or set mcp.oauth.operator_check in config MCP_OAUTH_OPERATOR_LOGIN_ROUTE=login # where to bounce an unauthenticated operator # MCP_OAUTH_REDIRECT_ALLOWLIST defaults to the claude.ai/claude.com callbacks # IP allowlist — on by default, local only MCP_IP_ALLOWLIST_ENABLED=true MCP_IP_ALLOWLIST=127.0.0.1,::1
Install
composer require decocode/laravel-mcp php artisan mcp:install
mcp:install publishes config + migrations, runs the migrations, and prints the manual steps
(the mcp auth guard for config/auth.php and the routes/ai.php entries) — these are printed
rather than auto-applied, because those files differ per project and version.
Channel A (Claude Code, Bearer) — the primary path
Beyond composer require + mcp:install, six steps get channel A live. mcp:install prints the
first two verbatim:
config/auth.php— add themcpguard (Passport driver) +mcp_serviceprovider (McpServiceAccount). Existing guards stay untouched.routes/ai.php—Mcp::local('diagnostics', DiagnosticsServer::class).- Three MySQL accounts + grants —
php artisan mcp:grants:printemits the exactGRANTstatements; a DBA runs them (the package never provisions DB users). This is where read-only is actually enforced (mcp_rois SELECT-only at the DB level). php artisan passport:install— keys + personal access client (required for Bearer tokens).- Account → grant → token:
mcp:account:create <name>→mcp:account:grant <name> read→mcp:token:issue <name>(the token is shown once). - Review masking against the project schema — patterns match by column name and are best-effort:
a bare
name/cityor an unlabelled person column (applicant) is not auto-masked. Extendmasking.patterns/masking.allowlistper database before exposing data. Re-do this for every new project — a different schema means different PII.- Run
php artisan mcp:masking:audit— a full-schema scan that lists PII-suspect columns that are not masked, table by table. Reviewing masking against a diff or a hand-built list misses gaps a full scan catches (legacy/foreign column names, a bareip,old/newaudit values); make this part of the deployment's Definition of Done. Add--strictto fail CI on any gap or any table it could not scan. The heuristic is deliberately broad — expect false positives to dismiss, and treat an empty result as "no suspect column slipped the current config", not proof of no PII. It fails (never green) on zero readable tables or an un-introspectable table. - For a column that is PII in one table but a harmless label in another (
customers.namevstracks.name), usemasking.table_patterns(masktable.column) instead of a global pattern;masking.table_allowlistun-masks a column in one table only. Keeptable_allowlistkeys specific — a glob key (or a matching view name) lifts the mask across every match.
- Run
Channel B (claude.ai, OAuth 2.1 + PKCE)
PREREQUISITE — Passport must be dedicated to MCP in this app. Channel B configures Passport globally, so enabling it in an app that also uses Passport for its own OAuth will disrupt that OAuth. Concretely, with
http.enabled+manage_passport(default on) the package sets: (1) the consent view — bound as a callback so only MCP connectors (redirect on the allowlist) get the MCP screen and other clients keep the defaultpassport::authorize; (2) global token lifetimes (1d / 30d / 90d) for all Passport tokens; and you must set (3)passport.guard = mcp_webglobally, which changes the OAuth resource owner for the whole app. If Passport is shared, either separate the concerns first or setmcp.oauth.manage_passport=falseand wire Passport yourself.
php artisan mcp:install --with-oauth scaffolds channel B: it publishes the consent view and prints
the wiring. The package ships the whole operator-authorization layer — an operator middleware, the
consent screen, the redirect allowlist and the Passport bootstrap — so you only wire it up and fill
one project-specific hook.
config/auth.php— add amcp_websession guard (overmcp_service) so the minted token'ssubis the service account, not the human operator.config/passport.php—'guard' => 'mcp_web'only if Passport is used solely for MCP in this app (it's a global setting; an app using Passport for other things must keep them separate).bootstrap/app.php— append the operator gate to thewebgroup:$middleware->web(append: [\Decocode\LaravelMcp\Http\Middleware\EnsureMcpOperator::class]).- Answer "who may authorize a connector" — the one project-specific edit:
- Gate ability (simple):
Gate::define('mcp-operator', fn ($u) => $u->hasRole('Super Admin')), thenmcp.oauth.operator_gate = 'mcp-operator'(+operator_guard,operator_login_route). McpOperatorCheckclass (advanced): setmcp.oauth.operator_checkto a class implementingauthorize(Request): bool+serviceAccountId(): ?int— wins over the gate; use it for logic a Gate can't express or a dynamically chosen service account.
- Gate ability (simple):
MCP_HTTP_ENABLED=true+ a public HTTPS endpoint (a dedicated domain or a tunnel), and pointmcp.oauth.accountat the provisioned service account.
The consent screen is always shown — never auto-approved. Client registration is public + dynamic,
so a silent approve would let one phished operator click authorize an attacker's client; the redirect
allowlist pins the delivery channel (claude.ai), the consent click pins the recipient. The package
applies this Passport bootstrap automatically when channel B is on (opt out with
mcp.oauth.manage_passport=false). Make your own RODO/GDPR decisions on what to mask.
Channel A is repeatable and mostly config. Channel B adds the wiring above — five steps, one of them the operator hook.
Security model (summary)
- Read-only enforced by the
mcp_roSELECT-only user (DB level) + capability checks (app level). - Deletion is impossible: no data-plane user ever holds DELETE, plus a global kill-switch.
- PII masked deny-by-default by column-name pattern — the shipped patterns cover credentials,
financial/national ids and direct PII (e-mail, phone/mobile, person-name columns incl. no-underscore
variants, street/address incl.
ip_address, postal/zip code, date of birth, user-agent) — plus recursive scrubbing of PII nested in JSON / PHP-serialized columns (toggleMCP_MASK_SCRUB_JSON, default on); whole tables hidden by a blocklist — with one documented gap:SHOW TABLES/SHOW TABLE STATUSstill reveal that a blocklisted table EXISTS and its metadata (row estimate, size, update time), because the blocklist scans the statement for the table's NAME andSHOWdoes not contain it. No row content is exposed. Matching is best-effort by column name: broad categories (*address*,*ssn*,*zip*,*dob*,*birth*) can't slip a variant, but person names can't go broad (a*name*would swallow entity names) — an unlabelled person column (applicant) or a barename/cityis NOT auto-masked, so review your schema and extendmasking.patterns. Broad*address*also masks FK columns (address_id) — allowlist the ones you need for joins. Verification timestamps (email_verified_at) are un-masked by default. Un-mask a field viamasking.allowlist, or soften it viamasking.partial(keyed by exact column name, e.g. a column namedemail→ domain). Table-qualified rules (masking.table_patterns/masking.table_allowlist) close the bare-namegap at the source — mask a column only in the table(s) where it is PII — and apply wherever the tool knows the source table (schema_describe,count_rows,order_inspect, andread_querySELECTs — which are single-table by design, sinceread_queryrejects JOINs and comma-joins (a multi-table result has no single source table and would fall back to name-based masking, letting a per-table-masked column leak). Audit coverage withphp artisan mcp:masking:audit. - Filterability is a separate decision from visibility. Masking a column also removes it from every
predicate (filtering on a secret turns a row count into an existence oracle) — but diagnosis often
needs to ask about a value it already holds, without ever seeing it: a voucher code quoted in a
customer's ticket, an order reference from an e-mail.
masking.filterable(table-pattern => column-patterns) marks a column that stays fully masked in output yet may be referenced in acount_rowsorread_queryWHEREpredicate — and only ascol = 'value',col IN ('a','b')orcol IS [NOT] NULL, with the value quoted (a bare number would compare numerically and match on a prefix). Ranges,LIKE/REGEXP, arithmetic and bitwise operators, casts, functions wrapping the column and comparisons to another column are refused: those make a masked value searchable instead of confirmable. The term must also stand on its own, joined only byAND/OR/XOR/NOT— otherwise an operator could bind to a vetted equality and change what is compared ('379' = code >> 4is a bisection, not an equality). Deny-by-default (empty map ships) and table-qualified; a wildcard table key is dropped rather than honoured, since filterability is a judgement about one column in one table. Enable it only where the value space is too large to walk (≳2^40 — a 10+ character code, a UUID, a hash). The 50-value cap is per call, not per caller: at the shipped throttle of 60 req/min it still allows ~4.3M guesses a day, so an 8-digit reference falls in ~23 days and a 6-digit code in ~4 hours. Order/invoice numbers and external references are usually sequential — not candidates. Note also that the compared value reaches MySQL as a literal, so it can land ingeneral_log/slow_query_log/ APM traces; check those before enabling. Never for low-entropy personal data (emailis a one-call membership oracle,date_of_birthhas ~29k values): there the count alone answers "is this person in your database". Columns used this way are recorded in the audit trail (masked_predicate),schema_describereports afilterableflag per column, andmcp:masking:auditlists them for review. - Filterability applies to
WHEREonly — never toORDER BY/GROUP BY/HAVING. Those keep the blanket refusal: the argument for filtering is about equality against a value you already hold, while sorting by a hidden value discloses its order across every row returned and grouping discloses its cardinality — neither is something a per-column opt-in can make safe.read_querytherefore splits its tail in two and relaxes only the first half. Note whatread_queryadds overcount_rowshere: it returns the ROW, so the caller learns the record a code belongs to, not just how many rows carry it. That is usually the point (the order behind a voucher code from a customer's ticket) and it is also the extra disclosure — weigh it when opting a column in. - Proving a duplicate without revealing the value:
masking.partialacceptsfingerprint, which emits a keyed HMAC of the value ([fp:9a3c1f2b7d04]) instead of the placeholder. Equal values read as equal fingerprints, so "did this card issue the same code twice" is answerable from ordinary masked output with no predicate relaxation at all. Keyed offAPP_KEY(nobody can confirm a candidate value offline; nothing correlates across deployments) and fail-closed without one. It does reveal the column's equivalence classes — right for an identifier, a linkage leak for personal data, so it is opt-in per exact column name. (last4is digits-only — it mangles a hex code; usefingerprint.) - Capabilities (
read/write/command:run) are stored inmcp_*tables and managed via artisan commands — never in the repo.
Tools (read-only)
Each tool self-filters by the caller's capabilities — a read-only identity only ever sees the
read tools, and execution is re-checked server-side. Every call is recorded to a fail-closed audit
trail (if the call cannot be logged, no data is returned).
| Tool | Capability | What it does |
|---|---|---|
read_query |
read |
Ad-hoc SELECT against mcp_ro. SELECT-only, single statement, forced LIMIT, blocked tables refused, sensitive columns + nested JSON masked. To keep masking from being evaded, the projection allows only * / t.* / bare columns / numeric literals — function calls, expressions, aliasing, UNION/INTERSECT/EXCEPT, CTEs, FROM-subqueries, JOINs/comma-joins and JSON extraction are rejected (each can surface a column past name-based or table-qualified masking). A masked column may appear in WHERE only if the project marks it masking.filterable, and never in ORDER BY / GROUP BY / HAVING. EXPLAIN <SELECT> is held to the same rules as the SELECT it wraps; after an EXPLAIN/DESCRIBE only a SELECT or a plain DESCRIBE <table> is accepted (ANALYZE, FORMAT=JSON/TREE, TABLE … and FOR CONNECTION are refused). A double quote outside a string literal is refused too — its meaning depends on sql_mode, so use ' for values and back-ticks for identifiers — as are control bytes (C0 except tab/newline/CR, plus DEL), which can steer how MySQL parses a comment, a backslash before a quote inside a literal (write '' instead of \'), and a back-quoted identifier containing anything outside [A-Za-z0-9_$]. Single-table only — aggregates live in count_rows. |
count_rows |
read |
Row count for a table, optional WHERE. The projection is fixed to COUNT(*) (never a column value), the table must be a plain non-blocked identifier, and the assembled query is guarded. Masked columns may not be referenced — except those the project marks masking.filterable, which accept an equality test against a value the caller already holds (never a range, LIKE or function). Fills the aggregation gap read_query leaves. |
schema_describe |
read |
Lists readable tables, or a table's columns. Blocked tables are hidden; each column notes whether its values are masked and whether it is filterable — freely for an un-masked column, equality-only, and only in a WHERE, for a masked one. Returns no row data. |
order_inspect |
read |
Example domain tool — fetches one record + related rows by id. Config-driven; only registers once mcp.tools.order_inspect is set, so the package ships no project-specific schema. |
The server exposing these tools is registered in the app (not auto-edited by mcp:install).
Add to routes/ai.php:
use Decocode\LaravelMcp\Servers\DiagnosticsServer; use Laravel\Mcp\Facades\Mcp; // Local/stdio (Claude Code, MCP Inspector via `php artisan mcp:inspector`): Mcp::local('diagnostics', DiagnosticsServer::class); // Public HTTPS (claude.ai), behind the mcp guard + IP allowlist + throttle: Mcp::oauthRoutes(); Mcp::web('/mcp/diagnostics', DiagnosticsServer::class) ->middleware(['auth:mcp', 'mcp.ip-allowlist', 'throttle:'.config('mcp.http.throttle')]);
Reading the audit trail
Every call lands in mcp_audit_log on the control-plane connection — the ones that ran, the ones the
database rejected, and (since 0.3.6) the ones a guard refused before they reached the database.
mcp:audit:report is how you read it. This is not a convenience: the trail is the compensating
control the masking.filterable relaxation is argued on. The cap on how many values one call may
test is defended by "a sweep would show up as a burst in the audit" — which is only true if somebody
looks, and only useful if the refused attempts are in there too. They are the ones that matter most:
where a column is not marked filterable, every probe against it is refused, so before 0.3.6 the
most suspicious traffic was the only traffic that left no trace.
php artisan mcp:audit:report # last 7 days php artisan mcp:audit:report --since="-2 hours" # around an incident php artisan mcp:audit:report --masked-only # only calls that filtered on a masked column php artisan mcp:audit:report --tool=read_query --outcome=rejected php artisan mcp:audit:report --since=2026-07-01 --json | jq .masked_column_activity
The report ends with per-column counts for masked-column predicates, split by account and by outcome —
executed (the project marked the column masking.filterable, so the call was answered) versus
refused (it did not, and a guard stopped the call). The shape to look for is one column, one
account, many calls in a short window: that is a value sweep, not a diagnosis. Refusals in that shape
mean someone kept hitting a wall; executions in that shape mean they were being answered. --outcome
takes ok, rejected (a guard refused the call), denied (the identity lacks the capability),
invalid (the arguments never got as far as a guard), failed (the database refused) or unknown
(the row cannot be read as expected — never hidden by a filter, whatever you pass). denied is the
one to reach for after a grant is revoked: it is how an identity sweeping tools it may not use looks.
Counts cover the whole window, not the printed page: --limit caps the rows shown, never what was
counted, so a sweep that falls off page one still appears in the per-column totals.
It reads only, and it is an operator-side command: the artisan-over-MCP channel is not implemented,
so no command can be invoked over MCP at all. (config('mcp.commands.denylist') names mcp:* for the
day it is — that entry is a stated intention, not yet an enforced control.) Its output carries
production data: a statement that ran is stored with the literals compared against masked columns cut
out, and everything else is verbatim SQL; one a guard refused is stored as a skeleton (see below).
The table argument is an exception on both paths — it is recorded close to what the caller wrote
(bounded, control bytes removed, runs of three or more digits blanked, so orders_2024 reads as
orders_#), because a trail that cannot say which table was asked about answers nothing.
Straight SQL, if you would rather not go through artisan:
-- What ran in the last hour, newest first. SELECT id, created_at, account_id, name, row_count, parameters FROM mcp_audit_log WHERE created_at >= NOW() - INTERVAL 1 HOUR ORDER BY id DESC; -- Masked-column predicates per account — the burst check (MySQL 5.7+/8). -- Reads the FIRST masked column of each call; a predicate touching two of them is -- counted under one. `mcp:audit:report --masked-only` counts every column separately. SELECT account_id, JSON_UNQUOTE(JSON_EXTRACT(parameters, '$.masked_predicate[0]')) AS masked_column, COUNT(*) AS calls, MIN(created_at) AS first_seen, MAX(created_at) AS last_seen FROM mcp_audit_log WHERE JSON_EXTRACT(parameters, '$.masked_predicate') IS NOT NULL AND created_at >= NOW() - INTERVAL 7 DAY GROUP BY account_id, masked_column ORDER BY calls DESC;
Two things to know when reading rows by hand:
- A call that returned normally records
"outcome":"ok"— but rows written before 0.3.6 recorded nothing at all on success, so on an older trail the absence ofoutcomemeans the same thing. - A refused row carries
refusal(a reason code plus the config rules involved) and its statement undersqlas a skeleton:SELECT id FROM orders WHERE code = 'a1b2c3d4e5'is stored asSELECT ? FROM ? WHERE ? = ?. Everything that is not a known SQL keyword becomes?— identifiers, literals, numbers, aliases alike. Nothing is known about a refused call at the time it is logged, so the row keeps the shape of the attempt and nothing else. Expect to lose names you wanted to see; that is the trade, and it is what makes "no value can be in this field" a statement rather than a hope. - A refusal about a masked column also records
masked_predicate, so--masked-onlycovers refused probes and not only the calls that ran. Since 0.3.6 that field holds the config rule that made the column masked (orders.gift_card,*voucher_code*) rather than the token from the query — a token that matched*voucher_code*may itself BE a voucher code. Rows written earlier hold column names; both read fine. - A refusal about a blocked table records
blocked_patterninstead — the blocklist entry (mcp_*), for the same reason and never the name the caller typed. It is a separate field on purpose:--masked-onlyanswers "who went after a masked column", and a blocked-table refusal is not that. Find those with--outcome=rejected; the pattern is shown in the arguments column.
The trail is append-only and the package never prunes it. Retention is the deployment's call (it holds
production SQL); created_at is indexed, so a date-bounded DELETE is cheap.
Authorization & exposure
Tools resolve the calling identity through a dedicated mcp auth guard (Passport driver over
McpServiceAccount) — the application's own guards (api, sanctum, …) are untouched. Capability
gating denies by default, so an identity only sees and runs the tools its grants allow.
- claude.ai (public HTTPS): full OAuth 2.1 —
Mcp::oauthRoutes()publishes the discovery (.well-known/*) + PKCE endpoints. Channel B is off by default (MCP_HTTP_ENABLED); enable it with an explicit domain, IP allowlist and throttle. - Claude Code (SSH/tunnel): a Passport personal access token (scope
mcp:use) asAuthorization: Bearer <token>. Issue withphp artisan mcp:token:issue <account>(shown once), revoke withphp artisan mcp:token:revoke <account>.
MCP_HTTP_ENABLED=false # channel B (public HTTPS) — opt in MCP_HTTP_DOMAIN=mcp.example.com MCP_HTTP_THROTTLE=60 # requests/minute on the MCP route MCP_IP_ALLOWLIST_ENABLED=true # default: local only MCP_IP_ALLOWLIST=127.0.0.1,::1
End-to-end OAuth against a live claude.ai connector and Claude Code is verified against a running application with Passport installed, so it is out of scope for this package's own test suite.