MCP server¶
The framework ships an MCP (Model Context Protocol) server, launched with
php <cli> mcp:serve. It speaks JSON-RPC 2.0 over stdio, which is what an assistant such
as Claude Code expects, and it exposes two kinds of thing:
- tools — callable capabilities: five introspect the application, two report what its logs say, one writes the day's changelog entry, and seven answer questions about the code and the project itself — where a symbol is defined and who calls it, which tests cover it, what the CLI can do, what the design tokens are, what the API documents, and what the framework's own guides and rules say. Only the first five need an application or a database;
- resources — read-only files the assistant can fetch by URI.
There is no HTTP surface and no port. The client starts the process, talks to it on stdin and stdout, and stops it. Nothing is exposed to a network.
This guide is the owner of the topic
MCP shipped in v1.2 and, until this page existed, was documented only in
1.2-new-features.md — a frozen reference — and in a handful of dated changelog
posts. That is precisely the failure mode the documentation rules describe: a
capability that is present, documented, and not findable. The frozen page is still
accurate about the classes it lists; this page is the one that is kept current.
Enabling it¶
Add the feature key to app/app.php:
McpServiceProvider then registers the server in the container as mcp.server, together
with the built-in tools and resources.
Scaffolded projects also get a .mcp.json in the project root, which is how the client
discovers the server. pramnos init writes it.
It names the CLI this project has, which is not ./bin/pramnos. That path exists in
the framework's own repository; in a project the CLI is <cliName>.php at the root and
bin/pramnos lives under vendor/. A Docker project gets the container form, because
the database is only reachable from inside and mcp:serve is a database tool above all
— one running on the host answers every query with a connection error:
{ "mcpServers": { "myapp": {
"command": "docker-compose",
"args": ["exec", "-T", "-u", "www-data", "app", "php", "myapp.php", "mcp:serve"]
} } }
-T is not optional: MCP speaks stdio over the pipe, and docker-compose exec without
it allocates a TTY that the protocol never gets a clean stream through. The scaffolded
./<cliName> wrapper is deliberately not reused here for that reason — it keeps its
TTY so an interactive migrate keeps its prompts.
A project without Docker gets the plain form:
The server reports app/app.php's name as its own, so a client listing several
projects can tell them apart. It reads the configuration file rather than a
database-stored setting: the name is right there, no connection is involved, and a project
whose settings failed to load would otherwise have shown the framework's generic default
for every one of its servers.
What a tool must tell a client¶
Three methods, and together they are the whole of discovery: a client reads them once and builds every call from them.
| Method | What it decides |
|---|---|
name() |
the wire identifier — a client sends tools/call with it and nothing else |
description() |
whether the tool is used, because a client chooses between fifteen of them by reading these and nothing else |
inputSchema() |
how the call is built and validated |
If you add a tool, these are the parts to get right, and the reason is that getting them wrong is silent. A malformed schema is not a wrong answer: the tool disappears from the client's list, or the client sends a shape the tool does not read, and nothing on the server logs anything because the request never arrives. The same for a description — an empty one is a tool nobody calls, and a one-word one is a tool called for the wrong thing, which is worse because the answer looks like an answer.
ToolDiscoveryContractTest holds every registered tool to it, so a tool added tomorrow is covered the
day it is registered rather than the day somebody writes a test for it. What it requires:
- a name matching
^[a-z][a-z0-9-]*$, unique across the server — the server keys tools by name, so a duplicate does not collide loudly, it replaces; - a description of more than thirty characters;
- a schema that is
{"type": "object", "properties": {…}}, where every property has atype(what the client validates against) and adescription(what stops the value being guessed); - a
requiredlist naming only properties the schema defines — requiring one it does not define is rejected outright by a strict client and sent empty by a lenient one; - and a schema that survives
json_encode/json_decodeunchanged, since that is the round trip it actually makes.
The built-in tools¶
| Tool | Answers |
|---|---|
list-tables |
Which tables exist, with row counts |
query-schema |
The columns, types, keys and indexes of a table |
migration-status |
Which migrations have run and which are pending |
model-inspect |
A model's table, primary key, columns and relations |
route-list |
Every registered route, with method, URI, action and permissions |
find-symbol |
Where a symbol is defined and who calls it — see below |
console-commands |
Every CLI command, including the twenty code generators — see below |
theme-info |
The design tokens, and whether the CSS was built from them — see below |
api-docs |
What the API promises, and whether the document is current — see below |
find-tests |
Which tests cover a class, and how to run them — see below |
coverage |
Which lines of your change no test touches — see below |
changelog-add |
Adds a section to today's changelog post — the only tool that writes |
log-analytics |
What is going wrong here, and how much — see below |
log-errors |
What the log lines actually say — see below |
framework-docs |
How the framework works — see below |
pramnos-check |
Whether this project has broken a documented rule — see below |
status |
Is anything broken, and what is waiting — see below |
schema-drift |
The schema on disk against the schema in the database — see below |
request-debug |
What a request that died actually did — see below |
find-symbol reads source files and needs neither, so it works when nothing boots — which is
when it is most likely to be wanted.
Start with status. It answers the four questions a session opens with — is the database
up, are there migrations to run, is anything stuck, when did something last go wrong — and the
alternative is finding out from a failure ten minutes in.
The first five are application introspection: they answer what exists in this project. They need a database or an application to answer at all, and two of them are skipped when no connection is configured. The two log tools answer what has been happening, and need only a readable log directory — so they register even when there is no application, which is exactly when somebody is asking why.
They all come from one list, McpServiceProvider::registerDefaults(). mcp:serve calls it
when no container has a server, which is the normal case; a second copy of that list is how
two tools once ended up registered and unreachable at the same time.
framework-docs¶
The odd one out, and the reason it exists is worth stating plainly.
docs/ is not export-ignored, so every guide ships inside the composer package and sits
in vendor/pramnos/framework/docs/ of each project. That is deliberate: the documentation
should be available to whoever is working there, an AI assistant included, and the
vendored docs always match the vendored code so there is no version to negotiate.
Nothing ever offered them. The other five tools answer questions about the
application; the registered resources are the application's own files. The only route to a
guide was for an assistant to guess that it should look inside vendor/. That is not a
hypothetical: it is the failure the documentation rules were written after — a feature
documented, present in the vendored corpus, not found, and built a second time beside the
working copy.
This tool takes no application and needs no database. It is the same answer in every project.
Calling it.
// The index — one line per guide: its name, and the first task it covers.
{}
// The same index with every use case of every page — an order of magnitude larger
{"detail": "full"}
// Find the page for a task
{"query": "issue an API token for a signed-in browser"}
// Read one in full
{"page": "Pramnos_Authentication_Guide"}
// The dated history of a change, rather than how the thing works now
{"corpus": "changelog", "query": "session exchange"}
The index is brief by default, and that is a usability fix rather than a saving. Every use
case of every page came to about 27KB — a page of reading before the question has been asked —
and the measurable effect was that grepping docs/ won instead: one line, and you know what
comes back. An index that fits in a glance gets asked reflexively, which is the only way an
index earns its place. {"detail": "full"} is there for when the brief one did not name what
you are looking for.
How pages are ranked. Every guide carries use_cases: frontmatter, and each entry is
phrased as the task the reader has in hand rather than as a description of the page —
"Adding a column to an existing table", not "Schema builder reference". Those are the
closest thing in the corpus to the question an assistant arrives with, so a hit there
outweighs a hit in a heading, which outweighs a hit in the body. Body matches still count,
because a question about a specific method name will not appear in any use case; they are
simply worth less.
A page carrying no use cases is demoted, because it is not guidance. Two pages are in that state on purpose — a frozen version reference and a release index — and they are also the two longest files in the corpus. On body volume alone, the frozen one outranked every live guide on the first query this tool was measured with, which would have sent a reader to a page that stopped describing current state deliberately. The rule is structural rather than a list of names, so a page cannot become quietly exempt by being added later.
Two corpora, never merged. guides describes current state; changelog is the dated
stream of what changed and when. There are far more posts than guides, and each post
repeats the vocabulary of the change it describes — merged, "how does this work" would be
answered by three fragments of a feature's history. That is exactly what the
guide/changelog split exists to prevent, and merged search would reintroduce it as a
ranking accident rather than as a decision. Ask guides first; reach for changelog when
the date or the reason for a change is the question.
pramnos-check¶
The other half of framework-docs, and the half with evidence behind it. That tool lets an
assistant find a rule; this one tells it when a rule has been broken — and every rule it
checks is something that happened after the guide describing it was written.
{} // the whole project
{"since": "HEAD"} // only the lines you changed — read this first
{"path": "src/Models"} // one subtree, or a single file
{"rules": ["raw-sql", "flash-query-params"]} // a subset
since — the option that makes it usable¶
Run over src/, this tool reports 76 findings, every one of them older than whatever you are
working on. The guide has said so from the start, and the predictable consequence was that
nobody ran it: with 76 pre-existing findings there is no way to see your own three.
since narrows to the lines the diff touched.
{"since": "HEAD", "changed_files": 4, "changed_lines": 212,
"suppressed": 5, "findings": [], "verdict": "No findings on the lines you changed."}
HEADis everything uncommitted, staged or not — what "my current change" means.stagedis the index only, for a pre-commit gate. Any ref works:main,HEAD~3, a tag.- A new file counts entirely. That is where new violations live, and skipping untracked files would pass every freshly written class.
- Editing one line of a legacy file does not surface its other findings. Otherwise this would be a file-level filter with a misleading name.
- Outside a git working tree it refuses, rather than reporting a clean change. "No findings on the lines you changed" when nothing was compared is a pass nobody earned.
Seven rules. Six are defects; the seventh polices the escape hatch.
| Rule | Why it is invisible when you make it |
|---|---|
raw-sql |
The builder is the only layer that knows the dialect. Hand-written SQL has produced both a query against a table no migration creates and unqualified names that match nothing. |
unqualified-authserver |
On PostgreSQL authserver is a real schema and is not on the default search_path, so the unqualified name returns no rows and no error. On MySQL the qualified form becomes authserver_x — a different table again. |
flash-query-params |
?message= is shown again on every reload, stays in history, and is user-controlled text arriving in a page that displays it. |
view-reserved-props |
sections, path, model and _layout are used by the View engine, so the value is overwritten and the variable is simply absent in the template. No error, no log line. |
baseline-migration-timestamp |
Installations predating the migration system set migration_cutoff = 2020_01_02_000000, so a new 2020_01_01_* migration is silently never run there. |
duplicate-debug-panel |
A second reader of the _debug payload is usually a panel written because the shipped one was not known to exist. That has happened, and it is why the documentation rules were rewritten. |
unexplained-suppression |
A suppression with no reason hides a finding and tells the next reader nothing. |
The authserver table list is read from the framework's own migrations at runtime, so it cannot drift out of step with the schema the framework creates.
Suppressing a finding¶
// pramnos-check: ignore raw-sql — recursive CTE the builder cannot express
$rows = $db->query('WITH RECURSIVE …');
Same line or the line above. The reason is required. A bare ignore raw-sql suppresses
nothing and is reported as its own finding — because the value of rule 12's "leave a one-line
comment saying why" is that the next reader can tell a considered exception from an oversight.
On precision¶
A check that cries wolf gets muted, and then the real finding it makes next month is muted with it. So the rules match constructions, not names, and each one carries a negative test.
That is not a stylistic preference. The first run of this tool against the framework's own
src/ reported 29 raw-SQL findings and sixteen were noise: SELECT version(),
SELECT NOW(), select @@global.long_query_time, TimescaleDB catalogs, and one example
inside a docblock — because the first version did not strip comments. Rule 12 exempts
introspection and driver-specific features in its own text; a statement with no table cannot
be expressed by a builder at all; a fixture in test code is clearer as a literal.
What the rules deliberately do not report:
- raw SQL in
database/migrations/**, intests/**, or in the framework'sTesting/helpers; - statements with no table to address, or against
information_schema,pg_*,timescaledb_information, orSHOW …; - reading
?message=— an application does not control every link pointing at it; authserver.user_activity_log, which contains the unqualified form as a substring;$config->path = …or$this->model = …, which are not view variables;_debugin a project with no shippedlib/debug.js, which has nothing to duplicate.
The framework does not pass its own check¶
Run against src/, it reports 9 raw-SQL findings and 67 flash-query-parameter ones.
Those are real, and they are the argument for the tool existing: the rules were written down,
and the framework itself drifted from them in seventy-six places. They are recorded rather
than quietly fixed, because rewriting seventy-six call sites is a decision about priorities
and not a tidy-up.
find-symbol¶
The question grep cannot answer. Grep finds strings; this finds calls, and names the
function each one sits inside.
{"name": "hasTable"} // definitions and callers
{"name": "SchemaBuilder::hasTable"} // narrow the definitions
{"name": "LogAnalytics", "scope": "app"} // only the consuming project
{"name": "parseTimestamp", "kind": "callers"}
{
"symbol": "hasTable",
"files": {"searched": 1550, "containing": 8},
"definitions": [
{"source": "framework", "file": "…/SchemaBuilder.php", "kind": "method",
"name": "Pramnos\Database\SchemaBuilder::hasTable", "line": 308}
],
"callers": [
{"source": "framework", "file": "…/Permissions.php", "line": 413,
"in": "Pramnos\Auth\Permissions::tableExists", "type": "method",
"code": "return $database->schema()->hasTable($table);"}
],
"counts": {"definitions": 1, "callers": 7},
"complete": true
}
in is the field that matters. Permissions::tableExists explains a line number;
src/Pramnos/Auth/Permissions.php:413 does not. The tool exists because of an afternoon spent
tracing which code ran a particular query: eight greps, and then a patch to
QueryBuilder::exists() that dumped a backtrace — in a framework whose entire source was on
disk. Grep could not find it, because the calling line contained neither word being searched
for: it read $database->queryBuilder()->table($table), and the name was in a constant three
lines up.
Token-based, so a mention in a comment, a doc-block or a string literal is not reported — which
is most of what grep returns when the name is an ordinary English word like logs or table.
Measured on one case: grep returned 14 hits for parseTimestamp; four were calls, ten were in
tests, and one was a sentence in a comment.
scopeisall(default),apporframework. The framework's own tests are in scope: "a test asserts this" is part of the answer to "who calls this", and it is what tells you whether changing it is safe.typeon each caller distinguishesmethod,static,call,newandclass-ref— the last beingFoo::with no parentheses after the name, which is how a class reached statically is used, and how most of this framework's classes are used.- A qualified name matches on its last segment, so
\Pramnos\Logs\LogAnalytics::summary()is found by searching forLogAnalytics. - Naming a class narrows the definitions, not the callers, and the answer says so:
establishing that a given
$thing->hasTable()is aSchemaBuilderwould need type inference. - What it cannot see: a dynamic call (
$method(),call_user_func), a type hint, ausestatement,instanceof. An empty answer says this, because "nothing calls this" is how a method gets deleted.
No cache, deliberately: tokenising all 557 files of the framework measures at 60ms, and a cache is a second source of truth that can be stale about the one thing this tool exists to be right about.
console-commands¶
Seventy-odd commands, twenty of which generate code. The reason this is a tool is the failure
it fixes: an assistant working in a codebase for a whole day writes a controller by hand rather
than running create:controller, because nothing told it the command exists. --help on
seventy commands is not a discovery mechanism.
{} // everything, grouped by prefix
{"generators": true} // only the commands that write files
{"filter": "migration"}
{"name": "create:crud"} // one command's arguments and options
{
"name": "create:crud",
"description": "Create a complete CRUD (model, controller, views and/or API + SPA screen)",
"usage": "create:crud [name]",
"generates": true,
"arguments": [{"name": "name", "description": "Name of the created object"}],
"options": [
{"name": "--table", "shortcut": "-t", "value": "value required", "description": "Database table"},
{"name": "--target", "value": "value optional", "description": "mvc, spa or both"}
],
"class": "Pramnos\Console\Commands\Make\MakeCrud"
}
generatesmarks the commands that write files into the project. That is not something to infer from a one-line description: it decides whether a command can be run to see what it does.classis there sofind-symbolis the obvious next question when the description is not enough. The two are meant to be used together.- Read from the live console definition. A second catalogue kept in the tool would be a second thing to forget, and this file has already been bitten by exactly that.
Twenty generators exist: controller, model, migration, crud, api, api-client,
screen, component, view, service, policy, provider, middleware, event,
listener, task, webhook, seeder, test, command. Ask before writing one by hand.
theme-info¶
Two questions, and the second is the one that bites.
{} // palette, themes, theme directories, build, freshness
{"theme": "msd-dark"} // one theme's full token list
{"token": "color-primary"} // one token across every theme
«What colour is --color-primary here» is answerable by reading a file, once you know which
file. «Is the CSS on disk built from the CSS in the repository» is not answerable by reading
anything, and getting it wrong is silent:
"freshness": {
"built": true,
"built_at": "27/08/2026 14:51",
"stale": true,
"newer_than_the_build": ["src/Views/register/register.html.php", "…"],
"why": "These changed after the last build, so the served stylesheet does not reflect them."
}
daisyUI is a Tailwind plugin, so it cannot come from a CDN and the build is not optional: without it the component classes resolve to nothing and the page renders unstyled rather than failing. And the compiled stylesheet is committed on purpose — so a checkout serves the site without npm — which makes it exactly the artifact somebody forgets to regenerate.
Three kinds of source are checked, because Tailwind depends on all three: the entry stylesheet,
the palette it @imports, and every directory it @sources for class names. The third is
the one that surprises people — adding btn-primary to a view means that class has to be
generated, so an untouched app.css is not evidence of a current bundle.
The input and output paths are read out of the package.json script rather than assumed, the
--watch script is never offered as the build command, and a project with no Tailwind script
is told about theme:build instead — whose output has no freshness problem, because it is a
direct translation of the palette.
{"token": …} keeps a null where a theme does not declare the token, and says what that
means: the value falls back to daisyUI's own, which is rarely the one beside it in the answer.
That is usually the real question — "is this readable in the other theme".
api-docs¶
route-list answers what URIs exist. This answers what the API promises — parameters,
request bodies, response codes, which credential each operation needs. Different question, and
the one an integration is written against.
{} // the operation list, plus the document's own facts
{"summary_only": true} // counts and freshness, no list
{"filter": "token"}
{"operation": "GET /me/tokens"} // one operation in full
Each operation carries the scheme names from its security requirement rather than the
whole object, because "does this need a credential, and which" is the first thing anybody asks
and the object buries it.
The freshness check is the same idea as theme-info's and matters for the same reason: the
OpenAPI document is a generated file that gets committed, so a controller can gain a
parameter while the published document goes on describing the old shape. Nothing fails. The API
works and the documentation lies, which is the worst available outcome because somebody
believes it.
Two generators are recognised, because a project uses one or the other — api:docs reading
#[Route] attributes, or an openapi:generate npm script converting apiDoc annotations.
Reporting only the framework's own would tell half the projects they have no API documentation.
An application with neither is told that api:docs would produce an empty document and pointed
at route-list, which parses the routes file instead.
servers, parameters, summary and $ref are legal keys beside the methods under a path,
and are not operations. Treating every key as one invented SERVERS /oauth/token and inflated
a real project's count from 15 to 20 — a fabricated endpoint in a list of endpoints is the same
failure as a wrong URI.
find-tests¶
Where the test for this is, read from #[CoversClass] rather than guessed from a filename.
{
"class": "LogManager", "covered": true,
"coveredBy": [{"class": "LogManager", "tests": [
{"class": "…\LogManagerTest", "file": "…/tests/Unit/Pramnos/Logs/LogManagerTest.php",
"methods": 10}]}],
"command": "./dockertest --filter 'LogManagerViewerCharacterizationTest|LogManagerTest'"
}
Guessing from the name has a wrong answer often enough to matter: Pramnos\Logs\LogManager is
tested in tests/Unit/Pramnos/Logs/, not tests/Unit/Logs/, and writing to a directory that
does not exist puts a new test somewhere nobody looks.
- It runs nothing. Running tests is something a shell does well, and wrapping it would hide
the project's rule about how: these projects hold a lock, and two concurrent runs corrupt
the shared test databases. So the command is reported —
./dockertestwhen the project has one — and never executed. - The command runs every matching test class, as a
--filteralternation. Naming one of three is worse than useless: it looks like the command that verifies a change and silently skips two thirds of the evidence. - An undeclared class is not called untested.
#[CoversClass]is a declaration, not a measurement — but it is what the coverage report goes by, so the answer says both and points at whatever test files merely mention the class. That found a real gap on this repository: aSeoTestexercisingSeowithout declaring it. - The framework's own tests are in scope, because a framework class is covered there and nowhere else, and "no tests" would otherwise be a confident lie.
status¶
{
"database": {"connected": true, "type": "postgresql", "prefix": ""},
"migrations": {"applied": 91, "pending": 0},
"queue": {"enabled": true, "by_status": {"pending": 3, "failed": 1}},
"health": {"status": "ok", "checks": {"database": {"status": "ok", "message": "Reachable"}}},
"errors": {"at": "29/08/2026 00:07:07", "level": "error",
"message": "column \"userid\" does not exist …",
"request": "62b82d3200a4be93"},
"verdict": "1 failed queue job(s); last error 29/08/2026 00:07:07."
}
Four questions that were four separate lookups and therefore usually none: a container that is not running is discovered by a failure, and a pending migration by a column that does not exist.
- The verdict is the product. Five sections of JSON is what somebody skims past; one line is what gets read. It never says everything is fine while something is not, and an unreachable database is the whole answer rather than one finding among five — every other section is unanswerable without it, and the usual cause is a container that is not running.
- Pending migrations are named, not counted. "3 pending" is a number; the names say whether they are this afternoon's work or somebody else's from a branch.
- The last error carries its request id, which is the argument to
request-debug. - It takes no arguments and changes nothing. A tool called reflexively at the start of a session must not be able to start, migrate, clear or retry anything.
schema-drift¶
{} // the whole comparison
{"table": "permissions"} // one table: what creates it, and has that run here
list-tables reads the live database; migration-status reads the migrations. The question
that matters is neither: does a migration create this table, and has it run here? The gap
between those two answers is a whole category of bug and is invisible from either side.
{
"unmanaged": ["deferredwrites", "schemaversion"],
"applied_but_missing": [{"table": "pramnos.framework_policies",
"migrations": ["create_framework_policies_table"]}],
"not_created_yet": [],
"verdict": "2 live table(s) no migration creates, and 1 migration(s) applied without their table."
}
Three findings, and they are three different problems:
- A table nothing creates. It exists, code queries it, and a fresh installation will not have it. The failure is a deploy to a new environment, months later, by somebody who was not there.
- A migration that ran without leaving its table. The history says applied and the table is not there — so every future run considers it done. The alarming one.
- A migration that has not run here. Ordinary, and listed apart so it does not drown the other two.
Two more things it says, and both exist because the first version said them wrong:
- A migration can declare itself conditional.
pramnos.framework_policiesexists on MySQL and plain PostgreSQL and must not exist on TimescaleDB, which manages its own policies — so the history saying applied with no table is the migration behaving exactly as designed. Reported as drift, it is the loudest possible false alarm, and one at the top of a report is enough to make somebody stop reading it.public bool $conditional = true;on the migration, and it is listed apart. - It says what it could not read. A migration that names its table with a constant or a
setting —
createTable(DeferredWriteQueue::TABLE, …)— cannot be read without running it, and the table it creates would otherwise appear under "no migration creates this". Those migrations are listed underunreadable_migrationsand the note beside the unmanaged list points at them.
Two things it does that a simpler implementation would not:
- It reads raw SQL as well as the schema builder, and takes the
hasTable()guard above an interpolatedCREATE TABLE {$t}as the table's name. Several migrations have to write raw SQL — a hypertable, a schema-qualified table — and reading onlycreateTable()reports every one of them as a table nothing creates, which is the loudest possible false alarm about the most carefully written migrations in the project. - It compares names normalised, because the same table is spelled four ways:
#PREFIX#usersettingsin a migration andpf_usersettingsin MySQL;authserver.permissionson PostgreSQL andauthserver_permissionson MySQL. What it does not flatten is the schema itself —authserver.permissionsand a bare legacypermissionsare two different tables, and treating them as one hides the exact bug this was written for.
The migrations are read, not executed: token_get_all() and a regex over string literals. A
tool that ran a migration to find out what it creates would be a tool that migrates the database
by being asked a question.
request-debug¶
{} // the requests that went wrong, most recent first
{"request": "62b82d3200a4be93"} // every line that request wrote
{"level": ""} // every request the log knows about
The debug toolbar answers this for a response somebody is looking at. A request that died carried almost nothing back — an error page is not a JSON payload, and the header that still gets through has room for a count and never for a message.
Listing is the default because the id is the part you do not have: it exists only after somebody has read an error page and copied it out, which on a request that never rendered one nobody has.
Lines are tagged with a request id only while the debug toolbar is active for that visitor, which is deliberate — on a live server everybody else is logging into the same seconds, and their lines are not a developer's to read. So "no lines for that id" is usually correct, and the tool says so rather than looking broken.
coverage¶
The rule in these projects is coverage above 95% on the code you changed, and it was unverifiable. A coverage run produces a project-wide percentage, which barely moves when fifty uncovered lines are added to twenty thousand covered ones — so the rule was followed by assumption, which is to say not followed.
{} // uncommitted changes vs HEAD
{"since": "main"} // the whole branch
{"path": "src/Mcp"}
{"project": true} // the whole-project figure, for context
{
"report": {"file": "coverage/clover.xml", "generated_at": "28/08/2026 18:48", "stale": false},
"since": "HEAD", "changed_executable_lines": 306, "covered": 23, "uncovered": 283,
"percent": 7.5,
"files": [{"file": "src/Pramnos/Mcp/Tools/CoverageTool.php", "covered": 0,
"uncovered": 174, "uncovered_lines": [48, 50, 51, "…"]}],
"verdict": "283 of 306 changed lines are not covered by any test — 7.5%."
}
That is a real first run: the tool pointed at its own author's work, reporting 7.5%. A number that can fail is the point.
- It reads
coverage/clover.xmland runs nothing../dockertest --coverageholds a lock — two concurrent runs corrupt the shared test databases — and it could not run from inside the container it would have to start. Produce the report, then ask. - A report older than the code is called stale. Reading a stale one is worse than reading none: it reports the previous version of a file as covered, and the line numbers have moved underneath it.
- Lines that cannot be executed are not counted against you. Blank lines, closing braces, comments and property declarations are absent from the report entirely, and counting them as uncovered would turn every honest change into a failure.
- A whole file the report does not mention is a different thing, and is named. A new class
under a measured root is absent because no test ever loaded it — PHPUnit never saw a line, so
it is not "nothing executable changed", it is 0%. Those appear under
unmeasuredand in the verdict. (Skipped in silence, a change consisting entirely of new untested classes reported 100%, which is the worst answer a coverage gate can give.) Files outside the measured roots — guides, stubs, tests — are still skipped quietly, because a warning that fires on every markdown file is a warning nobody reads. - A container's paths are joined to project-relative ones. Clover records the path the test
run saw —
/var/www/html/src/…— and getting that join wrong reports every line as unmeasurable, which is a silent pass.
{"project": true} is available and deliberately not the default: it is the number that made
the rule unverifiable in the first place.
changelog-add¶
The only tool here that writes, and the only one that needed to be: the rule is one post per day with every section listed at the top under a count, so adding an entry means appending the section, rebuilding the list from the headings, and getting the count and its plural right.
{"title": "Two rules that could not be checked", "body": "…", "categories": ["Testing"]}
{"title": "…", "body": "…", "preview": true} // see the result, write nothing
{"title": "…", "body": "…", "replace": true} // rewrite an existing section
Four things it refuses to do:
- It never edits the summary list — the list is derived from the
##headings, every time. A hand-maintained list drifts from what it describes, and the drift is invisible. - It refuses a duplicate title unless asked to replace one. Two sections with one name make a summary entry that points at whichever the reader finds first.
- It verifies before writing. If the rebuilt list does not have exactly one entry per section, nothing is written and it says so — that mismatch is the failure it exists to prevent, and producing it silently would be worse than hand-editing.
- It will not write into an installed package. An entry under
vendor/is edited into oblivion by the nextcomposer update. A development checkout — a symlink, or a git tree — is the framework's own history, so that counts.
A new post gets the frontmatter, the # 28 August 2026 heading, and <!-- more --> in the
right place: everything above the fold is the excerpt the blog index shows, so the summary
belongs above it and the sections below.
log-analytics and log-errors¶
These two exist because of an asymmetry. «What is going wrong on this installation» is the
first question anybody asks, and the answer already existed — the /admin/logs dashboard
computes a trend, a breakdown by level, the most frequent errors with their counts, and a
per-file error rate. It was reachable only by a human with an administrator's session.
So an assistant asked to look at a problem got handed a pasted log file, which is both more work and less information: a paste has no counts, no rates, and no idea what it left out.
The aggregation now lives in Pramnos\Logs\LogAnalytics, and the dashboard is one of its
callers. One implementation on purpose — two copies of the same aggregation drift, and the day
they disagree the screen and the tool each look right on their own.
log-analytics returns the summary. Counts, not lines:
{} // the last 24h, every readable file
{"timespan": "1h"} // 1h, 6h, 24h, 7d, 30d
{"timespan": "7d", "files": ["error.log"]}
{
"timespan": "24h", "from": 1756300000, "to": 1756386400, "group": "hour",
"trends": {"08:00": 4, "09:00": 0, "…": 0},
"levels": {"info": 21, "error": 3},
"topErrors": [{"message": "column \"userid\" does not exist", "count": 2,
"file": "error.log", "last_seen": "…"}],
"files": {"error.log": {"last_entry": "…", "error_rate": 12.5, "total_entries": 24}},
"truncated": false
}
Three things in that answer are deliberate:
topErrorsis keyed by the message, so the same failure in three files is one row carrying the total — which is the number somebody acts on, rather than three that each look survivable.truncatedsays a file was too large to scan in full and only its tail was read. Without it, a summary of the last 25 MB of a multi-gigabyte log reads as a complete picture.- An empty
filescomes back with anote, because "no log files were readable" and "nothing has gone wrong" are otherwise the same answer, and only one means stop looking.
log-errors is the next question — what the lines say:
{} // newest error-level entries, last 24h
{"levels": ["warning"], "query": "timeout"}
{"files": ["error.log"], "timespan": "1h", "limit": 20}
Its defaults are the levels somebody means by "the error log" — emergency, alert,
critical, error. info and debug are available by asking and are not what anybody wants
first. The response is bounded at 200 entries and carries complete, which is false when the
limit was reached: an answer that stopped early otherwise reads as «that is all there is»,
which is the wrong conclusion for somebody deciding whether a problem is over.
Files with no structured entries — GitDeploy, GitWebhookDebug, which are shell output — are
skipped by both. Counting levels in them produces a number that looks like a measurement.
Debugging it¶
mcp:serve speaks JSON-RPC on stdio and blocks on STDIN. Run by hand it looks like a hang;
run by a client, the client owns both pipes. Neither state lets you see what a tool returned,
and hand-writing an initialize frame to find out has the worst property a debugging
procedure can have: a mistake in the frame is indistinguishable from a broken tool.
Three things, for three different questions.
mcp:call — what does this tool return?¶
php <cli> mcp:call # every tool, with the arguments it takes
php <cli> mcp:call log-analytics --arg timespan=6h
php <cli> mcp:call log-errors --json '{"limit": 5, "query": "timeout"}'
php <cli> mcp:call route-list --raw # the JSON-RPC envelope, unwrapped
With no tool named it lists them, each with its input schema — including the enums, which are the difference between one guess and five:
log-analytics
Summarise this installation's logs: entry trend, counts per level, …
· timespan: string (1h|6h|24h|7d|30d)
· files: array
Four things it does deliberately:
- It goes through
McpServer::dispatch(), the same method the stdio loop calls, rather than reaching for the tool object. A tool that works when called directly and fails through the protocol is a real bug and this is where it shows. --arg amount=2arrives as the number 2. A shell has only strings, and a schema that wants an integer would otherwise reject the obvious spelling.true,false,null, numbers anda,b,clists are converted; use--jsonwhen a literal string is meant.- A tool that threw exits non-zero. An exception inside a tool comes back as a successful JSON-RPC response whose content is the exception message, so without this it would print like an answer.
--rawprints the envelope, for when the wrapper rather than the tool is the suspect.
The MCP tab in the DevPanel — the same thing, interactively¶
/devpanel/mcp lists every registered tool, renders each one's schema as a form, and
calls it on the page. It is the answer to "what does this return" when you are already in the
panel, and it adds what a terminal cannot do conveniently: the arguments are discovered
instead of looked up.
- An enum becomes a
<select>. That is the whole reason to render the schema rather than a textarea. - Every field can be left out. An omitted argument and an empty string are different
things — a tool with a default gets to keep it — so each control carries an explicit
— omit —, and a boolean is a tri-state select rather than a checkbox, because an unchecked box cannot say "leave it out". - The arguments actually sent are printed above the answer.
{"limit": "5"}and{"limit": 5}are different calls, and a schema that rejected the first is otherwise a mystery. - A tool that threw is shown as a failure. The protocol reports it as a successful response whose content is the exception message.
show the JSON-RPC envelopeprints the request and response as they go over the wire, for when the wrapper rather than the tool is the suspect.
It builds its own server when the container has none, so the tab works with the mcp feature
switched off — and says so, because the thing the feature adds is the container binding that
an application's own tools get registered into. The POST that runs a tool carries a CSRF
token: the panel's other endpoints read, this one executes whatever a project registered.
mcp:serve --log — what is the client actually sending?¶
The question mcp:call cannot answer. When Claude Code or an IDE starts the server, that
process is not yours: you cannot see its STDOUT, and its STDERR goes wherever the client puts
it. --log records every message in both directions.
php <cli> mcp:serve --log # into the log directory, as mcp.log
php <cli> mcp:serve --log=/tmp/mcp.log
In .mcp.json, add it to args:
{ "mcpServers": { "myapp": { "command": "docker-compose", "args":
["exec", "-T", "-u", "www-data", "app", "php", "myapp.php", "mcp:serve", "--log"] } } }
Each line is written in the framework's own structured-log format, so the log viewer,
LogAnalytics and the log-errors tool all read the file without knowing anything about MCP:
{"timestamp":"28/08/2026 15:04:11","level":"info","message":"→ tools/call log-analytics",
"data":{ … the whole request … }}
{"timestamp":"28/08/2026 15:04:11","level":"info","message":"← result",
"data":{ … the whole response … },"duration_ms":41.2}
Which means the useful query is one you already have:
- A failed call is logged at
error— including a tool that threw, which the protocol reports as a successful response. Without that, the most interesting event in a session is filed as routine and unfindable among a thousand good calls. - A malformed line is logged with the input that caused it. "Parse error" alone is the least actionable message a protocol can produce.
- It is off unless asked for, and it says the path on STDERR when it is on. The payloads
are whatever the tools returned, which for
query-schemais table structure — leave it switched off when you are done. - An unwritable path degrades to no logging rather than taking the server down. A debugging aid that kills the process it is instrumenting is worse than none.
The built-in resources¶
The application's own files, registered only when present:
| URI | File |
|---|---|
file://CLAUDE.md |
ROOT/CLAUDE.md |
file://README.md |
ROOT/README.md |
file://app/app.php |
ROOT/app/app.php |
file://docs/<name>.md |
every markdown file directly inside ROOT/docs/ |
The last row is discovered, not listed, and that is the point of it. A project's own notes —
a request log, a decisions file, whatever that project calls it — are exactly the documents
somebody wants in context from the first message, and they were never going to be named in the
framework. docs/ is where a project puts them, and a directory scan cannot go out of date.
Top level only, and markdown only: a recursive scan picks up a vendored copy of somebody else's manual, and a resource list nobody reads is the same as no resource list.
Adding your own¶
A tool¶
Implement Pramnos\Mcp\McpToolInterface — four methods, all required:
use Pramnos\Mcp\McpToolInterface;
class StationHealthTool implements McpToolInterface
{
/** Unique within one server instance. */
public function name(): string
{
return 'station-health';
}
/** One sentence; it is all a model reads when deciding whether to call this. */
public function description(): string
{
return 'Report the last successful stream check for a station.';
}
/** JSON Schema as a PHP array. */
public function inputSchema(): array
{
return [
'type' => 'object',
'properties' => [
'station_id' => ['type' => 'integer', 'description' => 'The station'],
],
'required' => ['station_id'],
];
}
/** Any JSON-serialisable value. */
public function execute(array $input): mixed
{
return ['checked_at' => /* … */];
}
}
Register it in a service provider's boot():
The application behind an MCP tool is the console kernel
mcp:serve is a console command, so the application your tool receives never handled
an HTTP request. Anything built during HTTP bootstrap is absent — the router being the
one that catches people out. RouteListTool therefore builds a router itself with
Router::loadFromDirectory(), because attribute routes are discoverable without a
request; that is the point of them being attributes.
Before that fix it answered {"error": "No router available"} on the only path that
can reach it. Not a defensive fallback — the whole method. A tool that returns an
error on every real call is indistinguishable from a working one until somebody reads
the output, so if yours needs something HTTP-shaped, build it rather than reporting
its absence.
A resource¶
use Pramnos\Mcp\McpResource;
$server->addResource(new McpResource(
uri: 'file://docs/architecture.md',
name: 'Architecture notes',
filePath: ROOT . '/docs/architecture.md',
mimeType: 'text/markdown',
));
read() returns null for a missing or unreadable file rather than throwing, so a
resource that disappears degrades to absent instead of breaking the session.
Serving it over HTTP, to somebody else's assistant¶
Everything above is the internal server: mcp:serve over stdio, nineteen tools that read
coverage reports and run the style checker, for an assistant working alongside you on this
machine. None of it should ever answer a stranger.
The public endpoint is a separate thing that happens to speak the same protocol.
// A ServiceProvider, or app/providers.php
use Pramnos\Mcp\PublicRegistry;
use Pramnos\Mcp\Tools\SearchTool;
PublicRegistry::add(new SearchTool());
That is the whole of it. init scaffolds POST /mcp when the authserver feature is on, and the
endpoint answers with an empty tool list until an application registers something.
Offering a capability: the short way¶
Most of what an application wants to offer is a name, a sentence and a closure. Writing a class for three lines of logic is the reason capabilities do not get offered at all, so there is one call that needs no class:
use Pramnos\Mcp\PublicRegistry;
PublicRegistry::offer(
name: 'station-health',
scope: 'user',
description: 'Report the last successful stream check for a station.',
input: ['station_id' => 'integer', 'verbose' => 'boolean?'],
handler: fn (array $in) => Station::health((int) $in['station_id']),
);
That is a complete, authenticated, scope-gated MCP tool.
The five arguments, and what each is really for:
name |
What a model calls. Unique within the endpoint. |
scope |
The OAuth scope a token must hold. There is no default — see below. |
description |
One sentence, and it is all a model reads when deciding whether to call this. It is not documentation for a person; it is the entire basis of the decision. "Report the last successful stream check for a station" gets called correctly. "Station tool" does not get called at all, or gets called for everything. |
input |
A compact spec, or full JSON Schema. |
handler |
Anything callable. It receives the decoded input and returns anything JSON-serialisable. |
The input spec. ['station_id' => 'integer', 'verbose' => 'boolean?'] is the same document as
fourteen lines of nested arrays, and the fourteen lines are where people stop. A trailing ?
marks a parameter optional; everything else is required — required-by-default is the safer
mistake, because a model omitting something the tool needs gets a clear refusal, where a tool
quietly running without it produces a wrong answer nobody questions.
Anything the short form cannot say is written as ordinary JSON Schema, and there is no wall to hit at either end:
// One awkward parameter among the shorthand
input: [
'query' => 'string',
'mode' => ['type' => 'string', 'enum' => ['fast', 'slow']],
],
// Or the whole thing spelt out — a spec with a top-level `type` passes through untouched
input: ['type' => 'object', 'properties' => [/* … */], 'required' => ['id']],
Offering a capability: the long way¶
Implement ScopedMcpTool when the tool has state, needs constructor injection, or is worth unit
testing on its own:
use Pramnos\Mcp\ScopedMcpTool;
class StationHealthTool implements ScopedMcpTool
{
public function __construct(private StationRepository $stations)
{
}
public function name(): string { return 'station-health'; }
public function description(): string { return 'Report the last stream check.'; }
public function requiredScope(): string { return 'user'; }
public function inputSchema(): array
{
return PublicRegistry::schema(['station_id' => 'integer']);
}
public function execute(array $input): mixed
{
return $this->stations->health((int) $input['station_id']);
}
}
PublicRegistry::add(new StationHealthTool($stations));
PublicRegistry::schema() is available to a class too — the compact form is not tied to the short
door.
Both doors lead to the same registry and obey the same rules. Neither is the "real" one.
Three things worth getting right¶
The scope is not decoration. It is the only thing standing between a capability and every person with a token. Pick the narrowest scope that makes sense, and if none of the existing scopes fits, that is a signal the capability needs a scope of its own rather than a borrowed one.
A tool runs as the person, not as the application. The token identifies somebody, and
User::getCurrentUser() is that person inside a handler. Scope every query by them. The endpoint
having authenticated the caller is not the same as the handler having checked that this caller may
see this row — the first is who, the second is what.
Return data, not prose. A handler returning "Station 4 was last checked at 14:02" gives a
model a sentence to re-read to somebody. Returning ['station' => 4, 'checked_at' => '…'] gives it
something to reason with, combine with another answer, and phrase itself. Structure survives
translation, summarising and being asked a follow-up; a sentence does not.
Why two registries and not one list with a flag¶
A shared collection with an "expose this" flag means every tool written from then on is public until somebody remembers to mark it otherwise. The failure is silent and remote: a twentieth development tool is added, and it is answering over HTTP before anybody notices it is reachable.
So public exposure is a type, not a flag. A tool must implement ScopedMcpTool, which adds one
method:
A tool that does not implement it cannot be added to PublicRegistry, and no configuration file
can override that. A tool that implements it but returns an empty scope is refused at registration
with an exception rather than skipped — a tool quietly dropped at boot is absent at run time for a
reason nobody can see.
Authentication is the one this server already does¶
The endpoint sits behind UnifiedAuthMiddleware, which validates the bearer token, loads its
scopes from usertokens and resolves the user. Nothing about OAuth is reimplemented here.
An unauthenticated call gets 401 and a header:
WWW-Authenticate: Bearer resource_metadata="https://example.com/.well-known/oauth-protected-resource"
That header is the discovery mechanism. An MCP client is expected to call blind, be refused,
read the document it is pointed at, find the authorization server and come back with a token. A
bare 401 ends the conversation — the client has nowhere to go and a person has to configure it
by hand.
Only what the token reaches, and the guarantee is structural¶
The server is built per request, holding only the tools the caller's scopes reach. A tool the caller may not use is not in that server at all, so naming it directly answers unknown tool.
That is stronger than filtering the list and checking again on the call: there is one decision rather than two that can disagree, a client that never read the list gains nothing by guessing, and the refusal does not confirm that the tool exists.
Scopes resolve through Scopes::resolveInheritedScopes(), so a parent scope satisfies a tool
asking for a child — the same rule the router applies, rather than a second one that drifts from
it. A same-origin session's wildcard * reaches everything, exactly as it does elsewhere. A token
whose scopes cannot be read reaches nothing: an unreadable scope list means "I do not know what
this caller may do", and the safe reading of that is not "everything".
SearchTool, and why it is the one worth shipping¶
It needs no code from the application beyond the Registry::register() calls it already makes for
its own search box — see the Search guide.
That works because Search\Registry was built for a box that several kinds of user share. Each
source declares a permission; each row is scoped by a filter callable that receives the current
user; both fail closed, and a dropped source leaves no trace rather than an empty group, because
an empty group named "Invoices" tells somebody who may not see invoices that invoices exist. An MCP
caller is a signed-in person with a token, so the question is the one the search box already asks
and the answer is scoped by the same code.
Its grouped-not-ranked shape carries through, and suits a language model better than a flat list: "5 users, 3 orders" is a fact it can reason about, where a merged ranking invites it to treat position as meaning.
Two returns are deliberately distinguishable. An empty term gives total: 0 and no groups — never
the first page of everything. An installation with no registered sources says so in a note,
because a model told "0 results" concludes the thing does not exist and tells the person so.
Protocol methods handled¶
| Method | Result |
|---|---|
initialize |
Server name, version, capabilities |
tools/list |
Every registered tool with its schema |
tools/call |
Runs one and returns its value |
resources/list |
Every registered resource |
resources/read |
The content of one |
ping |
Empty result |
Anything else returns JSON-RPC error -32601.
Related¶
- Console Commands —
mcp:serve,.mcp.json, and whatinitandproject:resyncwrite. - Debugging — the toolbar and the API playground, which answer what did this request do rather than what does this project contain.