Skip to content

Commit e06ced9

Browse files
committed
Add clear error for server-to-client requests in stateless HTTP mode
See modelcontextprotocol/python-sdk#1828
1 parent 57d4ff0 commit e06ced9

6 files changed

Lines changed: 218 additions & 0 deletions

File tree

Sources/MCP/Base/Transport.swift

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -145,6 +145,16 @@ public protocol Transport: Actor {
145145
/// For simple transports (stdio, single-connection), this returns `nil`.
146146
var sessionId: String? { get }
147147

148+
/// Whether this transport supports server-to-client requests.
149+
///
150+
/// Server-to-client requests (sampling, elicitation, roots) require a persistent
151+
/// bidirectional connection. Stateless HTTP transports do not support this because
152+
/// each request is independent with no way to send requests back to the client.
153+
///
154+
/// Most transports (stdio, stateful HTTP) support this and return `true`.
155+
/// Stateless HTTP transports return `false`.
156+
var supportsServerToClientRequests: Bool { get }
157+
148158
/// Establishes connection with the transport
149159
func connect() async throws
150160

@@ -185,6 +195,10 @@ extension Transport {
185195
/// HTTP transports override this to return their session identifier.
186196
public var sessionId: String? { nil }
187197

198+
/// Default implementation returns `true` since most transports support
199+
/// bidirectional communication. Stateless HTTP transports override this.
200+
public var supportsServerToClientRequests: Bool { true }
201+
188202
/// Default implementation that ignores the request ID.
189203
///
190204
/// Simple transports (stdio, single-connection) don't need request ID routing,

Sources/MCP/Base/Transports/HTTPServerTransport.swift

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -62,6 +62,12 @@ public actor HTTPServerTransport: Transport {
6262
/// The session ID for this transport (nil in stateless mode)
6363
public private(set) var sessionId: String?
6464

65+
/// Whether this transport supports server-to-client requests.
66+
/// Returns `false` in stateless mode since there's no persistent connection.
67+
public var supportsServerToClientRequests: Bool {
68+
options.sessionIdGenerator != nil
69+
}
70+
6571
/// Whether this transport has been initialized
6672
private var initialized = false
6773

Sources/MCP/Server/Server+ClientRequests.swift

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,13 @@ extension Server {
1515
throw MCPError.internalError("Server connection not initialized")
1616
}
1717

18+
guard await connection.supportsServerToClientRequests else {
19+
throw MCPError.invalidRequest(
20+
"Server-to-client requests are not supported in stateless HTTP mode. " +
21+
"Stateless mode has no persistent connection for bidirectional communication."
22+
)
23+
}
24+
1825
let encoder = JSONEncoder()
1926
encoder.outputFormatting = [.sortedKeys, .withoutEscapingSlashes]
2027
let requestData = try encoder.encode(request)

Tests/MCPTests/HTTPServerTransportTests.swift

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -51,6 +51,22 @@ struct HTTPServerTransportTests {
5151
#expect(sessionId == nil)
5252
}
5353

54+
@Test("Stateless mode does not support server-to-client requests")
55+
func statelessModeDoesNotSupportServerToClientRequests() async throws {
56+
let transport = testTransport()
57+
58+
let supportsRequests = await transport.supportsServerToClientRequests
59+
#expect(supportsRequests == false)
60+
}
61+
62+
@Test("Stateful mode supports server-to-client requests")
63+
func statefulModeSupportServerToClientRequests() async throws {
64+
let transport = testTransport(sessionIdGenerator: { UUID().uuidString })
65+
66+
let supportsRequests = await transport.supportsServerToClientRequests
67+
#expect(supportsRequests == true)
68+
}
69+
5470
// MARK: - POST Request Handling
5571

5672
@Test("POST requires correct Accept header")

Tests/MCPTests/Helpers/MockTransport.swift

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,10 @@ actor MockTransport: Transport {
1616

1717
var isConnected = false
1818

19+
/// Whether this transport supports server-to-client requests.
20+
/// Defaults to `true`. Set to `false` to simulate stateless mode.
21+
var supportsServerToClientRequests: Bool = true
22+
1923
private(set) var sentData: [Data] = []
2024
var sentMessages: [String] {
2125
return sentData.compactMap { data in
@@ -80,6 +84,10 @@ actor MockTransport: Transport {
8084
shouldFailSend = shouldFail
8185
}
8286

87+
func setSupportsServerToClientRequests(_ supports: Bool) {
88+
supportsServerToClientRequests = supports
89+
}
90+
8391
func queue(data: Data) {
8492
if let continuation = dataStreamContinuation {
8593
continuation.yield(TransportMessage(data: data))
Lines changed: 167 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,167 @@
1+
import Foundation
2+
import Testing
3+
4+
@testable import MCP
5+
6+
@Suite("Stateless Mode Tests")
7+
struct StatelessModeTests {
8+
9+
// MARK: - Transport Property Tests
10+
11+
@Test("MockTransport defaults to supporting server-to-client requests")
12+
func mockTransportDefaultsToSupportingRequests() async throws {
13+
let transport = MockTransport()
14+
let supports = await transport.supportsServerToClientRequests
15+
#expect(supports == true)
16+
}
17+
18+
@Test("MockTransport can be configured to not support server-to-client requests")
19+
func mockTransportCanBeConfiguredToNotSupportRequests() async throws {
20+
let transport = MockTransport()
21+
await transport.setSupportsServerToClientRequests(false)
22+
let supports = await transport.supportsServerToClientRequests
23+
#expect(supports == false)
24+
}
25+
26+
// MARK: - Server-to-Client Request Rejection
27+
28+
@Test("listRoots fails immediately when transport does not support server-to-client requests",
29+
.timeLimit(.minutes(1)))
30+
func listRootsFailsInStatelessMode() async throws {
31+
let transport = MockTransport()
32+
await transport.setSupportsServerToClientRequests(false)
33+
34+
let server = Server(name: "TestServer", version: "1.0")
35+
36+
// Start server and initialize
37+
try await server.start(transport: transport)
38+
39+
// Queue initialize request with roots capability
40+
try await transport.queue(
41+
request: Initialize.request(
42+
.init(
43+
protocolVersion: Version.latest,
44+
capabilities: .init(roots: .init(listChanged: true)),
45+
clientInfo: .init(name: "TestClient", version: "1.0")
46+
)
47+
))
48+
49+
// Wait for server to process and respond
50+
_ = await transport.waitForSentMessageCount(1, timeout: .seconds(5))
51+
52+
// Queue initialized notification
53+
try await transport.queue(
54+
notification: InitializedNotification.message(.init())
55+
)
56+
57+
// Brief pause to ensure notification is processed
58+
try await Task.sleep(for: .milliseconds(100))
59+
60+
// listRoots should fail immediately with stateless error
61+
do {
62+
_ = try await server.listRoots()
63+
Issue.record("Expected listRoots to throw in stateless mode")
64+
} catch let error as MCPError {
65+
// Verify it's the stateless mode error, not a capability error
66+
let errorDescription = String(describing: error)
67+
#expect(
68+
errorDescription.contains("stateless") || errorDescription.contains("server-to-client"),
69+
"Expected stateless mode error, got: \(errorDescription)"
70+
)
71+
}
72+
73+
await server.stop()
74+
}
75+
76+
@Test("createMessage fails immediately when transport does not support server-to-client requests",
77+
.timeLimit(.minutes(1)))
78+
func createMessageFailsInStatelessMode() async throws {
79+
let transport = MockTransport()
80+
await transport.setSupportsServerToClientRequests(false)
81+
82+
let server = Server(name: "TestServer", version: "1.0")
83+
try await server.start(transport: transport)
84+
85+
// Queue initialize request with sampling capability
86+
try await transport.queue(
87+
request: Initialize.request(
88+
.init(
89+
protocolVersion: Version.latest,
90+
capabilities: .init(sampling: .init()),
91+
clientInfo: .init(name: "TestClient", version: "1.0")
92+
)
93+
))
94+
95+
_ = await transport.waitForSentMessageCount(1, timeout: .seconds(5))
96+
97+
try await transport.queue(
98+
notification: InitializedNotification.message(.init())
99+
)
100+
101+
try await Task.sleep(for: .milliseconds(100))
102+
103+
do {
104+
_ = try await server.createMessage(
105+
CreateSamplingMessage.Parameters(
106+
messages: [Sampling.Message(role: .user, content: .text("Hello"))],
107+
maxTokens: 100
108+
)
109+
)
110+
Issue.record("Expected createMessage to throw in stateless mode")
111+
} catch let error as MCPError {
112+
let errorDescription = String(describing: error)
113+
#expect(
114+
errorDescription.contains("stateless") || errorDescription.contains("server-to-client"),
115+
"Expected stateless mode error, got: \(errorDescription)"
116+
)
117+
}
118+
119+
await server.stop()
120+
}
121+
122+
@Test("elicit fails immediately when transport does not support server-to-client requests",
123+
.timeLimit(.minutes(1)))
124+
func elicitFailsInStatelessMode() async throws {
125+
let transport = MockTransport()
126+
await transport.setSupportsServerToClientRequests(false)
127+
128+
let server = Server(name: "TestServer", version: "1.0")
129+
try await server.start(transport: transport)
130+
131+
// Queue initialize request with elicitation capability
132+
try await transport.queue(
133+
request: Initialize.request(
134+
.init(
135+
protocolVersion: Version.latest,
136+
capabilities: .init(elicitation: .init(form: .init())),
137+
clientInfo: .init(name: "TestClient", version: "1.0")
138+
)
139+
))
140+
141+
_ = await transport.waitForSentMessageCount(1, timeout: .seconds(5))
142+
143+
try await transport.queue(
144+
notification: InitializedNotification.message(.init())
145+
)
146+
147+
try await Task.sleep(for: .milliseconds(100))
148+
149+
do {
150+
_ = try await server.elicit(
151+
.form(ElicitRequestFormParams(
152+
message: "Please provide input",
153+
requestedSchema: ElicitationSchema(properties: [:])
154+
))
155+
)
156+
Issue.record("Expected elicit to throw in stateless mode")
157+
} catch let error as MCPError {
158+
let errorDescription = String(describing: error)
159+
#expect(
160+
errorDescription.contains("stateless") || errorDescription.contains("server-to-client"),
161+
"Expected stateless mode error, got: \(errorDescription)"
162+
)
163+
}
164+
165+
await server.stop()
166+
}
167+
}

0 commit comments

Comments
 (0)