Transports¶
Mocapi ships two transports today: Streamable HTTP for web-accessible
deployments and stdio for subprocess-launched MCP clients. Both
implement the same McpServer + McpTransport contract (see
ADR-0002) — handler code
is identical between them. Under MCP 2026-07-28 both are stateless
(ADR-0019,
ADR-0020): every request is
self-contained, there is no handshake, no session, and no
server-initiated request channel.
For decisions specific to transport behavior, see also:
- ADR-0003 — two peer transports
- ADR-0004 —
MessageWriterstate machine - ADR-0006 — virtual-thread-per-call
- ADR-0020 — sessions, resumability, Substrate, and Odyssey removed
- ADR-0022 — cancellation stance,
x-mcp-headerskipped
Picking a transport¶
| Transport | When to use | Module |
|---|---|---|
| Streamable HTTP | Web-accessible servers, long-running deployments, multiple concurrent clients, multi-node deployments (no shared state required) | mocapi-streamable-http-transport (or mocapi-streamable-http-spring-boot-starter) |
| Stdio | Desktop MCP clients that spawn the server as a subprocess (Claude Desktop, Cursor, MCP Inspector), one client per process, no network exposure | mocapi-stdio-transport (or mocapi-stdio-spring-boot-starter) |
Streamable HTTP¶
POST-only endpoint¶
The MCP endpoint accepts only POST. GET and DELETE return
405 Method Not Allowed (Allow: POST): the standalone GET SSE
stream and the DELETE session-termination request are gone with
sessions. An incoming Mcp-Session-Id header is ignored — never
minted, never echoed — and Last-Event-ID is ignored because the
2026-07-28 transport spec removed SSE resumability. The encrypted
event-ID machinery and the Odyssey-backed named/resumable streams
(DefaultSseStream/DefaultSseStreamFactory) were deleted with it;
ADR-0005 is historical.
Routing-header validation (-32020 HeaderMismatch)¶
The 2026-07-28 transport spec requires routing headers on every POST so
intermediaries can route without parsing the body. McpHeaderValidator
validates them against the body before dispatch:
MCP-Protocol-Version— required on every request and notification; must equal the body's_metaio.modelcontextprotocol/protocolVersionwhen present.Mcp-Method— required on every request and notification; must equal the body'smethod.Mcp-Name— required ontools/call/prompts/get(must equalparams.name) andresources/read(must equalparams.uri); not expected on any other method.
Any missing/mismatched/malformed header → HTTP 400 + JSON-RPC error
-32020 (HeaderMismatch). The constant lives in the transport module;
mocapi sources it from the transport spec (the value now also appears in
schema.ts as HEADER_MISMATCH). Body envelope semantics (-32602
invalid params, -32022 unsupported protocol version) remain the server
core's job; a request that fails both header and envelope validation
fails with -32020 (transport first). Unrecognized Mcp-Param-* headers
are ignored — mocapi
designates no custom parameter headers
(ADR-0022).
The Mcp-Name table above is the built-in set; it is not the whole
story. mocapi-server's RoutedParamContributor seam
(ADR-0038; renamed for
taxonomy consistency by
ADR-0039 —
see its amendment note on ADR-0038 for the prior name) lets an extension
module add its own method → expected-name-field entries without the
transport module depending on that extension.
mocapi-tasks contributes tasks/get, tasks/update, and
tasks/cancel, each validated against params.taskId. The transport
keeps owning wire validation
(I2) without
hardcoding extension knowledge — contributed entries are merged into the
same validation table and enforced identically to the built-in ones. A
method key colliding with another contributor's, or with a built-in,
fails the boot naming both parties (ADR-0039).
HTTP status from JSON-RPC error codes¶
Direct JSON replies map their HTTP status from the JSON-RPC error code
in a single table (HttpStatusMapping):
| JSON-RPC code | HTTP status |
|---|---|
-32700, -32600, -32602, -32020, -32021, -32022 |
400 Bad Request |
-32601 (method not found) |
404 Not Found — distinguishes a modern server from a legacy HTTP+SSE endpoint |
| anything else (internal/application errors) | 200 OK — the error is a well-formed JSON-RPC response |
Once the response has committed as SSE the status is already 200 and
errors travel on the stream.
Lazy JSON-vs-SSE response shape¶
Every JsonRpcCall POST runs on a virtual thread through
StreamableHttpTransport, which chooses JSON vs SSE based on the
first outbound message (ADR-0004, unchanged):
- Controller validates Accept, Origin, and routing headers, creates a
StreamableHttpTransportbacked by a per-requestPerRequestSseStreamsupplier, and spawns a virtual thread to runserver.handleCall(). - The transport holds a
MessageWriterstate machine starting inDirectMessageWriter. - First
send()decides the response shape: JsonRpcResponse→ commit asapplication/jsonwith the status from the mapping table, transition toClosedMessageWriter.JsonRpcRequest(a request-scoped notification such asnotifications/progress) → committext/event-streamwithX-Accel-Buffering: no, transition toSseMessageWriter.- Subsequent
send()calls onSseMessageWriterpublish to the stream; the finalJsonRpcResponseis written and then terminates the stream. ClosedMessageWriterrejects any further writes.
Simple tools that only return a response get JSON — no unnecessary SSE
upgrade. Tools that emit progress notifications upgrade lazily. The
stream is a plain Spring SseEmitter scoped to the single POST: there
are no named streams, no journaling, and no replay.
Client disconnect of the response stream is cancellation: once the emitter completes or errors, further writes become silent no-ops (the server MUST NOT send more messages for that request). The in-flight handler is not interrupted — its late output is simply dropped (ADR-0022).
The stream is closed on every terminating path, never leaked: a
serialization failure for one message is logged and dropped without
tearing down the stream; writing the terminal response always closes it,
even if that write throws; and a handler exception thrown after the SSE
response has committed is caught by the controller and routed to
StreamableHttpTransport.abort(...), which closes the committed stream
(before commit, abort fails the response future for a plain JSON
error). As a final backstop against a handler that hangs without ever
sending its terminal response, the emitter carries a configurable async
timeout (mocapi.stream-timeout, default 5 minutes) — there is no
resumability to recover, so an unbounded stream would otherwise hold the
connection forever.
Notifications POSTed by the client are dispatched and acknowledged with
202 Accepted and no body. A POSTed JSON-RPC response is rejected
with -32600: 2026-07-28 has no server-initiated requests, so clients
have nothing to respond to.
Origin validation¶
McpRequestValidator checks the Origin header against the
allowed-origins list (DNS-rebinding protection); invalid origins get
403 Forbidden. Requests must accept both application/json and
text/event-stream or they are rejected with 406 Not Acceptable.
Thread-local context propagation¶
Virtual threads created via Thread.ofVirtual().start(...) do not
inherit ThreadLocal values from their parent, so request-thread
context (Spring Security's SecurityContextHolder, Micrometer
observation scope, SLF4J MDC, etc.) would otherwise vanish at the
handler spawn boundary. The controller captures an
io.micrometer.context.ContextSnapshot on the request thread and
wraps the handler Runnable via snapshot.wrap(...) before spawning
the VT. Every ThreadLocalAccessor registered via the
context-propagation SPI is restored on the handler VT at run()
entry and cleared on exit — Spring Security 6+ and Micrometer
Observation ship accessors out of the box, so authentication and
tracing parent linkage cross the spawn automatically. The
ContextSnapshotFactory bean is exposed by
StreamableHttpAutoConfiguration under @ConditionalOnMissingBean;
register custom ThreadLocalAccessors via ContextRegistry to
propagate additional context objects.
Stdio¶
The MCP client launches the server as a subprocess and communicates via newline-delimited JSON on stdin/stdout; stderr carries logs. When the client closes stdin the server exits.
StdioServerreads lines from stdin in a single blocking loop. Every line is an independent request or notification — there is no handshake gating, andserver/discover(the back-compat probe) is answerable at any time, including as the first message.- Each line is dispatched on its own virtual thread via a try-with-resources
ExecutorService, so a slow handler can't stall the reader. StdioTransport.send(message)serializes to a single JSON line on stdout.PrintStreamis internally synchronized, so concurrent dispatch threads can't interleave partial lines.- Envelope semantics live in the server core: a request without the
_metaenvelope gets the server's-32602, relayed verbatim to stdout. Client JSON-RPC responses are dropped with a warning — no server-initiated requests exist to correlate them to. - On EOF, the try-with-resources block closes the executor (shutdown + awaitTermination) so in-flight handlers finish before the JVM exits.
Stdout is reserved for MCP protocol traffic — all logging must go to
stderr. The stdio example ships a logback-spring.xml that wires
the root logger to System.err and sets
spring.main.banner-mode=off so nothing else touches stdout. A stray
System.out.println anywhere in your code (or a logger pointed at
stdout) will corrupt the JSON stream.