Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
29 changes: 26 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -592,18 +592,41 @@ Difflock ships an MCP server that closes the loop.
// .mcp.json — Claude Code, Cursor, Laravel Boost, anything speaking MCP
{
"mcpServers": {
"difflock": { "command": "php", "args": ["artisan", "difflock:mcp"] }
"difflock": {
"command": "php",
"args": ["-d", "display_errors=stderr", "artisan", "difflock:mcp"]
}
}
}
```

Three tools, in the order a careful developer would use them:
Four tools, in the order a careful developer would use them:

| Tool | Answers |
| --- | --- |
| `difflock_table_context` | What does this table look like — rows, columns, indexes, foreign keys? |
| `difflock_lint_migration` | I just wrote this migration; what is wrong with it? |
| `difflock_lint_migration` | What is wrong with this migration — **including one not written yet**? |
| `difflock_schema_drift` | Has this database already diverged from the baseline? |
| `difflock_rules` | What does this rule actually check, in this project? |

### Check the draft, not the file

`difflock_lint_migration` takes `source` as well as `path`. An agent can validate the
migration it is *holding* — against real row counts and real indexes — fix it, and
write once. Checking after writing means every intermediate mistake lands in the
repository first.

### Why `-d display_errors=stderr`

On this transport **STDOUT carries the protocol and nothing else**. A single PHP
deprecation notice printed during bootstrap lands ahead of the handshake, the client
cannot parse it, and Difflock's tools appear not to exist — with nothing in the error
to suggest why. I hit exactly this on a live application whose `config/database.php`
used `PDO::MYSQL_ATTR_SSL_CA` on PHP 8.5.

The flag redirects PHP's diagnostics to STDERR, where MCP clients collect server
logs, so you still see them. Difflock also seals STDOUT around every request itself,
so a `dd()` left in a model cannot corrupt the stream either.

It is a **standalone stdio server**, not a Boost plugin. Boost publishes no documented API for third-party tool registration, and writing against an undocumented internal is how a package breaks on someone else's patch release. This works with Boost and with everything else.

Expand Down
26 changes: 22 additions & 4 deletions skills/difflock/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -17,12 +17,24 @@ wrong is not a style problem; it is how production columns get dropped.

```
1. difflock_table_context → what am I dealing with?
2. write the migration
3. difflock_lint_migration → what's wrong with it?
4. fix and repeat until nothing is above `low`
5. show the user, quoting anything that remains
2. draft the migration (do not write it yet)
3. difflock_lint_migration with `source` → what's wrong with it?
4. fix the draft, repeat 3, until nothing is above `low`
5. write the file
6. show the user, quoting anything that remains
```

**Check the draft with `source` before writing it to disk.** `difflock_lint_migration`
takes the migration code directly, and analyses it against the real database — real
row counts, real indexes — even though the file does not exist yet. Checking after
writing means every intermediate mistake lands in the user's repository first.

```
difflock_lint_migration { "source": "<?php\n\nuse Illuminate\\Database\\Migrations\\Migration;\n..." }
```

Use `path` only for migrations that already exist.

If the MCP tools are unavailable, the same facts come from the CLI:

```bash
Expand Down Expand Up @@ -67,6 +79,12 @@ Each carries a **risk** (`safe` → `critical`) and two facts that are not opini
not say. The distinction is the difference between "nothing to backfill" and "we
have no idea".

## Explaining a rule

Do not reconstruct what a rule means from its name. Call `difflock_rules` — it
returns each rule's own documentation, and the registered set is configurable, so a
project may have rules that were never part of Difflock.

## Saying it to the user

Lead with what will happen, not with the rule name:
Expand Down
38 changes: 38 additions & 0 deletions src/Console/Commands/McpCommand.php
Original file line number Diff line number Diff line change
Expand Up @@ -9,8 +9,10 @@
use Difflock\Database\DatabaseContextFactory;
use Difflock\Mcp\Server;
use Difflock\Mcp\Tools\LintMigration;
use Difflock\Mcp\Tools\Rules;
use Difflock\Mcp\Tools\SchemaDrift;
use Difflock\Mcp\Tools\TableContext;
use Difflock\RuleRegistry;
use Illuminate\Console\Command;
use Illuminate\Contracts\Config\Repository;

Expand Down Expand Up @@ -54,14 +56,50 @@ public function handle(
return self::INVALID;
}

$this->protectTheStream();

$server = new Server([
new TableContext($contexts),
new LintMigration($analyzer),
new SchemaDrift($checkup),
new Rules($this->laravel->make(RuleRegistry::class), $this->laravel),
]);

$server->serve(STDIN, STDOUT);

return self::SUCCESS;
}

/**
* Stop the host application writing into the protocol stream.
*
* PHP sends warnings, notices and deprecations to STDOUT when `display_errors`
* is on, which is the default in most local setups. On this transport that is
* fatal rather than untidy: one deprecation from a config file lands ahead of
* the handshake, the client cannot parse it, and Difflock's tools appear not to
* exist. There is nothing in the error to suggest the cause.
*
* Diagnostics are not lost — they are redirected to STDERR, where MCP clients
* collect server logs. So the operator still sees the deprecation; the agent
* still gets clean JSON.
*
* This covers everything from here on. Output emitted *earlier*, while the
* framework booted, is already gone by the time any command runs, which is why
* the documented invocation passes `-d display_errors=stderr` to PHP itself.
*/
private function protectTheStream(): void
{
ini_set('display_errors', 'stderr');
ini_set('log_errors', '0');

if (ob_get_level() > 0) {
// Something has been buffering since before this command. Flushing it to
// STDOUT now would be the exact corruption this method exists to prevent.
$pending = ob_get_clean();

if (is_string($pending) && trim($pending) !== '') {
fwrite(STDERR, "difflock: discarded output buffered before the server started.\n");
}
}
}
}
55 changes: 55 additions & 0 deletions src/Mcp/Server.php
Original file line number Diff line number Diff line change
Expand Up @@ -86,13 +86,68 @@ public function serve(mixed $input, mixed $output): void
}
}

/**
* Run the handler with STDOUT sealed off.
*
* This is the difference between a server that works and one that dies silently
* on somebody else's application. **STDOUT carries the protocol**, and anything
* else written to it — a `dd()` left in a model, a deprecation notice from a
* config file, a package that echoes during boot — lands in the middle of the
* JSON-RPC stream. The client sees malformed JSON, gives up, and the failure
* presents as the tools simply not existing. Nobody debugs that quickly.
*
* A real example, found on a live application: `config/database.php` referencing
* `PDO::MYSQL_ATTR_SSL_CA` on PHP 8.5 emits a deprecation notice to STDOUT, and
* that alone was enough to make the server mute.
*
* So every handler runs inside an output buffer. Whatever it prints is captured
* and thrown away rather than corrupting the stream, and the protocol frame is
* written afterwards by the caller. Output produced *before* this point — during
* framework bootstrap — cannot be caught here; {@see \Difflock\Console\Commands\McpCommand}
* handles that end.
*
* @template TReturn
*
* @param callable(): TReturn $handler
* @return TReturn
*/
private function guarded(callable $handler): mixed
{
ob_start();

try {
return $handler();
} finally {
$stray = ob_get_clean();

if (is_string($stray) && trim($stray) !== '') {
// Not silently discarded: an operator debugging a quiet server needs
// to know something is writing where it should not.
fwrite(STDERR, 'difflock: discarded '.strlen($stray).' bytes written to STDOUT during a '
.'request. Something in this application prints to standard output, which corrupts '
."the MCP stream.\n");
}
}
}

/**
* Handle one line of input, returning the response to write, or null for a
* notification.
*
* @return array<string, mixed>|null
*/
public function dispatch(string $line): ?array
{
// The guard lives here rather than in serve() so that it protects every entry
// point, not just the one the production transport happens to use. A caller
// that dispatches directly deserves the same guarantee.
return $this->guarded(fn (): ?array => $this->route($line));
}

/**
* @return array<string, mixed>|null
*/
private function route(string $line): ?array
{
try {
$message = json_decode($line, true, 32, JSON_THROW_ON_ERROR);
Expand Down
Loading
Loading