afiqsazlan / laravel-mcp-safe-sql
Read-only, de-identified MCP access to your Laravel app's database — SELECT-only enforcement, auto-LIMIT, statement timeouts, fail-closed PII pseudonymization, and a context-cheap Telescope digest.
Package info
github.com/afiqsazlan/laravel-mcp-safe-sql
pkg:composer/afiqsazlan/laravel-mcp-safe-sql
Requires
- php: ^8.2
- illuminate/contracts: ^12.0
- illuminate/database: ^12.0
- illuminate/support: ^12.0
- laravel/mcp: ^0.9.3
Requires (Dev)
- laravel/pint: ^1.18
- orchestra/testbench: ^10.0
- pestphp/pest: ^3.0
- pestphp/pest-plugin-laravel: ^3.0
- phpstan/phpstan: ^2.1
README
Read-only, de-identified access to your Laravel application's database for AI agents — served from inside your own app, under your own OAuth and your own permissions.
SELECT id, name, email, status FROM customers LIMIT 2
id name email status
1 [name:15cd0e0f] [email:51e84a2b] active
2 [name:2b194dbb] [email:f1100654] churned
Why this exists
Three things have to be true at once before you can point an agent at a production database, and no existing tool does all three.
| Overlaps on | Gap | |
|---|---|---|
| Laravel Boost | Database Query, Database Schema tools |
--dev only, stdio, local machine. No PII layer, no remote HTTP/OAuth. Built for a coding agent on your laptop. |
| The telescope-mcp packages | Telescope access | Compete on coverage — one advertises 19 tools. Each call risks dumping 10k+ tokens. |
mcp-server-mysql, mcp-read-only-sql |
Read-only SQL guardrails | External Node/Python processes holding their own DB credentials. No Laravel awareness, no app permissions, no anonymization. |
| The Laravel anonymization packages | PII column maps | All anonymize at rest or mask Eloquent serialization. None handle arbitrary result rows from free-form SQL. |
mcp-server-conceal |
In-flight pseudonymization | Generic proxy, extra network hop, no Laravel or schema awareness. |
The combination is the product: read-only SQL over a real database, pseudonymization of arbitrary result sets, served from inside the Laravel app under its own auth.
Installation
composer require afiqsazlan/laravel-mcp-safe-sql php artisan safe-sql:install
safe-sql:install publishes the config, asks which connection the profile
should read, records it in .env, and scaffolds the server class. Add
--routes for the example route file. Everything it does is also doable by
hand — see below.
Quick start
1. Define a profile in config/safe-sql.php:
'profiles' => [ 'research' => [ 'connection' => 'mysql_replica', 'anonymize' => true, 'tools' => ['sql', 'schema'], 'instructions' => resource_path('mcp/research.md'), ], ],
2. Declare a server — a subclass per profile, and nothing else:
namespace App\Mcp\Servers; use Afiqsazlan\SafeSql\Servers\SqlMcpServer; class ResearchServer extends SqlMcpServer { protected string $profile = 'research'; }
3. Register the route, with your own middleware:
use Afiqsazlan\SafeSql\Http\OAuthRoutes; OAuthRoutes::register(); Route::middleware(['auth:oauth', 'scope:mcp:use', 'can:access-research']) ->group(fn () => Mcp::web('mcp/research', ResearchServer::class));
The package enforces read-only SQL and pseudonymization. It does not decide who may connect — that stays your application's job.
Telling two endpoints apart
Give every profile a label:
'research' => ['label' => 'production', 'anonymize' => true, ...], 'debug' => ['label' => 'staging', 'anonymize' => false, ...],
A client connected to both sees the same tool names under each server, and picks between them mostly on wording. Choosing wrong is not symmetric: a question about real usage answered from staging returns a plausible number that is simply false, and nothing in the result looks wrong.
The label therefore appears in three places the model actually reads — the
tool descriptions, the server instructions, and a source field on every
result:
{ "source": "production (values pseudonymized)", "profile": "research", "rowCount": 128, ... }
The instructions tell the agent to ask which environment a question is about when it could be either, to name the database it used, and never to combine rows or carry a pseudonym across servers.
If you would rather not rely on any of that, connect one server at a time.
Use one endpoint per sensitivity tier
Give each profile its own endpoint rather than merging them. Anonymized production data and literal staging data need to be grantable independently: nobody debugging staging should acquire production access as a side effect. Server instructions also stay resident for the entire session, so a merged endpoint makes every conversation pay for both instruction sets.
What "safe" means
Precision matters more than reassurance here, so this section says what is guaranteed and what is not.
Read-only enforcement
Only SELECT, WITH and plain EXPLAIN reach the database. Writes, schema
changes, SET, procedures, filesystem access (LOAD_FILE, INTO OUTFILE)
and stacked statements are refused. EXPLAIN ANALYZE and
EXPLAIN FOR CONNECTION are refused because they execute the statement.
The validator mirrors MySQL's own comment rules rather than approximating
them, because every divergence is a bypass. It rejects /*! … */ executable
comments outright, and treats -- as a comment only when followed by
whitespace, as MySQL does.
This is defense in depth, not a sandbox. Give the connection a genuinely read-only database user. A validator is a parser, and parsers can be wrong; database grants cannot be argued with.
Pseudonymization
Fail-closed: a value is returned raw only when positively established as safe. Decisions run in this order, first match wins.
| Rule | Result | |
|---|---|---|
| 1 | null, boolean, empty |
raw |
| 2 | Label is known PII | pseudonymized |
| 3 | Value matches a PII shape | pseudonymized |
| 4 | Value is numeric | raw |
| 5 | Label is known safe | raw |
| 6 | Long free text | pseudonymized |
| 7 | Anything else | pseudonymized |
Step 3 is why this is not just a column list. The anonymizer only ever sees the result column label, and labels are chosen by whoever wrote the query, so trusting them alone fails in both directions:
SELECT email AS e FROM users -- defeats a label denylist SELECT email AS id FROM users -- defeats a label allowlist
Value-shape detection catches both, because the value still looks like an address whatever it is called.
What pseudonymization does not cover
- Values with no recognisable shape. A bare first name in an unmapped
column is caught by step 7 only if it is a string; if your schema stores an
identifier as an integer under an unrecognised label, it passes as a number.
Add such columns to
pii_columns. - No provenance resolution. Result labels are not mapped back to source
columns via
information_schema. Detection is by label and by value shape. - Aggregates leak shape, not values.
COUNT(*) WHERE email = '…'returns a real number. Pseudonymization protects values in result rows; it does not stop a determined operator inferring facts through repeated queries. - It is not access control. Anything the connection can read, the agent
can ask about. Scope the database user, and use
excluded_tables. - Free text is best-effort. Long text is pseudonymized wholesale, but inline redaction inside shorter strings only catches configured patterns.
Pseudonymization reduces exposure. It is not anonymization in the regulatory sense, and you should not describe it that way to your compliance team.
Token stability
Tokens are [type:hash], where the hash is keyed by a salt.
salt.lifetime |
Same value → same token | Use when |
|---|---|---|
session (default) |
within one MCP session | Normal. Multi-query analysis works; tokens die with the session. |
request |
within one tool call | Maximum privacy, and cross-query correlation breaks silently. |
config |
forever, across sessions and deploys | Long-running analysis. This is a durable pseudonym for a real person — treat the secret accordingly. |
Under HTTP transport each request is a separate PHP process, so session-stable tokens are derived from the MCP session id rather than held in memory.
The ergonomic cost, up front
Fail-closed means unrecognised short strings are pseudonymized, including harmless ones:
payment_method 'fpx' → [value:221a3546]
The tool cannot tell 'fpx' from a first name. Rather than classify a mature
schema by hand, generate a starting point:
php artisan safe-sql:classify --profile=research php artisan safe-sql:classify --profile=research --write=config/safe-sql-columns.php
It inspects every column, skips the ones you have already classified, and
suggests the rest — reading a sample of real values to catch identifiers whose
names give nothing away. Use --no-sample to classify from column names alone
if the command must not read production rows; opaque columns then stay
invisible, which is the trade.
The command prints classifications, never data.
Its most valuable output is the contradiction list: columns currently returned
raw whose contents look like PII. safe_column_patterns passes anything
ending in _id, and a column named whatsapp_id holding 60123456789 is a
phone number that convention waves through.
Everything it emits is a suggestion for you to review. Read safe_columns
twice — every entry there is a decision to return real values.
Telescope
One tool, not nineteen. The entry point is a uuid pasted from a Telescope URL, and it returns a digest of the whole batch — request line, exceptions with their top frames, the SQL that ran, log entries — for a few hundred tokens instead of the tens of thousands a full response body costs.
Heavy fields are opt-in:
telescope uuid=9f2a… compact digest
telescope uuid=9f2a… include=payload add the request payload
telescope uuid=9f2a… include=trace add the full stack trace
Everything that can carry user data is pseudonymized, and credential-bearing
headers (Authorization, Cookie, X-API-Key, …) are removed outright and
cannot be retrieved. A denylist is sound for headers, unlike for result
columns: header names are fixed by the protocol and cannot be aliased.
Free text is redacted inline so it stays debuggable:
Duplicate entry [email:51e84a2b] for key users_email_unique
The tools compose: digest a batch, pull the one heavy field you still need, then verify what was actually written with SQL. Telescope tells you what the request did; SQL tells you what survived it.
Instructions
Server instructions are composed per profile and shipped with the package. A profile that does not pseudonymize does not carry the pseudonymization rules; one without the telescope tool does not carry its workflow.
Your own instructions are appended, not substituted:
'instructions' => resource_path('mcp/research.md'),
This matters. An agent that does not know [email:51e84a2b] is a pseudonym
will write WHERE email = '[email:51e84a2b]', get zero rows, and report that
the customer does not exist. That is a wrong answer, not a worse
conversation — which is why it lives in instructions rather than a skill, and
why supplying your own cannot silently drop it.
OAuth
OAuthRoutes::register() serves discovery metadata without Dynamic Client
Registration. Laravel MCP's built-in Mcp::oauthRoutes() also exposes
POST /oauth/register and advertises a registration_endpoint, with no way
to opt out.
DCR lets anything that can reach the endpoint mint its own OAuth client. Reasonable for a public MCP service; wrong in front of a production database, where the legitimate clients are few and known:
php artisan passport:client --public --name="Claude"
Use Mcp::oauthRoutes() instead if you actually want open registration.
Configuration
| Key | Purpose |
|---|---|
profiles.* |
connection, anonymize, tools, instructions, descriptions |
limits |
row cap, response cap, timeout, cell length, Telescope caps |
anonymizer.salt |
lifetime and secret |
anonymizer.pii_columns |
label → token type |
anonymizer.safe_columns / safe_column_patterns |
labels that may pass raw |
anonymizer.value_patterns |
shape detection, applied regardless of label |
schema.core_tables |
described in full. Empty means index-only, the cheap default |
schema.excluded_tables |
omitted from the digest |
telescope.header_denylist |
headers removed outright |
uri_scheme |
scheme for resource URIs |
Requirements
- PHP 8.2+
- Laravel 12
laravel/mcp^0.9
laravel/mcp is pre-1.0, and that is this package's largest maintenance
risk. The constraint is deliberately tight. Expect a package release to be
needed for each laravel/mcp minor bump.
MySQL is the primary target. Query validation is written against MySQL's grammar; schema introspection uses Laravel's driver-agnostic schema builder and is not MySQL-specific.
Testing
composer test
License
MIT.