decocode/laravel-mcp

Read-only MCP server for Laravel apps — safe production data diagnostics for Claude (claude.ai + Claude Code).

Maintainers

Package info

github.com/decocode-pl/laravel-mcp

pkg:composer/decocode/laravel-mcp

Transparency log

Statistics

Installs: 45

Dependents: 0

Suggesters: 0

Stars: 0

Open Issues: 0

v0.3.6 2026-07-31 18:04 UTC

This package is auto-updated.

Last update: 2026-07-31 18:06:02 UTC


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_ro gets SELECT on 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 a db.* grant, so this is per-table.
  • --ro-mode=schema — a single GRANT 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:

  1. config/auth.php — add the mcp guard (Passport driver) + mcp_service provider (McpServiceAccount). Existing guards stay untouched.
  2. routes/ai.phpMcp::local('diagnostics', DiagnosticsServer::class).
  3. Three MySQL accounts + grantsphp artisan mcp:grants:print emits the exact GRANT statements; a DBA runs them (the package never provisions DB users). This is where read-only is actually enforced (mcp_ro is SELECT-only at the DB level).
  4. php artisan passport:install — keys + personal access client (required for Bearer tokens).
  5. Account → grant → token: mcp:account:create <name>mcp:account:grant <name> readmcp:token:issue <name> (the token is shown once).
  6. Review masking against the project schema — patterns match by column name and are best-effort: a bare name/city or an unlabelled person column (applicant) is not auto-masked. Extend masking.patterns / masking.allowlist per 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 bare ip, old/new audit values); make this part of the deployment's Definition of Done. Add --strict to 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.name vs tracks.name), use masking.table_patterns (mask table.column) instead of a global pattern; masking.table_allowlist un-masks a column in one table only. Keep table_allowlist keys specific — a glob key (or a matching view name) lifts the mask across every match.

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 default passport::authorize; (2) global token lifetimes (1d / 30d / 90d) for all Passport tokens; and you must set (3) passport.guard = mcp_web globally, which changes the OAuth resource owner for the whole app. If Passport is shared, either separate the concerns first or set mcp.oauth.manage_passport=false and 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.

  1. config/auth.php — add a mcp_web session guard (over mcp_service) so the minted token's sub is the service account, not the human operator.
  2. 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).
  3. bootstrap/app.php — append the operator gate to the web group: $middleware->web(append: [\Decocode\LaravelMcp\Http\Middleware\EnsureMcpOperator::class]).
  4. Answer "who may authorize a connector" — the one project-specific edit:
    • Gate ability (simple): Gate::define('mcp-operator', fn ($u) => $u->hasRole('Super Admin')), then mcp.oauth.operator_gate = 'mcp-operator' (+ operator_guard, operator_login_route).
    • McpOperatorCheck class (advanced): set mcp.oauth.operator_check to a class implementing authorize(Request): bool + serviceAccountId(): ?int — wins over the gate; use it for logic a Gate can't express or a dynamically chosen service account.
  5. MCP_HTTP_ENABLED=true + a public HTTPS endpoint (a dedicated domain or a tunnel), and point mcp.oauth.account at 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_ro SELECT-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 (toggle MCP_MASK_SCRUB_JSON, default on); whole tables hidden by a blocklist — with one documented gap: SHOW TABLES / SHOW TABLE STATUS still 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 and SHOW does 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 bare name/city is NOT auto-masked, so review your schema and extend masking.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 via masking.allowlist, or soften it via masking.partial (keyed by exact column name, e.g. a column named email → domain). Table-qualified rules (masking.table_patterns / masking.table_allowlist) close the bare-name gap 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, and read_query SELECTs — which are single-table by design, since read_query rejects 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 with php 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 a count_rows or read_query WHERE predicate — and only as col = 'value', col IN ('a','b') or col 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 by AND/OR/XOR/NOT — otherwise an operator could bind to a vetted equality and change what is compared ('379' = code >> 4 is 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 in general_log / slow_query_log / APM traces; check those before enabling. Never for low-entropy personal data (email is a one-call membership oracle, date_of_birth has ~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_describe reports a filterable flag per column, and mcp:masking:audit lists them for review.
  • Filterability applies to WHERE only — never to ORDER 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_query therefore splits its tail in two and relaxes only the first half. Note what read_query adds over count_rows here: 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.partial accepts fingerprint, 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 off APP_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. (last4 is digits-only — it mangles a hex code; use fingerprint.)
  • Capabilities (read / write / command:run) are stored in mcp_* 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 of outcome means the same thing.
  • A refused row carries refusal (a reason code plus the config rules involved) and its statement under sql as a skeleton: SELECT id FROM orders WHERE code = 'a1b2c3d4e5' is stored as SELECT ? 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-only covers 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_pattern instead — the blocklist entry (mcp_*), for the same reason and never the name the caller typed. It is a separate field on purpose: --masked-only answers "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) as Authorization: Bearer <token>. Issue with php artisan mcp:token:issue <account> (shown once), revoke with php 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.