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