Skip to content

Stateless streamable HTTP skips protocol version negotiation when the handler overrides initialize #1079

Description

@DaleSeo

Summary

Follow-up to #916 / #930 (and related to #841). Those changes added negotiate_protocol_version to two call sites: the default ServerHandler::initialize and serve_server_with_ct_inner. The stateless streamable HTTP path is covered only by the first one, so a handler that overrides initialize gets no negotiation at all on that transport.

This is easy to hit, because overriding initialize is the normal way to record metrics, capture client info, or track peers. Such a handler negotiates correctly over stdio and stateful HTTP (the transport negotiates the response on its behalf) but returns its advertised version verbatim over stateless HTTP.

Present in 2.1.0 and still present in 3.0.0.

Why the paths differ

Both stdio and stateful streamable HTTP end up in serve_server, which post-processes whatever the handler returned:

// service/server.rs, serve_server_with_ct_inner
let init_response = service.handle_request(request.clone(), context).await;
// ...
init_response.protocol_version = negotiate_protocol_version(
    &requested_protocol_version,
    init_response.protocol_version,
);

Stateful HTTP reaches that through the serve_server call in the session task in streamable_http_server/tower.rs.

The stateless branch of that same file instead calls serve_directly_with_ct(service, transport, peer_info, ...), which by design skips the handshake. The handler's response is serialized and returned to the client with no equivalent negotiation step. The only protocol version work on that path is peer_info_for_stateless_request (so context.protocol_version() is populated) plus header validation against the request body. Neither one touches InitializeResult::protocol_version.

So on the stateless path, negotiation lives exclusively in the default trait method body, which an overriding handler replaces.

Expected behavior

A server whose handler overrides initialize responds with the client's requested version when that version is in KNOWN_VERSIONS, on every transport and in both stateful and stateless mode.

Actual behavior

On stateless streamable HTTP the client receives the handler's advertised version. With ProtocolVersion::default() (or LATEST) that means 2025-11-25 goes out to a client that asked for 2025-06-18, and clients pinned to the older version fail to initialize. The same handler over stdio or stateful HTTP correctly answers 2025-06-18.

Reproduction

  1. Implement a ServerHandler that overrides initialize and returns self.get_info(), where get_info advertises ProtocolVersion::LATEST.
  2. Serve it with StreamableHttpService configured .with_stateful_mode(false).
  3. Send initialize with "protocolVersion": "2025-06-18".
  4. The response is 2025-11-25. Switching to .with_stateful_mode(true) returns 2025-06-18 from the same handler.

tests/test_stateless_protocol_version.rs does not catch this, because its Calculator handler overrides only get_info and so inherits the negotiating default.

Suggested fix

Negotiate on the stateless path as well, next to where peer_info is already reconstructed, mirroring what serve_server_with_ct_inner does (including updating the reconstructed peer_info so context.protocol_version() agrees with what was sent to the client). That makes negotiation a transport-level guarantee instead of something every handler has to remember, which is also what #916 asked for ("sharing a single negotiation helper between the stdio and HTTP paths").

A test handler that overrides initialize would keep it from regressing.

This should not be a breaking change

Given that 3.0.0 just shipped, worth spelling out:

  • No public API changes. The fix is confined to the stateless branch of the tower service.
  • It is idempotent, so it cannot break handlers that already work around this. The branch predicate in negotiate_protocol_version depends only on client_requested, so negotiate(r, negotiate(r, x)) == negotiate(r, x). Handlers negotiating in their own initialize keep working unchanged, and in the unknown-version case the transport passes the handler's chosen fallback through rather than overriding it.
  • Precedent. fix: negotiate protocol version in handler #930 changed the observable behavior of the default ServerHandler::initialize (in 2.0.0 it was just set_peer_info then Ok(self.get_info()), with no negotiation) along with stateless peer_info reconstruction. That shipped in the 2.1.0 minor and was marked as introducing no breaking changes. This is the same class of change with a smaller blast radius.
  • The only servers that could observe a difference are stateless servers that intentionally answer with a version other than the client's requested known version. That already contradicts the lifecycle spec ("If the server supports the requested protocol version, it MUST respond with the same version") and already behaves differently on stdio and stateful HTTP, so it cannot be something people depend on portably.

If you would rather not touch transport behavior, the alternative is to expose the negotiation policy so downstreams can apply it themselves, for example by making the existing negotiate_protocol_version free function pub. Right now it is pub(crate), so a handler that overrides initialize has no way to reuse it and has to reimplement the KNOWN_VERSIONS check and keep it in sync by hand. (An inherent ProtocolVersion::negotiate method would work too, though it would shadow any downstream extension trait of the same name, and downstreams working around this gap are the most likely authors of one.)

Workaround

Apply the negotiation logic in the overriding handler:

async fn initialize(&self, request: InitializeRequestParams, context: RequestContext<RoleServer>)
    -> Result<InitializeResult, McpError>
{
    // ... own bookkeeping ...
    let mut info = self.get_info();
    if ProtocolVersion::KNOWN_VERSIONS.contains(&request.protocol_version) {
        info.protocol_version = request.protocol_version.clone();
    }
    Ok(info)
}

This composes with the handshake paths, where the later negotiate_protocol_version call becomes a no-op. We shipped this in apollographql/apollo-mcp-server#802 to unblock a client pinned to 2025-06-18, and would be glad to drop it in favor of a fix here.

I am happy to send a PR for the stateless fix if the approach sounds right.

Metadata

Metadata

Assignees

Labels

bugSomething is not working

Type

No type

Projects

No projects

Milestone

No milestone

Relationships

None yet

Development

No branches or pull requests

Issue actions