MCP server: connecting an agent to the same engine
An AI agent writing a migration is fast and confident, and neither of those is the same as correct. SQLens as an MCP server gives it something it does not have on its own: a deterministic verifier that answers about your database, with your rules, and says plainly when it could not tell.
The point is not that the agent gets a new capability. It is that it gets the same
one your pipeline has. sqlens:lint and the lint_pending tool are two callers of one
service — same rules, same suppression, same three-valued answer — so a person and an
agent cannot end up disagreeing about a migration neither of them changed.
Install the one optional package it needs
composer require --dev laravel/mcp
This is the only capability in SQLens that needs something the package does not require.
laravel/mcp carries a server runtime that every run without the MCP server never uses, so
requiring it would put a dependency into build pipelines and CI containers for a feature most
of them do not touch.
Skipping it costs you this page and nothing else. sqlens:mcp then names the missing
package on standard error, prints that install command, and exits 2 — the
misconfiguration code, because nothing was served. It never
starts a server that would answer about a world nobody described.
Start it
php artisan sqlens:mcp
That is the whole server. It speaks the Model Context Protocol over stdio: it reads requests on standard input and writes answers on standard output, and it opens no port and listens on no socket. Everything that is not protocol — a warning, a diagnostic, a reason a run stopped — goes to standard error, so the frame stream belongs to the protocol alone.
Output from your own application goes there too: an echo or a forgotten dump in a
service provider or a query listener, and a log channel you configured on php://stdout,
which writes to standard error while the server runs. Each line is marked on standard
error so you can find where it came from. Code that writes to STDOUT directly, and a
log handler built around a stream it was handed, write past this and break the session.
A tool that fails is answered with the kind of failure and, for a database error, its
SQLSTATE: 42501 is a missing privilege, 57014 a statement that ran out of time. The
error's own text is not in the answer, because a database error carries the connection's
host, port and database and the statement with its values, and the answer reaches the
model behind the client. The whole error goes to your application's log. The server runs
with app.debug off whatever your application sets, so a malformed request is answered
as an error rather than ending the session.
It takes one option, --enable-tool, which exposes a
tool that touches a database for that run alone.
You will not normally run it by hand. An agent starts it for you, which is what the next section configures.
Connect an agent
Most agent tooling reads a project-local .mcp.json. Put this at the root of the
Laravel application:
{
"mcpServers": {
"sqlens": {
"command": "php",
"args": ["artisan", "sqlens:mcp"]
}
}
}
Restart the agent, and it will list the SQLens tools among the ones it can call. Nothing else is needed: the server inherits the trust boundary of the process that started it, which is the agent already running on your machine.
If the agent reports that the server exited immediately, run the command yourself in the same directory and read standard error — a configuration the package refuses is named there, and a refused configuration stops the server rather than starting one that answers about a world nobody described.
What each tool is for
Four tools are on by default. All four read; none of them writes anything, anywhere.
Each tool says what it does in tools/list as well, in the annotations a client reads:
readOnlyHint: true on every tool that writes nothing, predeploy included, and
readOnlyHint: false with destructiveHint: true on lint_shadow, which creates and drops
throwaway databases.
lint_pending
Lints the migrations the project has not run yet, and returns structured findings — each
with its rule id, level or severity, and its downtime_class, which is the field an
agent rewriting a migration acts on.
| Parameter | Meaning |
|---|---|
level | Strictness 0–9. Defaults to the project configuration. |
category | Scope the run to one category. |
connection | The name of a connection the application has configured. |
profile | The run profile to apply, local, ci or predeploy, as sqlens:lint --profile does. See the profile a run uses. |
file | One migration, on the sub-second path. |
file is the one to reach for in an edit loop: it lints exactly the migration that just
changed, opens no database connection at all, and comes back in milliseconds. It runs the
same rules and the same suppression as a full run, so a baseline entry or a
#[SqlensIgnore] still applies.
An answer looks like this, trimmed:
{
"status": "ok",
"undetermined_reasons": [],
"summary": "1 finding(s) on pgsql, 1 returned. Nothing was left out.",
"connection": "pgsql",
"gate": { "breached": true, "exit_code": 1, "meaning": "One or more findings breached the level or severity gate." },
"findings": [
{
"rule_id": "PG.L2.INDEX_NOT_CONCURRENT",
"status": "fail",
"level": 2,
"downtime_class": "blocking",
"documentation_url": "https://docs.pushery.com/sqlens-for-laravel/rules/pg-l2-index-not-concurrent/"
}
],
"total": 1,
"returned": 1,
"truncated": false
}
explain_rule
Returns a rule's own metadata — what it judges, why, its level or severity, and where its
documentation is. It is the tool to call before trying to work around a finding, and it
is usually cheaper than the workaround. An id it does not know is an error naming the
closest matches, never an empty answer that reads like "no such problem". An id built per finding,
such as DEPLOY.CONTEXT.SETTING.LOCK_TIMEOUT_UNBOUNDED, is answered with the entry of the family it
belongs to. A finding of Squawk or the Postgres Language Server carries the tool's own rule name behind
the tool's prefix, such as SQUAWK.ban-char-field or PGLS.extensionInPublic. Those rules are the
tool's, so the answer comes from the card SQLens ships for the tool: what SQLens files a finding under it
as, whether it reports one at all, the SQLens rule that covers the same ground if there is one, and the
tool's own page in source_url.
The answer carries a bad/good example pair where one exists, and says so plainly where none
does — registered: false with a reason, never a missing key. Which ids owe a pair is not a
guess the tool makes: every catalog entry declares an attribution.
attribution | means | example pair |
|---|---|---|
authored | the finding names something you wrote and can rewrite — a migration, a schema object, a query | required |
observed | the finding names what the run found in the world, or what it could not do: an unreachable server, a lock another session holds, an index whose build died | refused |
The second row is the one worth stating. There is no migration that causes
CAP.L0.SERVER_UNREACHABLE and none that avoids it, so a "good example" for it would be
confident, concrete, and about something else — the package refuses to ship one rather than
leaving the absence to look like an oversight.
get_findings
Reads the report a previous run wrote and returns it. It never starts a run. That is the point rather than a limitation: asking "what did the last run find" must not be able to open a database connection, and a fresh run would answer about a different moment anyway.
get_debt_ledger
Reads the migration debt ledger — what the project has knowingly deferred, and for how
long. The reference date is a parameter, so two readings of one ledger agree. A long ledger
comes in pages: limit and offset ask for one, max_findings caps it as it caps every
answer, and truncation_reason says whether the request or the ceiling cut it.
Every answer is three-valued
status is ok or undetermined, and the difference is the reason this package exists.
A run that could not happen — an unsupported engine, an unreachable server, a path that
is not a migration — comes back undetermined with a named reason, never as an empty
finding list. "Found nothing" and "could not look" produce the same empty list, and an
agent reading the second as the first reports the work as finished.
The text summary says so too, in words a client without a structured display will show a person: an unjudgeable run reads This could NOT be checked: …, never No findings.
The profile a run uses
A tool that starts a run chooses its profile the way the command it stands in for does, in
the same order: the profile parameter where lint_pending is given one, then
SQLENS_PROFILE, then sqlens.profile, then the command's own default, which is
predeploy for the gate and local for the lint tools. The run then gates on that
profile's settings, and its header names it. The settings last for that call only: the
next call starts from the configuration the application set.
So a project whose ci profile raises the level gets the same answer from lint_pending
with profile: "ci" as from sqlens:lint --profile=ci, and predeploy applies the
project's profiles.predeploy section exactly as sqlens:predeploy does.
A name that is not a profile stops the call, as it stops the command. The answer is
undetermined with refusal.id set to unknown_profile, the value, and where it was set:
parameter, env or config. It never falls back to a more lenient profile.
Configure the surface
Everything lives under agent.mcp in config/sqlens.php:
transport—stdio, the only value this version accepts.connection— the connection the tools answer about.nulluses the application's default, which is the same one the CLI would use.findings_path— whereget_findingsreads the last report from.max_findings— the ceiling on how many findings or debts one answer may carry, whateverlimita client sends. A run with four thousand findings would fill an agent's whole context window with one call and leave no room for the work. Crossing the ceiling is reported as crossing it, in a structured field — never a quiet truncation, which reads exactly like a database with fewer problems than it has.SQLENS_MCP_MAX_FINDINGSsets it per environment.tools— which tools the server exposes, one entry per name.
The tools that touch a database
Two more tools exist and are off. Each is enabled on its own, by name, and there is no switch that turns on both.
That is deliberate and worth stating plainly: a master switch would turn a decision about one dangerous capability into a decision about every dangerous capability this package will ever have, including the ones added after somebody flipped it.
There are two ways to name one, and they answer different questions.
agent.mcp.tools.<name> in config/sqlens.php says what the project has adopted:
committed, in a file everybody shares, on for every run. --enable-tool=<name> says what
an operator is doing in this session, which is how you try a mutating tool once
without leaving it on for everybody.
php artisan sqlens:mcp --enable-tool=lint_shadow --enable-tool=predeploy
The option is repeatable, there is no --enable-all for the reason above, and it is
additive: it turns a tool on and never turns off one the configuration turned on. A name
this build does not know is refused out loud — Not a tool this build knows: …, and the
misconfiguration code — rather than ignored, because somebody
who typed predploy believes they enabled a deploy gate. Every start then names on
standard error which mutating tools that run exposes, not only the run that turned one on.
Neither way relaxes anything else. A lint_shadow enabled by option still answers
undetermined without the consent below, and the production guard is untouched by both.
lint_shadow
Runs the pending migrations for real against a throwaway database it creates and then drops. It is the truth mode — it sees what static analysis structurally cannot — and it is also the only mode in this package that writes anything at all.
Enabling the tool is not enough to make it run. A server has no terminal, so the
confirmation sqlens:lint --shadow asks for interactively cannot be asked over a
protocol — and silence is not taken as consent. Without a standing, recorded consent the
tool answers undetermined, names the check that refused, and points at the CLI.
'agent' => [
'mcp' => [
'tools' => [
'lint_shadow' => true,
],
// The standing "yes, I mean it" that stands in for the CLI's --force,
// because a protocol has nobody to prompt.
'shadow_consent' => true,
],
],
Both switches together still do not override the production guard. The environment must
be on capture.shadow.allowed_environments, and neither the target nor the connection the
throwaway database is built on may look like a production connection — neither check is
overridable by anything the tool accepts. A
refusal names which of the three checks held the run, because the fix differs: an
environment is a configuration decision, a production-looking connection is a target
mistake, and a missing consent is a setting to write down.
A run the guard allowed can still end without a throwaway database. Behind a transaction
pooler without capture.shadow.direct_connection, on a read replica, with a shadow
connection that reaches the database under examination, or with a direct connection to a
different server, the capture judges every migration undetermined and creates nothing.
The tool answers undetermined then as well, naming the reason the engine gave, and it
says its findings came from a real run against a throwaway database only when there was
one.
Read the shadow-mode guide before enabling this. It needs a role with
CREATEDB, kept separate from your runtime and migration roles.
predeploy
Runs the deploy gate: the same read-only, time-bounded, fail-closed preflight
sqlens:predeploy runs, one line before migrate --force. It returns the pending
migrations and the instance checks as one verdict.
It writes nothing. It is off by default anyway, because the decision it asks you to make is about reach: this is the tool that touches a real, named production database.
'agent' => [
'mcp' => [
'tools' => [
'predeploy' => true,
],
],
],
Fail-closed survives the protocol. A target that cannot be resolved or reached comes back
undetermined with a gate that says it blocks — never an empty finding list.
allow_undetermined opens exactly one door (a run whose only blockers could not answer),
cannot wave through a gate that never ran, and is echoed in every answer whether or not it
changed anything: an emergency exit that can be used invisibly is a default nobody agreed
to. It opens no more than the project declared in sqlens.deploy.predeploy.allow_undetermined:
true asks for that declaration, false keeps the run shut, and leaving it out is the
declaration. A client that reaches production can close the hatch for a run and never widen
it. The console flag --allow-undetermined is a person at the deploy deciding to proceed, and
the protocol has no counterpart to it.
When the preflight reads through the connection that runs your migrations, the answer says
so in advisory, the note sqlens:predeploy prints on standard error, and summary
repeats it for a client that shows only text. The note decides nothing: gate is the same
with it as without it. On every other run advisory is null.
It needs the preflight connection and the privileges the predeploy permissions page describes — the same ones the CLI path needs, and no others.
What the MCP server never requires
- No open port. stdio only. Nothing listens, so nothing is reachable from anywhere.
- No write access. The four default tools read; the two that do more are off, and turning one on is a decision somebody made by name.
- No superuser. The privileges are the CLI's, documented per suite.
- No free-text SQL. No tool accepts a query, a fragment, or a connection string — every parameter is a bounded value or a name the application already configured. That is enforced by a test over every tool's published schema, not by a convention.
- No telemetry. Nothing is sent anywhere. The server answers the process that started it and writes nowhere else.
HTTP is out of scope, on purpose
This version speaks stdio and nothing else, and that is a decision rather than a gap.
An HTTP server is a shared endpoint: it needs authentication, authorization, transport security and a story for who may ask what — an entire surface, and one that would be worse half-built than absent. stdio needs none of it, because it inherits the trust boundary of the process that started it: the agent already running on your machine, as you.
The transport setting exists so that the day HTTP arrives it is a value in a
configuration file rather than a new shape. Until then, an application that wants SQLens
answering over a network is asking for a proxy it controls and can secure, not for this
package to grow one.