Skip to main content

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.

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.

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.

ParameterMeaning
levelStrictness 0–9. Defaults to the project configuration.
categoryScope the run to one category.
connectionThe name of a connection the application has configured.
profileThe name of a run profile the project has configured.
fileOne 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".

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.

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.

Configure the surface

Everything lives under agent.mcp in config/sqlens.php:

  • transportstdio, the only value this version accepts.
  • connection — the connection the tools answer about. null uses the application's default, which is the same one the CLI would use.
  • findings_path — where get_findings reads the last report from.
  • max_findings — the ceiling on how many findings one answer may carry. 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.
  • 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 the target must not 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.

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 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.