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
- Implement a
ServerHandler whose get_info() returns InitializeResult with protocol_version = ProtocolVersion::default() (= 2025-11-25).
- Serve it over
streamable_http.
- Send an
initialize request with "protocolVersion": "2025-06-18".
- 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.
Summary
The
streamable_httpserver transport returns theServerHandler'sInitializeResult.protocol_versionto the client verbatim, without the version-negotiation downgrade that the stdio (serve_server) path performs. As a result, a server whoseinitializeresponse advertises a protocol version newer than the client supports will have its connection refused overstreamable_http, even though the same handler negotiates correctly over stdio.This appears to violate the MCP lifecycle spec, which requires negotiation regardless of transport:
Confirmed on rmcp
1.6.0and1.7.0.Expected behavior
When the client sends
initializewithprotocolVersion: Xand the handler's response advertises versionYwhereY > XandXis a known/supported version, the server should respond withX(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 receivesY(the handler's advertised version) unchanged. A client that does not supportYdisconnects, 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.The
streamable_httpsession worker has no equivalent step. Incrates/rmcp/src/transport/streamable_http_server/session/local.rs, the worker'srun()forwards the handler's initialize response straight back to the client:A search of
crates/rmcp/src/transport/streamable_http_server/forpartial_cmp,Ordering, or any negotiation logic returns nothing. Note also thatProtocolVersion::default()resolves toLATEST(2025-11-25), so any handler that uses the default (directly or viaServerHandler::get_info) and serves overstreamable_httpwill hand the latest version to every client regardless of what the client requested.Reproduction
ServerHandlerwhoseget_info()returnsInitializeResultwithprotocol_version = ProtocolVersion::default()(=2025-11-25).streamable_http.initializerequest with"protocolVersion": "2025-06-18".protocolVersionis2025-11-25, not2025-06-18. The same handler over stdio responds with2025-06-18.Suggested fix
Apply the same negotiation step used by
serve_serverin thestreamable_httpinitialize 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
initializehandler by readingrequest.protocol_versionand setting the response'sprotocol_versionto the client's requested version when supported, otherwise the server's latest. This composes correctly with the stdio path (the subsequentmincomparison is a no-op) and is forward-compatible with a fix here.