ADR: Stateless MCP (spec 2026-07-28) and unpinning stdio MCP from one replica¶
Status: proposed (2026-10-01); the transport-and-call-hook slice of section 4 is in the code behind app.mcp.registry-route-tool-calls (default false, checked 2026-10-21, v0.1.9); section 5 is not done.
1. Verified facts (primary sources, checked 2026-10-01)¶
- The MCP spec 2026-07-28 is final (RC locked 2026-05-21, released 2026-07-28; blog.modelcontextprotocol.io/posts/2026-07-28/).
Headline change: the
initialize/initializedhandshake and theMcp-Session-Idheader are removed. Every request carries_meta.io.modelcontextprotocol/{protocolVersion,clientInfo,clientCapabilities}; an optionalserver/discoverRPC replaces upfront capability discovery; state must be modelled as tool-returned handles. Extensions: MCP Apps, Tasks. - Tier 1 SDKs at release: TypeScript, Python, Go, C#; Rust beta. The Java SDK is not listed, and no Java release note was found.
- Installed SDK does not support it. Spring AI 1.1.7 (
spring-ai-starter-mcp-client) resolvesio.modelcontextprotocol.sdk:mcp-core0.18.x.ProtocolVersionsin mcp-core 0.18.3 and in the 2.0.0 jar in the local Maven cache both top out at2025-11-25(read from the jars). TheMcpStatelessAsyncServerclasses in 2.0.0 are the older stateless Streamable HTTP server mode of the 2025 protocol, not the 2026-07-28 wire format. Moving to 2.0.0 also needs Spring AI 2.x / Boot 4, so it is not a drop-in. - Rule honoured: no hand-rolled transport.
2. Premise check: what pins stdio MCP to one replica¶
Partly already built (flagged, default off, unit-tested only): app.mcp.registry-store=redis (RedisMcpOwnershipRegistry leases,
McpCommandBus, RedisMcpOwnershipGate, McpOrphanAdopter). In default replicated mode every replica spawns its own subprocess, so chat
is not pinned (cost: N subprocesses, per-JVM stdio caps multiply by N). With registry-exclusive-clients=true only the owner spawns, but
non-owners then have no client and no tools, so MCP traffic would have to be pinned. The missing piece is a call path from a non-owner
to the owner.
Stdio servers are subprocesses and therefore per-host whatever the HTTP protocol does; stateless MCP helps remote servers (no sticky sessions) once the SDK supports it, and does nothing for stdio.
3. Options¶
| Option | Trade-off |
|---|---|
| A. Wait for Java SDK 2026-07-28 support, then upgrade Spring AI | No custom code; blocked upstream and on Boot 4; remote servers only. |
| B. Hand-roll the 2026-07-28 client transport | Rejected: security-sensitive, diverges from the SDK, breaks the rule. |
| C. Route tool calls to the stdio owner over the existing Redis bus | Works today for stdio, protocol-independent; adds a Redis hop and an at-most-once story. |
| D. Dedicated single-replica stdio worker service | Clean isolation, fits L6 sandbox; bigger infra change. |
Decision: C as the first slice, A when the SDK lands, revisit D with L6.
4. Slice shipped (behind app.mcp.registry-route-tool-calls, default false)¶
McpCommand.Op.CALLwith optionalpayload(JSON{tool,args}); the 4-arg constructor is kept; legacy queue entries still parse.McpOwnershipGate.routeToolCall(...)(default: empty = not routed) andattachToolCaller(...).RedisMcpOwnershipGateroutes only when the flag is on, this JVM has no live client, and a different replica owns the registration.- Owner side:
DynamicMcpConnectionManager.callLiveTooluses the already sanitized and namespaced callbacks, so the output-sanitizer defence is unchanged. CALL runs on the test pool (now 4 threads, shared with TEST) so a slow tool never stalls connect/disconnect lanes; a tool running up to 60 s can still occupy a thread, so a dedicated CALL pool is a stage-1 follow-up if the flag is ever enabled in production. - At-most-once: owner silent or erroring makes the gate throw
IllegalStateException; callers must not retry locally (the tool may have run). Redis lookup failures fail open to local behaviour like the rest of the gate. - Sender side:
RoutedMcpToolCallbackis the outermost wrapper of every dynamic registration's callbacks (connect and tools/list_changed refresh). It asks the gate first and falls through to the local call when the gate answers empty, so with the flag off it is a pass-through. - Keys:
app.mcp.registry-route-tool-calls,app.mcp.registry-tool-call-timeout(PT60S), declared underapp.mcp.*inapplication.yml(envAPP_MCP_REGISTRY_ROUTE_TOOL_CALLS,APP_MCP_REGISTRY_TOOL_CALL_TIMEOUT).
5. Staged plan (not done)¶
- Non-owner discovery: owner publishes sanitized tool definitions to a TTL'd Redis key; non-owner builds proxy
ToolCallbacks that callrouteToolCall, wired intoresolveDynamicToolCallbacksbehind the same flag. Until then the sender hook exists but a non-owner has no callback to invoke, so enabling the flag does not yet make a non-owner replica expose the tools: this slice is the transport and call hook, not full stateless MCP. A callback only reachesrouteToolCallwhen it outlives its client on a replica that is not the owner. - Real-Redis two-JVM test (owner killed mid-call, lease takeover).
- Cluster-wide per-tenant stdio caps and
inFlightStdioTests(currently per JVM). - Attribution: owner runs the call as
system:mcp-command; carry the caller subject/run id in the payload for audit and budget. - When the Java SDK supports 2026-07-28: upgrade, drop session-affinity assumptions for remote servers, adopt
server/discover.
6. Risks¶
- Tool arguments and results transit Redis (reply TTL 2 min, queue TTL 5 min). Require Redis auth/TLS before enabling.
- Added latency up to
registry-poll-ms(250 ms) plus the 100 ms reply poll; unsuitable for streaming output. - Tenancy: the owner re-applies the tenant carried in the command and the registry key is trusted; the payload does not carry the caller's
subject, so owner-side audit shows
system:mcp-command(stage 4). - Verification state: not compiled by this agent (lead builds serially), never run against a real Redis.