Model Context Protocol (MCP)¶
Kinetis’s native Model Context Protocol server — expose tools and resources an AI agent can discover and call using the same attributes and validation you already use for HTTP routes. It ships as its own package, and installing it is the whole setup:
composer require kinetis/mcp
That one install registers everything below — the kinetis mcp:serve
command, the /mcp HTTP endpoint, and Kinetis’s own documentation as
readable resources — through the package’s extra.kinetis declaration,
with nothing to wire by hand. Without the package, none of it exists:
core has no MCP surface of its own.
Tools and resources¶
use Kinetis\Mcp\Attributes\{McpTool, McpResource};
final readonly class AccountController
{
#[McpTool(name: 'get_user_status', description: 'Retrieve user status by ID')]
public function getUserStatus(int $userId): array
{
return ['userId' => $userId, 'status' => 'active'];
}
#[McpTool(name: 'create_user', description: 'Create a user account')]
public function createUser(CreateUserRequest $data): array
{
return ['name' => $data->name, 'email' => $data->email];
}
#[McpResource(uri: 'kinetis://status', name: 'status', description: 'Server status')]
public function status(): string
{
return 'ok';
}
}
Nothing to register by hand — any class anywhere under one of your own
PSR-4 roots is discovered automatically the moment #[McpTool]/
#[McpResource] appears on one of its methods (more on how that works
under Transports below). Installed packages can
contribute tools and resources the same way, through their own
extra.kinetis scan roots — see CLI.
A tool call’s arguments always arrive as one flat named JSON object, so
there’s no #[Body]/#[Query] distinction to make the way HTTP routing
needs one (see Routing & Validation) — every parameter is resolved
from that object the same way. A class-typed parameter (like
CreateUserRequest above) is validated using the same constraint
attributes an HTTP request body uses; a failed validation becomes a
normal tool result with isError: true, not a transport-level error —
more on that distinction below.
The tool’s JSON Schema input is built automatically from the method’s
parameters, so #[Email]/#[MinLength]/etc. describe an MCP tool’s
arguments exactly as precisely as they describe an HTTP request body.
Transports¶
stdio — the primary transport¶
php vendor/bin/kinetis mcp:serve
This is how Claude Desktop, Cursor, and most local MCP clients actually
talk to a server: launched as a subprocess, one JSON-RPC message per line
on stdin, one response per line on stdout. There’s nothing to register —
any class anywhere under one of your own PSR-4 roots is found
automatically, the moment #[McpTool]/#[McpResource] appears on one of
its methods, with no required directory or namespace convention. See
CLI for exactly how this discovery works, how to restrict it for a
large application, and how it interacts with production caching.
Streamable HTTP¶
/mcp is an ordinary route — Kinetis\Mcp\Http\McpController,
discovered from this package’s own scan root the moment the package is
installed, with nothing to pass to Kernel:
curl -X POST http://localhost:8080/mcp \
-H "Content-Type: application/json" \
-d '{"jsonrpc":"2.0","id":1,"method":"tools/list"}'
Being an ordinary route is what gives every message the full request
lifecycle with nothing special wired: a fresh request scope per message,
tool and resource controllers resolving from it — a tool
constructor-injecting RequestScope receives the live scope of its own
call — a dangling transaction rolled back through the same
TransactionGuard hook every HTTP request gets, and disposal once the
response is written. State a tool registers on its scope does not
survive to the next message. The stdio transport gives each line the
same treatment. Only a hand-rolled transport that calls
McpServer::handle() without passing a scope keeps the unscoped
behavior.
GET /mcp and DELETE /mcp both answer the router’s own 405 carrying
Allow: POST — correct for both protocol eras,
below. Earlier Streamable HTTP revisions used GET to
open a server-initiated stream and DELETE to terminate a session;
Kinetis implements neither, so neither method is declared.
Securing the HTTP transport¶
The endpoint’s own middleware is the mcp middleware group, which
McpController references like any route references a group — resolved
from each request’s own scope, so everything route middleware can do
works here (see Middleware):
Originvalidation, always on. The Streamable HTTP specification requires validating theOriginheader on every incoming connection to prevent DNS-rebinding attacks. The package’s ownMcpOriginMiddlewareis a permanent member of the group and readsMCP_ALLOWED_ORIGINS— a comma-separated exact list, empty by default, so any request carrying anOriginheader at all is rejected403until the deployment names which ones may connect. A request with noOriginheader (any non-browser client — the stdio transport never sends one) passes regardless..env¶MCP_ALLOWED_ORIGINS=https://my-mcp-client.example
Your own middleware, via
#[AsMiddlewareGroup('mcp')]. Declare membership on the middleware class and it joins the endpoint’s pipeline — after the origin check, which runs first by priority. Because the group resolves from the request’s scope,kinetis/auth’sBearerAuthMiddlewareandkinetis/auth-jwt’sJwtAuthMiddlewaresubclasses work here unchanged, and theCurrentUserInterfacethey publish reaches the tool:use Kinetis\Http\Attributes\AsMiddlewareGroup; #[AsMiddlewareGroup('mcp')] final readonly class McpAuthMiddleware extends BearerAuthMiddleware {}
A tool that constructor-injects
CurrentUserInterface(or resolves it from its injectedRequestScope) sees exactly the identity the middleware resolved for this message — the same mechanism an HTTP controller already uses.
Global middleware wraps /mcp too, like every route — the group exists
for what should apply to this endpoint only.
Protocol eras¶
Kinetis’s McpServer supports both MCP protocol eras side by side, in
the same class, with the older one’s code path completely untouched by the
newer one:
Legacy (2025-03-26) — the
initialize/notifications/initializedconnection handshake most real clients still speak today.Modern (2026-07-28) — a fully stateless, per-request model: every request carries its own protocol version and capabilities in
params._meta, replacing connection-level negotiation with a mandatoryserver/discovercall.
{"jsonrpc": "2.0", "id": 1, "method": "initialize"}
{
"jsonrpc": "2.0",
"id": 1,
"method": "tools/list",
"params": {
"_meta": {
"io.modelcontextprotocol/protocolVersion": "2026-07-28",
"io.modelcontextprotocol/clientCapabilities": {}
}
}
}
Which era a message belongs to is decided by the presence of an
io.modelcontextprotocol/-prefixed key in _meta, not merely by whether
_meta exists at all — a legacy client can send _meta.progressToken
(below) entirely on its own, and that alone doesn’t make the request
modern.
A modern request’s result is wrapped in the spec’s envelope automatically:
{"jsonrpc": "2.0", "id": 1, "result": {"resultType": "complete", "tools": [/* ... */]}}
Request headers¶
A modern request over HTTP mirrors three body fields into headers, so an intermediary (a load balancer, a gateway) can route or inspect a request without parsing the body — required on every modern-era request:
POST /mcp HTTP/1.1
Content-Type: application/json
MCP-Protocol-Version: 2026-07-28
Mcp-Method: tools/call
Mcp-Name: get_weather
{"jsonrpc": "2.0", "id": 1, "method": "tools/call", "params": {"name": "get_weather", ...}}
MCP-Protocol-Version mirrors _meta’s own protocol version;
Mcp-Method mirrors method; Mcp-Name mirrors params.name on
tools/call or params.uri on resources/read (required for those two
methods only — every other method has no name/uri to mirror). A
value that isn’t safe as a plain header (non-ASCII characters, control
characters) is sent Base64-encoded, wrapped as =?base64?{...}?=:
Mcp-Name: =?base64?SGVsbG8sIOS4lueVjA==?=
A header that’s missing, or that doesn’t match the corresponding body
value once decoded, is rejected with 400 and a JSON-RPC -32020
HeaderMismatch error — this check only runs for modern-era requests;
a legacy 2025-03-26 client never sends these headers at all.
Caching hints and server instructions¶
server/discover, tools/list, and resources/list results carry
ttlMs (how long a client may consider the result fresh, in
milliseconds) and cacheScope ("public", since these describe the
server’s own registered tools/resources — identical for every caller).
resources/read carries the same ttlMs, but cacheScope: "private" by
default, since a resource’s own content can be caller-specific in a way
this server has no way to know about in general. tools/call never
carries either — it’s an action, not a cacheable read.
{"jsonrpc": "2.0", "id": 1, "result": {"resultType": "complete", "tools": [/* ... */], "ttlMs": 3600000, "cacheScope": "public"}}
server/discover’s own result can also carry instructions — a short,
natural-language description of what this server’s tools are for, shown
to an agent alongside its capabilities:
$server = new McpServer($registry, $dispatcher, instructions: 'This server manages orders and inventory.');
Omitted entirely when not given, rather than sent as an empty string.
Progress notifications¶
A long-running tool can report progress on its own call — over any transport, including stdio, with no special-casing needed there since stdio is already one-message-per-line:
#[McpTool(name: 'slow_count', description: 'Reports progress three times')]
public function slowCount(ProgressReporter $progress): array
{
for ($i = 1; $i <= 3; $i++) {
sleep(1);
$progress->report($i, total: 3, message: "step {$i}");
}
return ['done' => true];
}
ProgressReporter is recognized by its type and never counted as one of
the tool’s own arguments. Calling report() when the caller never opted
in (no _meta.progressToken on the request) is always safe — it’s simply
a no-op, so tool code never needs to check whether it’s in a streaming
context before calling it.
Over HTTP specifically, a tools/call request carrying _meta.progressToken
gets a genuine text/event-stream response: progress events arrive
incrementally, as the tool calls report(), not buffered until the end.
Every other request still gets a single buffered JSON response; this is
additive, not a change to the default shape.
Note
This is deliberately built with no Fiber/generator machinery at all —
report() just invokes a closure synchronously, inline, on the same call
stack as the tool method itself. Nothing here needs to suspend or resume
execution; it only needs a way to write output at a specific point during
an already-synchronous call.
Error handling¶
A tool or resource executing and failing — a thrown exception, a failed
Hydrator validation — is reported as a normal result with isError: true
in its content, not a JSON-RPC transport error:
{
"jsonrpc": "2.0",
"id": 1,
"result": {
"content": [{"type": "text", "text": "{\"errors\":{\"email\":[\"must be a valid email address.\"]}}"}],
"isError": true
}
}
This is the actual MCP convention: an agent sees “the tool ran, but
failed,” rather than a transport-level failure it has no way to
distinguish from a broken connection. Only genuine protocol-level problems
— an unknown method, an unknown tool name, a malformed request — become a
real JSON-RPC error response.
What the content carries depends on the failure. A failed validation
carries its real errors map, as above — that’s the argument feedback an
agent needs to retry correctly. Any other exception carries the fixed
string Tool execution failed., with the real exception going to the
logger instead — an unexpected failure’s message can hold SQL error text,
file paths, or anything else internal, none of which belongs in a
response to whatever agent happens to be connected.
A resource method throwing is different: readResource() has no inner
try/catch of its own the way a tool call does, so the exception
propagates to McpServer::handle()’s own top-level catch and becomes a
-32603 Internal error — logged through whatever Psr\Log\LoggerInterface
you pass to McpServer’s logger constructor parameter (see
Logging). It defaults to a NullLogger, since McpServer is
constructed directly rather than resolved through the container; bin/kinetis mcp:serve already wires this through for you.
Exposing Kinetis’s own docs as a resource¶
Kinetis\Mcp\KinetisDocsResource registers every page of this documentation
site as an MCP resource — kinetis://docs/tutorial,
kinetis://docs/routing-validation, and so on — so an agent working in
your codebase can read Kinetis’s own docs the same way it reads your
app’s resources, instead of relying on stale training data about the
framework:
Included automatically on both transports — the class lives under this
package’s own scan root, so discovery finds it exactly the way it finds
your application’s resources. Registering it explicitly
($registry->register(KinetisDocsResource::class)) is only needed for a
hand-wired McpRegistry that never goes through discovery.
Each resource returns the actual docs/*.md source as text/markdown —
read from the monorepo when developing Kinetis itself, and fetched from
the published documentation otherwise — so there’s nothing to keep in
sync as pages change.
A standalone docs server for Claude Code¶
No Kinetis project needed for this — one command installs
kinetis/framework into its own directory and registers Kinetis’s docs
as an MCP server in Claude Code directly:
curl -fsSL https://raw.githubusercontent.com/kinetis-dev/kinetis/main/tools/setup-docs-mcp.sh | bash
Requires a running Docker daemon and the claude CLI already on your
machine — nothing else, no PHP or Composer of your own, and no sudo:
everything it does runs as your own user, writing only to
~/.kinetis-mcp and your own Claude Code configuration. Start a new
Claude Code session afterward to use it.
The registered server checks for a newer kinetis/framework release on
its own, at most once a day, so it stays current without needing to be
set up again.
See also¶
Routing & Validation —
Hydrator/JsonSchema, the validation machinery MCP tool arguments share with HTTP request bodies.Logging — registering the logger
McpServeruses.Caching & AOT Compilation — how
bin/kinetis mcp:serveavoids re-reflecting every registered tool on every single call in production.