Support the stateless MCP protocol revision 2026-07-28 (server/discover, per-request _meta)

Why

Follow-up from #611372 (closed) and !253324 (merged).

MCP spec revision 2026-07-28 replaces the stateful protocol with a stateless one:

  • Drops the initialize handshake, Mcp-Session-Id, the GET stream, and SSE resumability.
  • Every request carries _meta with a protocol version and client capabilities.
  • Servers expose a new server/discover method.
  • Results carry a resultType.

Our server is already stateless, so the gap is small. !253324 (merged) made us stop claiming 2026-07-28, so an initialize asking for it gets 2025-11-25 back. That is correct, but it leaves us speaking only the legacy protocol. This issue adds the stateless revision alongside the legacy one, on the same endpoints.

Extensions are negotiated the same way, through clientCapabilities.extensions in _meta and capabilities.extensions in server/discover. So the tasks, elicitation, skills and apps work is blocked on this issue.

Where we are today

What we have

What we do not have

  • server/discover. A request for it returns HTTP 404 with -32601. The spec makes this method mandatory.
  • Any reading of _meta from requests.
  • Validation of the MCP-Protocol-Version, Mcp-Method, and Mcp-Name headers.
  • resultType, io.modelcontextprotocol/serverInfo, ttlMs, or cacheScope in results. Note the full _meta key name, it is not a bare serverInfo.
  • -32020, -32021, and -32022, the three error codes this revision defines.

What the earlier version of this issue got wrong

  • It said we have -32001 for version mismatch and that it "stays correct for legacy requests". Neither part holds. The entry sits in lib/api/mcp/base.rb and nothing references it, in lib/, ee/lib/, spec/, or ee/spec/. An unsupported version raises ArgumentError, which the endpoint turns into -32602. The spec also now treats -32000 to -32019 as legacy and tells new implementations not to use it. So -32001 is dead code to delete, not behaviour to keep.
  • It listed only -32020 and -32022. The revision defines three codes. -32021 MissingRequiredClientCapability was missing.
  • It wrote _meta.serverInfo. The real key is io.modelcontextprotocol/serverInfo.
  • It said header validation returns -32020 "on a mismatch". A missing required header is also a -32020. It also left out the =?base64?VALUE?= sentinel on Mcp-Name, which the server must decode before it compares the value to the body. That comparison is the whole point of the header rules, so skipping the decode would let a caller put one name in the header and another in the body.
  • It listed the shared helper and the docs update as new work. !253329 (merged) and !253836 (merged) already start both.

Proposed approach

Phase 1: dual-era server (this issue)

  1. Extend lib/api/helpers/mcp/json_rpc.rb to hold the JSON-RPC version constant, the shared error table, method_not_found!, and format_jsonrpc_response. Both endpoints include it. Drop the dead -32001. Keep -32603 in the EE endpoint, which is the only one that uses it. Keep -32700 out, since Workhorse answers 400 before we see a parse error. No behaviour changes.
  2. Read params[:_meta]. A request is modern when io.modelcontextprotocol/protocolVersion is present, otherwise it stays legacy and nothing changes for it. For a modern request, require io.modelcontextprotocol/protocolVersion and io.modelcontextprotocol/clientCapabilities, and answer -32602 with HTTP 400 when either is missing. Answer -32022 with HTTP 400 and a data holding supported and requested for a version we do not speak. Add -32021 with data.requiredCapabilities for a capability the client did not declare, even though no tool needs one today. Log io.modelcontextprotocol/clientInfo when it arrives.
  3. Validate the headers on modern requests. MCP-Protocol-Version must equal the _meta version, Mcp-Method must equal the body method, and Mcp-Name must equal params.name on tools/call. A missing required header and a mismatched one both answer -32020 with HTTP 400. Decode the =?base64?VALUE?= sentinel on Mcp-Name first. Compare header names case insensitively and values case sensitively. Ignore Mcp-Session-Id and Last-Event-ID.
  4. Add server/discover, returning resultType, supportedVersions, capabilities, _meta["io.modelcontextprotocol/serverInfo"], ttlMs, and cacheScope.
  5. Add resultType: "complete" and io.modelcontextprotocol/serverInfo to every modern result. Add ttlMs and cacheScope: "private" to tools/list. ttlMs must be >= 0. Legacy results gain none of these fields.
  6. Document the stateless revision on top of !253836 (merged): the _meta keys, the headers, the new error codes, server/discover, the caching hints, and the fact that a legacy client keeps working unchanged on the same endpoint.

Every item covers both endpoints and needs specs, plus a request spec showing a legacy client and a modern client hitting the same endpoint.

Phase 2: later

  • MRTR (input_required) for tools that need user confirmation. Now tracked in #585072.
  • subscriptions/listen (not needed while listChanged is false).
  • Deterministic tool ordering for prompt caching.

Open questions

  • Which protocol version do the Duo Agent Platform executor and Duo Chat clients send today, and will they move to langchain.mcp or the FastMCP client? This decides how urgent phase 1 is.
  • How does a dual-era client react to our current 404 -32601 for server/discover? The spec lists -32601 as a recognized modern error, so a client could read us as a modern server with no discovery support. We should test this with the LangChain client before and after the change.
  • The transport revision also says a server must validate the Origin header and answer 403 when it is present and invalid. We should check whether Workhorse or Rails already covers this for API endpoints before we add anything.
  • This revision says a request id must not be null and a notification must not carry one. Our Grape params mark id optional on both endpoints. Worth deciding whether to tighten that for modern requests.
  • No urgency signal yet: @jessieay tested on !253324 (merged) that Cursor 3.15 and 3.19 still ask for 2025-11-25. No popular client needs this today. This is about being ready, not fixing a break. Answered in the Why section: the tasks, elicitation, skills and apps extensions are blocked on this issue.

References

Edited by 🤖 GitLab Bot 🤖