Skip to content

StatelessHTTPServerTransport: notifications/cancelled leaves the original POST hanging (HTTP exchange never completed) #255

Description

@ianegordon

Summary

In StatelessHTTPServerTransport (0.12.1), a request that is cancelled via notifications/cancelled leaves its original HTTP POST hanging indefinitely.

The cancellation flow correctly suppresses the JSON-RPC response (per base-protocol cancellation semantics, the server must not reply to a cancelled request). But the transport's HTTP waiter for that id is only ever resumed by two paths:

  • a matching JSON-RPC response arriving through send(_:) (responseWaiters.removeValue(forKey: id) + resume), or
  • full transport terminate() (responseWaiters.removeAll() after failing them).

Since a cancelled request produces no response and nothing else resumes the waiter, the withCheckedThrowingContinuation in handleJSONRPCRequest (…/StatelessHTTPServerTransport.swift:230-233) never returns, and the original POST's HTTP exchange never completes.

Spec conflict

MCP 2025-11-25, Streamable HTTP, Sending Messages to the Server #5:

"If the input is a JSON-RPC request, the server MUST either return Content-Type: text/event-stream, to initiate an SSE stream, or Content-Type: application/json, to return one JSON object."

And cancellation is the spec's own recommended flow (same section, #6):

"To cancel, the client SHOULD explicitly send an MCP CancelledNotification."

So a spec-conformant client that cancels an in-flight request drives the server into violating the MUST above — the POST receives neither an SSE stream nor a JSON object, ever.

Reproduction sketch

  1. Start a server on StatelessHTTPServerTransport with a slow tool handler.
  2. POST a tools/call request with id X; while it is in flight, POST a notifications/cancelled for id X (202 as expected).
  3. Observe: the first POST never completes.

Suggested direction

  • When processing notifications/cancelled, resume the matching responseWaiters entry so the HTTP exchange completes (e.g. with a JSON-RPC error object such as "request cancelled", keeping the HTTP layer satisfied while the JSON-RPC layer stays silent-by-design is admittedly awkward — an error response for the cancelled id seems the pragmatic choice given the transport MUST above).
  • Independently, a waiter deadline would bound any orphaned exchange.

Related: filed separately, concurrent requests sharing a JSON-RPC id also orphan waiters via map overwrite.

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