Skip to content

streamable_http transport does not perform MCP protocol version negotiation #916

Description

@DaleSeo

Summary

The streamable_http server transport returns the ServerHandler's InitializeResult.protocol_version to the client verbatim, without the version-negotiation downgrade that the stdio (serve_server) path performs. As a result, a server whose initialize response advertises a protocol version newer than the client supports will have its connection refused over streamable_http, even though the same handler negotiates correctly over stdio.

This appears to violate the MCP lifecycle spec, which requires negotiation regardless of transport:

If the server supports the requested protocol version, it MUST respond with the same version. Otherwise, the server MUST respond with another protocol version it supports. This SHOULD be the latest version supported by the server.

Confirmed on rmcp 1.6.0 and 1.7.0.

Expected behavior

When the client sends initialize with protocolVersion: X and the handler's response advertises version Y where Y > X and X is a known/supported version, the server should respond with X (or otherwise the highest mutually supported version), the same way stdio does. The transport should not return a higher version than the client requested verbatim.

Actual behavior

Over streamable_http, the client receives Y (the handler's advertised version) unchanged. A client that does not support Y disconnects, e.g. Server's protocol version is not supported: 2025-11-25.

Root cause

The stdio path negotiates in crates/rmcp/src/service/server.rs (around lines 230-238): it compares the client's requested version against the handler's response and downgrades to the client's version when the response is higher.

let peer_protocol_version = peer_info.params.protocol_version.clone();
let protocol_version = match peer_protocol_version
    .partial_cmp(&init_response.protocol_version)
    .ok_or(ServerInitializeError::UnsupportedProtocolVersion(peer_protocol_version))?
{
    std::cmp::Ordering::Less => peer_info.params.protocol_version.clone(),
    _ => init_response.protocol_version,
};
init_response.protocol_version = protocol_version;

The streamable_http session worker has no equivalent step. In crates/rmcp/src/transport/streamable_http_server/session/local.rs, the worker's run() forwards the handler's initialize response straight back to the client:

context.send_to_handler(request).await?;
let send_initialize_response = context.recv_from_handler().await?;
responder.send(Ok(send_initialize_response.message)) // ...

A search of crates/rmcp/src/transport/streamable_http_server/ for partial_cmp, Ordering, or any negotiation logic returns nothing. Note also that ProtocolVersion::default() resolves to LATEST (2025-11-25), so any handler that uses the default (directly or via ServerHandler::get_info) and serves over streamable_http will hand the latest version to every client regardless of what the client requested.

Reproduction

  1. Implement a ServerHandler whose get_info() returns InitializeResult with protocol_version = ProtocolVersion::default() (= 2025-11-25).
  2. Serve it over streamable_http.
  3. Send an initialize request with "protocolVersion": "2025-06-18".
  4. Observe the response protocolVersion is 2025-11-25, not 2025-06-18. The same handler over stdio responds with 2025-06-18.

Suggested fix

Apply the same negotiation step used by serve_server in the streamable_http initialize path (in the session worker, after receiving the handler's initialize response and before sending it to the client). Sharing a single negotiation helper between the stdio and HTTP paths would keep the two transports consistent and spec-compliant.

Workaround

Servers can negotiate in their own initialize handler by reading request.protocol_version and setting the response's protocol_version to the client's requested version when supported, otherwise the server's latest. This composes correctly with the stdio path (the subsequent min comparison is a no-op) and is forward-compatible with a fix here.

Metadata

Metadata

Assignees

Labels

bugSomething is not working

Type

No type
No fields configured for issues without a type.

Projects

No projects

Milestone

No milestone

Relationships

None yet

Development

No branches or pull requests

Issue actions