From 438384cb31bc9d9d3008975ef072f4f888d963cb Mon Sep 17 00:00:00 2001 From: Justin Purnell Date: Wed, 29 Jul 2026 11:09:34 -0400 Subject: [PATCH] fix(StatelessHTTPServerTransport): complete cancelled request's HTTP exchange A request cancelled via notifications/cancelled left its original HTTP POST hanging indefinitely. The transport's response waiter is only ever resumed by a matching JSON-RPC response or by terminate(); a cancelled request correctly produces no response (the server must stay silent), so nothing resumed the waiter and the withCheckedThrowingContinuation in handleJSONRPCRequest never returned. This also violates the Streamable HTTP requirement that a POST carrying a request MUST receive either an SSE stream or a JSON object. When a CancelledNotification for an in-flight request arrives, complete that request's HTTP exchange with a JSON-RPC error (implementation-defined server-error code -32002, echoing the request id) so the POST returns a JSON object. The notification is still forwarded to the server so it can cancel the underlying work. Adds a regression test: the cancelled request's POST completes with a JSON-RPC error for its id (not a hang), the cancellation returns 202, and both messages still reach the server. Fixes #255 --- .../StatelessHTTPServerTransport.swift | 68 +++++++++++++++ Tests/MCPTests/HTTPServerTransportTests.swift | 85 +++++++++++++++++++ 2 files changed, 153 insertions(+) diff --git a/Sources/MCP/Base/Transports/HTTPServer/StatelessHTTPServerTransport.swift b/Sources/MCP/Base/Transports/HTTPServer/StatelessHTTPServerTransport.swift index abe9576c..082e0256 100644 --- a/Sources/MCP/Base/Transports/HTTPServer/StatelessHTTPServerTransport.swift +++ b/Sources/MCP/Base/Transports/HTTPServer/StatelessHTTPServerTransport.swift @@ -207,6 +207,11 @@ public actor StatelessHTTPServerTransport: Transport, HTTPContextProviding { // Handle by message type switch messageKind { case .notification, .response: + // A CancelledNotification must also complete the target request's HTTP exchange. + // A cancelled request produces no JSON-RPC response (the server must stay silent), + // so nothing else would ever resume its waiter and the original POST would hang + // forever (issue #255). Complete it here before forwarding the notification. + completeCancelledExchange(body) // Yield to server and return 202 Accepted incomingContinuation.yield(body) return .accepted() @@ -243,6 +248,69 @@ public actor StatelessHTTPServerTransport: Transport, HTTPContextProviding { return .data(responseData, headers: [HTTPHeaderName.contentType: ContentType.json]) } + // MARK: - Cancellation + + /// If `data` is a `notifications/cancelled` referencing an in-flight request, completes + /// that request's HTTP exchange with a JSON-RPC error so the original POST returns instead + /// of hanging. A cancelled request yields no JSON-RPC response, so without this the waiter + /// registered in ``handleJSONRPCRequest`` would only ever be resumed by ``terminate()``. + /// See issue #255. No-op for any other message or an unknown/absent request id. + private func completeCancelledExchange(_ data: Data) { + guard let params = Self.decodeCancellation(data), let id = params.requestId else { + return + } + let key = id.description + guard let continuation = responseWaiters.removeValue(forKey: key) else { + return + } + httpRequestContexts.removeValue(forKey: key) + logger.debug( + "Completing a cancelled request's HTTP exchange", + metadata: ["requestID": "\(key)"] + ) + continuation.resume(returning: Self.cancelledResponseBody(id: id, reason: params.reason)) + } + + /// Decodes a `notifications/cancelled` message's parameters, or `nil` if `data` is not a + /// cancellation notification. + private static func decodeCancellation(_ data: Data) -> CancelledNotification.Parameters? { + struct Envelope: Decodable { + let method: String + let params: CancelledNotification.Parameters? + } + guard let envelope = try? JSONDecoder().decode(Envelope.self, from: data), + envelope.method == CancelledNotification.name + else { + return nil + } + return envelope.params + } + + /// Builds a JSON-RPC error response body for a cancelled request, echoing the request id so + /// the client can correlate it. Uses an implementation-defined server-error code in the + /// JSON-RPC `-32000…-32099` range. + private static func cancelledResponseBody(id: ID, reason: String?) -> Data { + let cancelledErrorCode = -32002 + var message = "Request cancelled" + if let reason, !reason.isEmpty { + message += ": \(reason)" + } + let idValue: Any + switch id { + case .string(let string): idValue = string + case .number(let number): idValue = number + } + let body: [String: Any] = [ + "jsonrpc": "2.0", + "id": idValue, + "error": [ + "code": cancelledErrorCode, + "message": message, + ] as [String: Any], + ] + return (try? JSONSerialization.data(withJSONObject: body)) ?? Data() + } + // MARK: - HTTPContextProviding public func httpRequestContext(for id: ID) -> HTTPRequest? { diff --git a/Tests/MCPTests/HTTPServerTransportTests.swift b/Tests/MCPTests/HTTPServerTransportTests.swift index 8e1c89b4..620f514a 100644 --- a/Tests/MCPTests/HTTPServerTransportTests.swift +++ b/Tests/MCPTests/HTTPServerTransportTests.swift @@ -1051,6 +1051,91 @@ struct StatelessHTTPServerTransportTests { // Should return error (500 or similar) since waiter was cancelled #expect(response.statusCode == 500) } + + // MARK: - Cancellation completes the HTTP exchange (issue #255) + + @Test( + "notifications/cancelled completes the cancelled request's HTTP exchange instead of hanging", + .timeLimit(.minutes(1)) + ) + func testCancelledRequestCompletesHTTPExchange() async throws { + let transport = makeStatelessTransport() + try await transport.connect() + + // Observe what actually reaches the server: the request and the cancellation. + actor SeenMethods { + private(set) var methods: [String] = [] + func add(_ method: String) { methods.append(method) } + } + let seen = SeenMethods() + let drain = Task { + let stream = await transport.receive() + for try await data in stream { + switch JSONRPCMessageKind(data: data) { + case .request(_, let method)?: await seen.add(method) + case .notification(let method)?: await seen.add(method) + default: break + } + } + } + + // POST a request the server will never respond to — it gets cancelled mid-flight. + actor ResponseBox { + private(set) var response: HTTPResponse? + func set(_ value: HTTPResponse) { response = value } + } + let box = ResponseBox() + let requestBody = makeRequestBody(id: "slow-1", method: "tools/call") + Task { await box.set(await transport.handleRequest(makeStatelessPOSTRequest(body: requestBody))) } + + // Let the request register its waiter and park. + try await Task.sleep(for: .milliseconds(50)) + + // Client cancels it with a CancelledNotification for the same id. + let cancelBody = try JSONSerialization.data(withJSONObject: [ + "jsonrpc": "2.0", + "method": "notifications/cancelled", + "params": ["requestId": "slow-1", "reason": "user aborted"] as [String: Any], + ]) + let cancelResponse = await transport.handleRequest( + makeStatelessPOSTRequest(body: cancelBody) + ) + #expect(cancelResponse.statusCode == 202) + + // The original POST must now complete (spec: it MUST receive a JSON object), not hang. + var requestResult: HTTPResponse? + for _ in 0..<200 { + if let r = await box.response { + requestResult = r + break + } + try await Task.sleep(for: .milliseconds(10)) + } + #expect(requestResult != nil, "cancelled request's POST must complete, not hang") + #expect(requestResult?.statusCode == 200) + + // Body is a JSON-RPC error for the cancelled id so the client can correlate. + if let data = requestResult?.bodyData, + let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any] + { + #expect(json["error"] != nil) + let idString: String? + if let s = json["id"] as? String { idString = s } + else if let n = json["id"] as? Int { idString = String(n) } + else { idString = nil } + #expect(idString == "slow-1") + } else { + Issue.record("expected a JSON body carrying an error for the cancelled request") + } + + // Both the request and the cancellation still reached the server (so it can cancel work). + let seenMethods = await seen.methods + #expect(seenMethods.contains("tools/call")) + #expect(seenMethods.contains("notifications/cancelled")) + + drain.cancel() + await transport.disconnect() + } } // MARK: - HTTPContextProviding / Server.currentHandlerContext