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/initialized connection 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 mandatory server/discover call.

Legacy request
{"jsonrpc": "2.0", "id": 1, "method": "initialize"}
Modern request — carries its own version/capabilities per call
{
    "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 & ValidationHydrator/JsonSchema, the validation machinery MCP tool arguments share with HTTP request bodies.

  • Logging — registering the logger McpServer uses.

  • Caching & AOT Compilation — how bin/kinetis mcp:serve avoids re-reflecting every registered tool on every single call in production.