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

    Fields

    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