hi
Describe the bug
When an MCP client connects to a streamable-HTTP server through a gateway/proxy that performs session bridging, the gateway may echo back the JSON-RPC id as a JSON string even though the client sent it as a JSON number (e.g. client sends "id":0, gateway returns "id":"0"). rmcp's strict PartialEq comparison on NumberOrString then treats Number(0) and String("0") as different, causing the initialize handshake to fail with:
conflict initialized response id: expected 0, got 0
To Reproduce
A minimal client (StreamableHttpClientTransport + ServiceExt::serve) against a streamable-HTTP server behind a session-bridging gateway:
- Client sends
initialize with a numeric id (AtomicU32RequestIdProvider always generates Number): "id":0
- Gateway echoes the id back as a string:
"id":"0"
- rmcp fails at
crates/rmcp/src/service/client.rs:402:
if id != response_id { // Number(0) != String("0") => true
return Err(ClientInitializeError::ConflictInitResponseId(id, response_id));
}
Error: conflict initialized response id: expected 0, got 0
Expected behavior
rmcp should tolerate a gateway that echoes numeric ids as numeric strings, matching the behavior of the TypeScript and Python official SDKs (see below). JSON-RPC 2.0 specifies the id "SHOULD" be the same value, but in practice many intermediaries coerce the JSON type (number -> string) when routing through session-based bridges. A client-side normalization (numeric-string -> number) maximizes interoperability without violating the spec.
Root cause
Three places are affected, all due to NumberOrString deriving PartialEq/Eq/Hash so that Number(n) and String("n") never compare equal:
- ID generation --
crates/rmcp/src/service.rs:301: RequestId::Number(id as i64) (the client always sends a number)
- Handshake comparison --
crates/rmcp/src/service/client.rs:402 (initialize) and :468 (discover): if id != response_id uses derived PartialEq; a stringified response id fails the check before the session is ever established.
- Steady-state correlation --
crates/rmcp/src/service.rs:1319/:1333: local_responder_pool.remove(&id) is a HashMap<RequestId, ...> keyed by derived Hash + Eq. A stringified response id hashes/compares differently from the numeric key the request was stored under, so the response is silently dropped and the pending RequestHandle::await_response times out.
NumberOrString is defined at crates/rmcp/src/model.rs:227 with #[derive(Debug, Clone, Eq, PartialEq, Hash)].
Precedent in other official SDKs
Both the TypeScript SDK and the Python SDK handle this with client-side id normalization.
The Python SDK (mcp/shared/session.py, _normalize_request_id) explicitly cites the TypeScript SDK as the reference approach:
def _normalize_request_id(self, response_id: RequestId) -> RequestId:
"""
Normalize a response ID to match how request IDs are stored.
Since the client always sends integer IDs, we normalize string IDs
to integers when possible. This matches the TypeScript SDK approach:
https://github.com/modelcontextprotocol/typescript-sdk/blob/a606fb17909ea454e83aab14c73f14ea45c04448/src/shared/protocol.ts#L861
"""
if isinstance(response_id, str):
try:
return int(response_id)
except ValueError:
logging.warning(f"Response ID {response_id!r} cannot be normalized to match pending requests")
return response_id
It is applied before response correlation:
response_id = self._normalize_request_id(root.id)
stream = self._response_streams.pop(response_id, None)
rmcp currently has no equivalent normalization
hi
Describe the bug
When an MCP client connects to a streamable-HTTP server through a gateway/proxy that performs session bridging, the gateway may echo back the JSON-RPC
idas a JSON string even though the client sent it as a JSON number (e.g. client sends"id":0, gateway returns"id":"0"). rmcp's strictPartialEqcomparison onNumberOrStringthen treatsNumber(0)andString("0")as different, causing theinitializehandshake to fail with:To Reproduce
A minimal client (
StreamableHttpClientTransport+ServiceExt::serve) against a streamable-HTTP server behind a session-bridging gateway:initializewith a numeric id (AtomicU32RequestIdProvideralways generatesNumber):"id":0"id":"0"crates/rmcp/src/service/client.rs:402:Error:
conflict initialized response id: expected 0, got 0Expected behavior
rmcp should tolerate a gateway that echoes numeric ids as numeric strings, matching the behavior of the TypeScript and Python official SDKs (see below). JSON-RPC 2.0 specifies the
id"SHOULD" be the same value, but in practice many intermediaries coerce the JSON type (number -> string) when routing through session-based bridges. A client-side normalization (numeric-string -> number) maximizes interoperability without violating the spec.Root cause
Three places are affected, all due to
NumberOrStringderivingPartialEq/Eq/Hashso thatNumber(n)andString("n")never compare equal:crates/rmcp/src/service.rs:301:RequestId::Number(id as i64)(the client always sends a number)crates/rmcp/src/service/client.rs:402(initialize) and:468(discover):if id != response_iduses derivedPartialEq; a stringified response id fails the check before the session is ever established.crates/rmcp/src/service.rs:1319/:1333:local_responder_pool.remove(&id)is aHashMap<RequestId, ...>keyed by derivedHash + Eq. A stringified response id hashes/compares differently from the numeric key the request was stored under, so the response is silently dropped and the pendingRequestHandle::await_responsetimes out.NumberOrStringis defined atcrates/rmcp/src/model.rs:227with#[derive(Debug, Clone, Eq, PartialEq, Hash)].Precedent in other official SDKs
Both the TypeScript SDK and the Python SDK handle this with client-side id normalization.
The Python SDK (
mcp/shared/session.py,_normalize_request_id) explicitly cites the TypeScript SDK as the reference approach:It is applied before response correlation:
rmcp currently has no equivalent normalization