Model Context Protocol (MCP)¶
Kinetis ships a 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.
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';
}
}
$registry = new Kinetis\Mcp\McpRegistry();
$registry->register(AccountController::class);
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 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¶
The same McpServer/McpRegistry also work over HTTP for free, via
Kernel’s opt-in $mcp constructor parameter:
use Kinetis\Http\Kernel;
use Kinetis\Mcp\McpDispatcher;
use Kinetis\Mcp\McpServer;
$mcp = new McpServer($registry, new McpDispatcher($app));
$kernel = new Kernel($app, $router, mcp: $mcp);
curl -X POST http://localhost:8080/mcp \
-H "Content-Type: application/json" \
-d '{"jsonrpc":"2.0","id":1,"method":"tools/list"}'
GET /mcp answers 405 — correct for both protocol eras, below
— rather than falling through to routing’s 404.
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": [/* ... */]}}
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.
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/getting-started,
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 when running mcp:serve — nothing to opt into.
For the HTTP transport, where you construct McpRegistry yourself,
register it explicitly:
use Kinetis\Mcp\KinetisDocsResource;
$registry->register(KinetisDocsResource::class);
Each resource returns the actual docs/*.md source as text/markdown,
read directly from wherever kinetis/kinetis is installed, so there’s
nothing to keep in sync as pages change.
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.