Skip to content

conflict initialized response id when gateway echoes numeric JSON-RPC id as string #1017

Description

@MaiM1ng

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

Metadata

Metadata

Assignees

No one assigned

    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