Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down Expand Up @@ -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? {
Expand Down
85 changes: 85 additions & 0 deletions Tests/MCPTests/HTTPServerTransportTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down