Skip to content

StatelessHTTPServerTransport: concurrent requests sharing a JSON-RPC id overwrite each other's response waiter (hang + continuation leak) #254

Description

@ianegordon

Summary

In StatelessHTTPServerTransport (0.12.1), response waiters are correlated by the raw JSON-RPC id:

// Sources/MCP/Base/Transports/HTTPServer/StatelessHTTPServerTransport.swift:53
private var responseWaiters: [String: CheckedContinuation<Data, any Error>] = [:]

// …:232 (handleJSONRPCRequest)
responseWaiters[requestID] = continuation

JSON-RPC ids are client-scoped, and this transport is explicitly multi-client ("can handle multiple client connections"), so id collisions across concurrent POSTs are legal and common — most clients start their id sequence at 1. The dictionary assignment at line 232 silently overwrites any waiter already registered for that id.

Two consequences:

  1. Hang — the overwritten request's continuation is never resumed. Nothing else resumes a waiter except a matching response in send(_:) or full transport terminate(), and no waiter timeout exists (the "No waiter for response, may have timed out" log message suggests one, but none is implemented). The first POST hangs until connection teardown.
  2. Leaked CheckedContinuation — the abandoned continuation trips Swift's continuation-misuse diagnostics. In our test harness, 8 concurrent tools/list POSTs sharing "id": 7 against a single transport crashed the Swift Testing runner outright rather than merely hanging.

A related conflation: ids are stringified into the map key, so a string "1" and an integer 1 from different clients collide as well.

Reproduction sketch

  1. Start a server on StatelessHTTPServerTransport with any tool registered.
  2. Issue N concurrent POST requests whose bodies share a JSON-RPC id, e.g. {"jsonrpc":"2.0","id":7,"method":"tools/list"}.
  3. Observe: some requests never receive a response (hang), and continuation-misuse diagnostics fire as overwritten continuations are dropped.

Suggested direction

  • Correlate waiters by a per-exchange token (e.g. a UUID assigned per handleRequest call) rather than the wire-level JSON-RPC id, mapping outgoing responses back through a wire-id → exchange-token association that is scoped to the exchange rather than global.
  • Independently, a waiter deadline would convert any residual correlation failure from an indefinite hang into a bounded error response.

Found during an MCP 2025-11-25 conformance audit of an app embedding this SDK. Happy to provide more detail.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    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