diff --git a/Sources/MCP/Base/Transports/HTTPServer/StatelessHTTPServerTransport.swift b/Sources/MCP/Base/Transports/HTTPServer/StatelessHTTPServerTransport.swift index abe9576c..736ee5bc 100644 --- a/Sources/MCP/Base/Transports/HTTPServer/StatelessHTTPServerTransport.swift +++ b/Sources/MCP/Base/Transports/HTTPServer/StatelessHTTPServerTransport.swift @@ -29,6 +29,13 @@ import Logging /// - Session management is handled externally or not needed /// /// For full streaming and session support, use ``StatefulHTTPServerTransport`` instead. +/// +/// ## Concurrency +/// +/// In-flight requests are correlated to their HTTP exchange by JSON-RPC id. Because ids are +/// client-scoped, a concurrent request that reuses an id already in flight is rejected with +/// HTTP 409 and a JSON-RPC `-32600` error rather than silently displacing the first request. +/// Clients must therefore keep ids unique among their own concurrent in-flight requests. public actor StatelessHTTPServerTransport: Transport, HTTPContextProviding { public nonisolated let logger: Logger @@ -221,6 +228,30 @@ public actor StatelessHTTPServerTransport: Transport, HTTPContextProviding { requestID: String, request: HTTPRequest ) async -> HTTPResponse { + // Reject a second request that reuses a JSON-RPC id already in flight. + // + // JSON-RPC ids are client-scoped, and this transport is explicitly multi-client, so + // colliding ids across concurrent POSTs are legal and common (most clients start their + // id sequence at 1). Keying per-request state on the raw id means a second request + // would silently overwrite the first's response waiter (the first POST hangs forever + // and its continuation leaks — issue #254) and its HTTP context (a handler could then + // read another client's `Authorization` header — issue #265). Failing the duplicate + // fast keeps every in-flight exchange isolated. Transparent support for concurrent + // colliding ids arrives with the stateless core (SEP-2575). + guard httpRequestContexts[requestID] == nil else { + logger.warning( + "Rejecting request: a JSON-RPC id is already in flight for another concurrent request", + metadata: ["requestID": "\(requestID)"] + ) + return .error( + statusCode: 409, + .invalidRequest( + "Duplicate in-flight JSON-RPC request id '\(requestID)'; " + + "ids must be unique among concurrent in-flight requests" + ) + ) + } + httpRequestContexts[requestID] = request // Yield the incoming message to the server incomingContinuation.yield(body) diff --git a/Tests/MCPTests/HTTPServerTransportTests.swift b/Tests/MCPTests/HTTPServerTransportTests.swift index 8e1c89b4..1fcc9c83 100644 --- a/Tests/MCPTests/HTTPServerTransportTests.swift +++ b/Tests/MCPTests/HTTPServerTransportTests.swift @@ -1051,6 +1051,104 @@ struct StatelessHTTPServerTransportTests { // Should return error (500 or similar) since waiter was cancelled #expect(response.statusCode == 500) } + + // MARK: - Concurrent colliding JSON-RPC ids (issues #254, #265) + + @Test( + "Colliding in-flight JSON-RPC ids: duplicate is rejected; first request's context and response stay intact", + .timeLimit(.minutes(1)) + ) + func testCollidingInFlightRequestIDsRejected() async throws { + let transport = makeStatelessTransport() + try await transport.connect() + + // Track which request bodies actually reach the server via receive(). + // The rejected duplicate must never be dispatched. + actor SeenIDs { + private(set) var ids: [String] = [] + func add(_ id: String) { ids.append(id) } + } + let seen = SeenIDs() + let drain = Task { + let stream = await transport.receive() + for try await data in stream { + if case .request(let id, _)? = JSONRPCMessageKind(data: data) { + await seen.add(id) + } + } + } + + // Request A — id "1", Authorization "Bearer A". Parks until we respond. + let requestA = HTTPRequest( + method: "POST", + headers: [ + "Content-Type": "application/json", + "Accept": "application/json", + "Authorization": "Bearer A", + ], + body: makeRequestBody(id: "1", method: "tools/list"), + path: "/mcp" + ) + let taskA = Task { await transport.handleRequest(requestA) } + + // Let A register its context + waiter and park. + try await Task.sleep(for: .milliseconds(50)) + + // The in-flight context for id "1" is A's. + let ctxDuringA = await transport.httpRequestContext(for: .string("1")) + #expect(ctxDuringA?.header("Authorization") == "Bearer A") + + // Request B — SAME id "1", Authorization "Bearer B". Must be rejected promptly, + // NOT overwrite A's waiter/context, and NOT hang. + let requestB = HTTPRequest( + method: "POST", + headers: [ + "Content-Type": "application/json", + "Accept": "application/json", + "Authorization": "Bearer B", + ], + body: makeRequestBody(id: "1", method: "tools/list"), + path: "/mcp" + ) + actor ResponseBox { + private(set) var response: HTTPResponse? + func set(_ value: HTTPResponse) { response = value } + } + let box = ResponseBox() + let taskB = Task { await box.set(await transport.handleRequest(requestB)) } + + // Poll up to ~2s: B must return, not hang. + var responseB: HTTPResponse? + for _ in 0..<200 { + if let r = await box.response { + responseB = r + break + } + try await Task.sleep(for: .milliseconds(10)) + } + #expect(responseB != nil, "duplicate-id request must return promptly, not hang") + #expect(responseB?.statusCode == 409) + + // A's context is untouched — B never overwrote it (no auth cross-talk, #265). + let ctxStillA = await transport.httpRequestContext(for: .string("1")) + #expect(ctxStillA?.header("Authorization") == "Bearer A") + + // Respond to A — it completes normally with its own response (#254: waiter not lost). + // Capture the body once: JSONSerialization key order isn't stable across calls. + let responseBodyA = makeResponseBody(id: "1") + try await transport.send(responseBodyA) + let resultA = await taskA.value + #expect(resultA.statusCode == 200) + #expect(resultA.bodyData == responseBodyA) + + // Only A's body ever reached the server; B was rejected pre-dispatch. + let seenIDs = await seen.ids + #expect(seenIDs == ["1"]) + + _ = await taskB.value + drain.cancel() + await transport.disconnect() + } } // MARK: - HTTPContextProviding / Server.currentHandlerContext