Skip to content

fix: StatelessHTTPServerTransport leaves the original POST hanging after notifications/cancelled (#255) - #260

Open
ianegordon wants to merge 1 commit into
modelcontextprotocol:mainfrom
ianegordon:fix/255-cancellation-hang
Open

ianegordon wants to merge 1 commit into
modelcontextprotocol:mainfrom
ianegordon:fix/255-cancellation-hang

Conversation

@ianegordon

@ianegordon ianegordon commented Jul 20, 2026 •

Copy link
Copy Markdown

Motivation and Context

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

Problem

A request cancelled via notifications/cancelled gets no JSON-RPC response (per the cancellation spec's "Not send a response for the cancelled request"), but nothing resumes the transport's HTTP waiter, so the original POST hangs until transport termination — violating the Streamable HTTP requirement that a request POST "MUST either return Content-Type: text/event-stream … or Content-Type: application/json, to return one JSON object."

Fix

The transport detects notifications/cancelled, still yields it to the Server (whose existing cancellation path cancels the handler task and stays wire-silent), then resumes the matching response waiter with a synthesized JSON-RPC error: code -32002, message "Request cancelled[: <reason>]". Deviating from the cancellation SHOULD is sanctioned by the spec itself: "The sender of the cancellation notification SHOULD ignore any response to the request that arrives afterward." Unknown, already-completed, and malformed cancellations are ignored ("Invalid cancellation notifications SHOULD be ignored"). Cancel-vs-response races are safe via actor isolation — exactly one path resumes the waiter.

-32002 sits in MCP's designated implementation-specific band [-32000, -32099], next after this SDK's -32000/-32001. Surveyed precedents: python-sdk always answers cancelled requests (code 0), go-sdk's jsonrpc2 always answers calls (generic -32001), kotlin-sdk completes the POST as 202-no-body (which reads as violating the MUST above for request inputs), typescript-sdk currently has this same hang. No SDK uses LSP's -32800.

Tests

Five deterministic tests (no fixed sleeps — handler-entry gating via AsyncStream): completion with -32002 + handler-observed cancellation, reason propagation, integer-id round-trip, unknown/completed-id no-op, malformed no-op. Full suite: 556 tests / 41 suites green, no warnings.

Non-goals

How Has This Been Tested?

Tested locally AND @sollahiro verified fix addresses a hang in their test suite.

Breaking Changes

None

Types of changes

  • Bug fix (non-breaking change which fixes an issue)
  • New feature (non-breaking change which adds functionality)
  • Breaking change (fix or feature that would cause existing functionality to change)
  • Documentation update

Checklist

  • I have read the MCP Documentation
  • My code follows the repository's style guidelines
  • New and existing tests pass locally
  • I have added appropriate error handling
  • I have added or updated documentation as needed

🤖 Generated with Claude Code and Codex

…essHTTPServerTransport

A request cancelled via notifications/cancelled correctly receives no
JSON-RPC response from Server, but nothing resumed the transport's HTTP
response waiter, so the original POST hung until transport termination.

The transport now detects notifications/cancelled, forwards it to the
Server unchanged (its existing cancellation path still cancels the
handler task), and resumes the matching waiter with a synthesized
JSON-RPC error response (code -32002, "Request cancelled"), satisfying
the Streamable HTTP requirement that a request POST receive one JSON
object. Unknown, already-completed, and requestId-less cancellations
are ignored per spec.

Fixes modelcontextprotocol#255

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@sollahiro

Copy link
Copy Markdown

Verified locally: pinned swift-sdk to this PR's commit (b5da0ef) and ran our existing hang-reproduction test suite (StatelessTransportHangMitigationTests, built for #84/#254/#255) against it.

This is a local reproduction test, not a live production A/B — but the scenario it exercises (slow handler + notifications/cancelled) is exactly the mechanism we suspect caused the production symptom we reported (no server access-log entry, 8-79s client-side completion).

Results:

  • The raw transport now resumes the HTTP waiter (previously hung indefinitely; now completes in ~2.3s in our harness).
  • Our own mitigation (withOperationTimeout, 200s in prod) no longer needs to kick in — the request now completes with a -32002 JSON-RPC error in ~0.2s.
  • Full suite (719 tests) green aside from the two tests specifically asserting the old hang behavior (expected).

+1 from us based on this. We'll follow up if a production deploy surfaces anything further.

@ianegordon
ianegordon marked this pull request as ready for review July 20, 2026 22:31
@ianegordon

Copy link
Copy Markdown
Author

Thank you @sollahiro , marked as "Ready to Review"

@ianegordon

Copy link
Copy Markdown
Author

@jpurnell I believe PR #260 and PR #268 may solve the same issue (#255). Does this PR solve your issue?

ianegordon added a commit to ianegordon/swift-sdk that referenced this pull request Sep 10, 2026
…tream modelcontextprotocol#260, rebased onto modelcontextprotocol#264)

Manifest entry 2. Upstream refs/pull/260/head b5da0ef (branch pr/260),
rebased onto modelcontextprotocol#264 as 12b8c92 with the cancellation routed through the
exchange table. Fixes modelcontextprotocol#255.
ianegordon added a commit to ianegordon/swift-sdk that referenced this pull request Sep 10, 2026
ianegordon pushed a commit to ianegordon/swift-sdk that referenced this pull request Oct 13, 2026
…ests without a reply (upstream modelcontextprotocol#298, EhsanAzish80)

Supersedes the contributions of modelcontextprotocol#264, modelcontextprotocol#260 and modelcontextprotocol#270 (manifest entries 1-3),
which modelcontextprotocol#298 combines. Conflicts resolved in favour of modelcontextprotocol#298 in
HTTPServerTypes.swift, StatelessHTTPServerTransport.swift, Server.swift,
CancellationTests.swift and HTTPServerTransportTests.swift.

Fork changes re-applied on top:
- modelcontextprotocol#258's Server.init description/websiteUrl/icons parameters
- modelcontextprotocol#221's MockTransport receiveCallCount / finishStream
- entry 1a's collision regression tests and their makeStatelessPOSTRequest
  authorization overload

Behavior: a cancelled original POST now ends as HTTP 200 with an empty
text/event-stream and no JSON-RPC body (previously -32002 from modelcontextprotocol#260).
Follow-up on when to use -32800: fork issue #41.

Full suite: 589 tests in 43 suites passed.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
@varun06

varun06 commented Oct 22, 2026

Copy link
Copy Markdown
Contributor

Hi @ianegordon, thank you for this contribution. We're consolidating overlapping PRs.

#298 covers the same issue (#255), completing the original POST after notifications/cancelled. It also isolates each HTTP exchange with a private ID, so a cancel from one client can't complete another client's request with the same JSON-RPC id. We'd also prefer not to reuse -32002, which MCP defines as "Resource not found".

We plan to close this PR once #298 is merged, so the fix isn't lost in between. If you think something here isn't covered by #298, please comment there or here and we'll make sure it's carried over.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

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

3 participants