Appendix: MCP Reference

The contracts behind Model Context Protocol (MCP): the protocol revision and its request grammar, Streamable HTTP and its status codes, the error catalogue, progress streaming, the mcp middleware group, request scope and disposal, and argument binding. For the task-first path — tools, resources, stdio and HTTP setup — see Model Context Protocol (MCP).

Ownership

kinetis/mcp-protocol owns the wire: JSON-RPC 2.0 envelopes, the one MCP revision, typed tool and resource descriptions, progress notifications, and the checked newline-delimited stdio loop. kinetis/mcp owns everything Kinetis-specific around it — the attributes, discovery and registry, schema generation, hydration and validation, telemetry, middleware, request scopes, the /mcp route and the package bootstrap — and adapts them to that wire through Kinetis\Mcp\KinetisMcpApplication. kinetis/mcp-docs and kinetis/orbitron are the other two consumers of the same protocol package, and Orbitron composes kinetis/mcp-docs to publish its resources too; neither depends on kinetis/mcp, whose installation would register a bootstrap and a discovery plugin in the consumer application.

Protocol revision

Kinetis\McpProtocol\McpServer implements MCP 2025-06-18 and no other. A session opens with the ordinary lifecycle handshake:

{"jsonrpc": "2.0", "id": 1, "method": "initialize", "params": {"protocolVersion": "2025-06-18", "capabilities": {}, "clientInfo": {"name": "claude-code", "version": "2.1.273"}}}

protocolVersion must be a non-empty string, capabilities an object, and clientInfo an object with non-empty string name and version; anything else is -32602. The answer always selects 2025-06-18, whatever the client asked for — the specification’s rule is that a server responds with a version it supports and the client decides whether to continue, so a single-version server has no unsupported-version error to raise. A repeat initialize returns the identical result: the server keeps no negotiated state.

{"jsonrpc": "2.0", "id": 1, "result": {"protocolVersion": "2025-06-18", "capabilities": {"tools": {}, "resources": {}}, "serverInfo": {"name": "Kinetis", "version": "1.0.0"}}}

capabilities carries tools only when the application registered at least one tool, and resources only when it registered at least one resource, so a server never invites a call it cannot answer. instructions is present only when the consumer supplied it — kinetis/mcp supplies none.

The server answers six methods: initialize, ping, tools/list, tools/call, resources/list and resources/read. Any other request method is -32601. tools/list and resources/list return the whole list and issue no nextCursor, so a cursor a client sends is one this server never gave it and is -32602.

Notifications are never answered and never dispatch anything. notifications/initialized is a no-op, and every other valid notification is suppressed — including a tools/call without an id, which runs no tool, because nothing this revision defines from client to server asks this server to act. A structurally valid response message (an id plus exactly one of result and error, and no method) is ignored too: this server sends no requests, so there is nothing it could be a response to.

Initialization ordering is not enforced. A client is required to initialize first, but the same stateless server sits behind the HTTP route, where each request stands alone, so an otherwise valid tools/list before initialize is answered rather than refused.

Streamable HTTP

POST /mcp carries one JSON-RPC message. MCP-Protocol-Version is the transport’s only protocol header:

POST /mcp HTTP/1.1
Content-Type: application/json
MCP-Protocol-Version: 2025-06-18

{"jsonrpc": "2.0", "id": 1, "method": "tools/call", "params": {"name": "get_weather", "arguments": {"city": "Berlin"}}}

initialize may arrive without the header, because it is what establishes the version. Every later message must carry it and must carry exactly 2025-06-18. A missing header on a later message means the specification’s 2025-03-26 fallback, which this single-version server does not implement, so it is 400 rather than assumed; an unsupported or malformed value is 400 too. Nothing is remembered between requests, so an earlier initialize never excuses a missing header on a later one.

Sessions are optional in this revision and this server issues none: no Mcp-Session-Id is ever sent, and none is ever required. GET /mcp and DELETE /mcp answer the router’s own 405 with Allow: POST — GET opens a server-initiated stream and DELETE terminates a session, and neither is implemented.

HTTP status codes

Outcome

Status

Body

A JSON-RPC result, or a JSON-RPC error after a valid envelope

200

JSON-RPC response

Malformed JSON or a malformed envelope (-32700, -32600, -32602 on params)

400

JSON-RPC error

A missing, unsupported or malformed MCP-Protocol-Version

400

JSON-RPC -32600 naming the supported revision

A tools/call request with a well-formed _meta.progressToken

200

text/event-stream; an error arrives in the final event

A notification, or a client response message

202

none

An Origin not listed in MCP_ALLOWED_ORIGINS

403

{"error": "Origin \"…\" is not allowed to access this MCP endpoint."}

No CurrentUserInterface and MCP_HTTP_PUBLIC not true

401

{"error": "Unauthenticated."}

A body over the request body limit

413

the framework’s error body

GET or DELETE

405

the framework’s error body, with Allow: POST

A request whose envelope was understood carries its outcome in the JSON-RPC envelope rather than in the status: an unknown method, an unknown tool name and a failed parameter check are all 200. Only input the transport could not use at all is a 4xx.

A 401 produced by an authentication middleware in the group, such as BearerAuthMiddleware’s, is that middleware’s own response and runs before the guard.

Malformed requests

Every message — over HTTP, over stdio, or handed to McpServer::handle() directly — passes the same structural validation in Kinetis\McpProtocol\JsonRpcCodec before method is dispatched. Invalid JSON is -32700 Parse error under id: null. Valid JSON that is not a well-formed message is -32600 Invalid Request: a missing or wrong jsonrpc, a missing or empty method, an id outside string and integer, or a top-level JSON array. Batching is not part of this revision, so an array body is rejected outright.

An id MUST NOT be null in 2025-06-18, so {"id": null} is an invalid request rather than a request whose id is null. A boolean, a float or a structured id is refused the same way, and the error answers under id: null because there was nothing valid to echo.

Only a structurally valid message without id is a notification, and it gets no response. A structurally invalid message still gets an error, because the envelope is exactly the thing that would have told us it was a notification. Over HTTP a malformed body therefore never becomes 202: a 202 always means a genuine notification or a client response message.

params, _meta and tools/call’s arguments are named objects. A present value that is a JSON array, a scalar or null is -32602; only omitting the field means “none given”. {} and [] are different on the wire and get the responses their shapes mean. A present _meta.progressToken that is not a string or an integer is also -32602, rather than silently disabling progress. A notification whose own params are malformed is neither answered nor dispatched.

tools/call’s name and resources/read’s uri are required non-empty strings. A well-formed name or URI the consumer does not publish is refused before anything runs: -32602 for a tool, -32002 for a resource.

A caller building a message in PHP — a test, or an embedder bypassing both transports — cannot write {} and [] differently in an array literal. Kinetis\McpProtocol\JsonObject marks a value as an object: new JsonObject() is accepted exactly like a decoded {}, and JsonRpcCodec::toObjectTree() converts a tree holding markers back into the stdClass/array shape a consumer receives, so a marker never reaches consumer code.

Error catalogue

Code

Message

Raised when

-32700

Parse error.

The body or line is not valid JSON, or a stdio line exceeded the payload cap.

-32600

Invalid Request.

The envelope is malformed, including a top-level array. HTTP also uses it, with its own message, for a missing or unsupported MCP-Protocol-Version.

-32601

Method not found: "…".

The method is not one of the six this server answers.

-32602

varies

A named object has the wrong shape, name, uri, protocolVersion, capabilities or clientInfo is missing or malformed, progressToken has the wrong type, a cursor was sent, or the tool name is not registered.

-32002

Resource not found: "…".

resources/read names a URI the consumer does not publish; error.data.uri carries it.

-32603

Internal error.

An unexpected exception outside a tool call, including a resource method that throws, or a response the consumer made unencodable.

A tool that runs and fails is not in this table. KinetisMcpApplication::callTool() catches it and returns a result with isError: true:

  • A ValidationException carries its violations as {"errors": [...]} in the text content, each entry with a segmented path, a stable code, a message and its parameters — the structure the HTTP renderer puts in its RFC 9457 document, never that document itself. Invalid UTF-8 in a violation is substituted rather than failing the encode.

  • Any other exception, including a tool result that cannot be JSON-encoded, becomes the fixed text Tool execution failed..

A read has no error result of its own: a resource method that throws becomes -32603 with the fixed message Internal error.. Every unexpected exception the protocol server catches is redacted the same way, and its text is discarded rather than reported — a consumer that wants diagnostics writes them before letting the exception reach that boundary. A resource method’s string return value is its text; anything else is JSON-encoded.

Containment covers the response as well as the exceptions on the way to it. A consumer chooses the bytes in a tool result, a resource’s text, and a protocol error’s own message and data, and any of them can be a string PHP accepts and JSON refuses. The protocol server checks every envelope before returning it and answers the same generic -32603 under the request’s own id when it cannot be encoded — never the bytes, and never a repaired version of them, since altered content under a successful result would be a worse answer than an honest failure. Encoding those bytes where the frame is written would instead end a persistent stdio process and lose every message queued behind the bad one.

The real exception, in every case, goes to the Psr\Log\LoggerInterface passed to KinetisMcpApplication, NullLogger by default. The package’s bootstrap passes the container’s logger, so mcp:serve and /mcp both log through the application’s binding (see Logging). A logger that throws is caught and discarded; it can never replace the response or stop a stdio process.

Progress streaming

A Kinetis\Mcp\ProgressReporter-typed tool parameter is injected by type. It is excluded from the tool’s inputSchema, never required of a call, and an argument of that name sent by a client is unexpected_field. report(int|float $progress, int|float|null $total = null, ?string $message = null) delegates to the protocol package’s own emitter synchronously, on the tool’s own call stack; no Fiber or generator is involved. Without _meta.progressToken on the request, report() does nothing, so a tool calls it unconditionally.

Each report becomes one notification. total and message are omitted when not given, rather than written as null — an absent optional and an explicit null are different values, and only the first means “not reported”:

{"jsonrpc": "2.0", "method": "notifications/progress", "params": {"progressToken": "reindex-1", "progress": 2, "total": 3, "message": "halfway"}}

Over stdio, each notification is one line written before the response line.

Over HTTP, a tools/call request carrying a well-formed _meta.progressToken is answered with Content-Type: text/event-stream and X-Accel-Buffering: no. Each notification is one data: <json> event, flushed as report() is called, and the final event is the JSON-RPC response. The status is 200 because headers are sent before the tool runs, so a JSON-RPC error arrives in that final event. Every other request gets one buffered JSON response — including one whose progressToken has the wrong type, which is rejected as -32602 rather than streamed.

A tools/call notification — id absent — never opens a stream and never runs the tool. It gets 202 with no body.

The mcp middleware group

McpController references the mcp group with #[Middleware('@mcp')], and the group resolves from each request’s scope like any route middleware (see Middleware). Members run by descending #[AsMiddlewareGroup] priority:

Priority

Member

100

Kinetis\Mcp\Http\McpOriginMiddleware, always present

50 (the attribute’s default)

the application’s own members, such as an authentication middleware

0

Kinetis\Mcp\Http\McpIdentityGuardMiddleware, always present

McpOriginMiddleware reads MCP_ALLOWED_ORIGINS as a comma-separated list of exact values, each trimmed. A request without an Origin header passes; one whose Origin is not listed is 403. The Streamable HTTP specification requires this check against DNS rebinding. Its permanent membership is also what guarantees the group exists whenever the package is installed.

McpIdentityGuardMiddleware passes a request when MCP_HTTP_PUBLIC is true or when CurrentUserInterface is registered on the request’s scope (RequestScope::isRegistered(), which an autowired instance does not satisfy). Otherwise it answers 401 before McpController is constructed: nothing is dispatched, and the body is the framework’s own {"error": "Unauthenticated."}. It sends no WWW-Authenticate challenge, since the scheme belongs to the authentication middleware, and nothing in the response distinguishes a missing identity from a closed endpoint. A middleware registering only a concrete user class does not open the endpoint.

MCP_HTTP_PUBLIC is a typed Kinetis\Config boolean read from the boot-time environment (see Configuration): a value that is not a recognized boolean throws InvalidConfigValueException, and the endpoint stays closed.

The stdio transport has no group, no guard and no MCP_HTTP_PUBLIC.

Global middleware wraps /mcp like every route, including RequestBodyMiddleware (see Middleware), which bounds and stages the body before routing: an oversized body, with or without an honest Content-Length, is 413 before McpServer sees it.

Compiled group membership

A middleware group’s membership is compiled into .kinetis-cache/compiled.php (see Caching & AOT Compilation), and a published artifact is superseded only by a rebuild or a cache format change. A deployment that pre-warms the cache runs kinetis build in every deploy that changes the group’s members — adding an authentication middleware, or upgrading kinetis/mcp — or it keeps serving /mcp with the membership it was built with. Development’s live discovery, and a production deployment that compiles lazily against an empty .kinetis-cache/, pick up the change with nothing extra to do.

Request scope and disposal

Over HTTP, /mcp is an ordinary route and gets the full request lifecycle: a fresh RequestScope per request, every AppScope::onRequestScopeCreated() initializer run on it — including kinetis/database-bridge’s lazy TransactionGuard binding (see Appendix: Container Lifecycle) — the tool or resource class and its dependencies resolved from it, and disposal once the response is written. A tool constructor-injecting RequestScope receives that live scope, and whatever an mcp-group middleware registered on it.

A progress-streamed call runs on the same scope. Kernel keeps a streaming response’s scope alive until the stream is settled and then disposes it (see Appendix: Container Lifecycle), so everything the middleware published — CurrentUserInterface, a concrete class alongside it such as JwtUser, anything else request-scoped — is still there when the tool runs. A disposal failure there is contained and logged rather than raised.

Over stdio, mcp:serve wraps the shared server in Kinetis\Mcp\ScopedMessageHandler, which is where every lifecycle rule lives: each decoded message gets a scope from AppScope::createRequestScope() with every initializer run, and the scope is disposed — followed by gc_collect_cycles() — in a finally once the response has been computed and before the loop writes its frame. Disposal never throws: a failure is logged through AppScope’s logger, since the message’s own scope is already disposed, and the loop moves on to the next line. Progress notifications may already have been written by then; they belong to the tool that was still running. A parse error the codec answers on its own never reaches the handler, so it creates no scope.

On both transports, a disposal failure never suppresses a response that was written and never produces a second JSON-RPC message. A write failure — a closed or broken stdout, or, on the streamed HTTP transport, an output-buffer handler throwing during the flush — propagates as the primary failure, with the scope still disposed underneath it. The data being written was already encoded once inside the server, so a tool’s result cannot cause it. Nothing about disposal timing changes when progress notifications are written.

State a tool registers on its scope does not survive to the next message. Only a hand-rolled transport that drives the protocol server without ScopedMessageHandler shares the dispatcher’s own container across messages.

Stdio framing

Kinetis\McpProtocol\StdioLoop is the transport: one JSON-RPC message per line on stdin, one frame per response on stdout, synchronous and one message at a time, which is the backpressure. EOF ends the loop and returns normally — a client closing the pipe is how a stdio server is stopped.

Input is read in bounded chunks, never a whole-line fgets(), and a message payload is capped at 2 MiB, matching the framework’s default MAX_BODY_SIZE. A line past the cap is drained through its next terminator or to EOF, answered with exactly one -32700 under id: null, and the next frame is then processed normally. A full chunk arriving with no terminator is not oversized on that ground alone: only the accumulated payload decides, and a final line at EOF with no terminator is a complete message.

Only \r and \n are stripped before decoding — never a bare trim(), which would also remove NUL and vertical-tab bytes and turn invalid input into accepted input. A line holding only spaces or tabs is skipped as a blank.

Every frame — a progress notification or a response — is written whole: the loop repeats fwrite() until the encoded message and its newline are written, because fwrite() may accept fewer bytes than given when the reader of a pipe falls behind. A write that stops making progress (a false or a 0) throws Kinetis\McpProtocol\Exception\StdioWriteException with the bytes written and the total, and ends the loop, since nothing can safely follow a partial frame.

A failed progress write is never allowed to reach the tool, where it would be caught as an ordinary tool failure and that failure then written into the corrupted stream. The loop records it, skips every later notification for that message, and re-throws it once the handler returns — before any final response is attempted.

There is no output cap. A tool result has no universal safe size, so each consumer bounds its own.

Argument binding

A tool call’s arguments are one decoded JSON object, read under Kinetis\Validation\InputSource::Json — the vocabulary of a JSON HTTP body, and the one the tool’s inputSchema promises. A parameter typed int takes the JSON number 42, not the string "42"; see Routing & Validation’s “Scalar type checking”.

  • An argument the method requires and the call omitted is required at that argument’s path.

  • An explicit null for a parameter whose type refuses it is null_not_allowed, for a DTO-typed parameter as much as a scalar.

  • The arguments object is closed, as the schema’s additionalProperties: false says: a key naming no parameter is unexpected_field on its own path. A DTO-typed argument’s object is closed one level in, and so is every DTO nested inside it.

  • Every failure a call has — unknown, missing, wrong-typed, or refused by a rule — arrives together in one result.

A tool parameter declares a single named type. A union or intersection has no truthful inputSchema, so it is refused when the tool is registered — including the T|Absent presence union, which belongs to a DTO constructor field (see Routing & Validation’s “Required, optional, and absent fields”); a DTO-typed argument may use it on its own fields. Deriving the binding plan refuses the same declaration in the same words, so what a method may declare does not depend on the path that reached it.

The inputSchema is built from the method’s parameters and their constraint attributes, as for an HTTP request body. #[Regex] and #[NotBlank] have no JSON Schema keyword, so they check an argument at runtime only; see Routing & Validation’s “Validation constraints”.

Registration reads public methods. A tool name and a resource URI are each unique across every registered class: two methods claiming one — in the same class or in two packages — throw Kinetis\Mcp\Exception\DuplicateDefinitionException naming both Class::method() pairs, so the schema from tools/list is always the one tools/call invokes. Registering the same class twice is a no-op. Discovery scans the application’s PSR-4 roots and every installed package’s extra.kinetis scan roots; MCP_DISCOVERY_PATHS restricts the application scan (see CLI’s “Restricting discovery”), and a production build compiles the registry into the cache (see Caching & AOT Compilation).

See also