Skip to content

Commit d6a4875

Browse files
feat/2025-11-25: sampling, elicitation, roots (#198)
* feat: Roots implementation * feat: added unit testing * feat: improved unit tests and fixed NetworkTransport crash * feat: implemented sampling and elicitation * feat: improved sampling tests * feat: improved elicitation tests * feat: improved confirmance tests * feat: improved Prompts unit tests * feat: removed test code * feat: updated conformance tests
1 parent 35213e0 commit d6a4875

18 files changed

Lines changed: 3285 additions & 504 deletions

Sources/MCP/Base/Error.swift

Lines changed: 68 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,25 @@ import Foundation
66
@preconcurrency import SystemPackage
77
#endif
88

9+
/// Information about a required URL elicitation
10+
public struct URLElicitationInfo: Codable, Hashable, Sendable {
11+
/// Elicitation mode (must be "url")
12+
public var mode: String
13+
/// Unique identifier for this elicitation
14+
public var elicitationId: String
15+
/// URL for the user to visit
16+
public var url: String
17+
/// Message describing the elicitation
18+
public var message: String
19+
20+
public init(mode: String = "url", elicitationId: String, url: String, message: String) {
21+
self.mode = mode
22+
self.elicitationId = elicitationId
23+
self.url = url
24+
self.message = message
25+
}
26+
}
27+
928
/// A model context protocol error.
1029
public enum MCPError: Swift.Error, Sendable {
1130
// Standard JSON-RPC 2.0 errors (-32700 to -32603)
@@ -18,6 +37,9 @@ public enum MCPError: Swift.Error, Sendable {
1837
// Server errors (-32000 to -32099)
1938
case serverError(code: Int, message: String)
2039

40+
// MCP specific errors
41+
case urlElicitationRequired(message: String, elicitations: [URLElicitationInfo]) // -32042
42+
2143
// Transport specific errors
2244
case connectionClosed
2345
case transportError(Swift.Error)
@@ -31,6 +53,7 @@ public enum MCPError: Swift.Error, Sendable {
3153
case .invalidParams: return -32602
3254
case .internalError: return -32603
3355
case .serverError(let code, _): return code
56+
case .urlElicitationRequired: return -32042
3457
case .connectionClosed: return -32000
3558
case .transportError: return -32001
3659
}
@@ -68,6 +91,8 @@ extension MCPError: LocalizedError {
6891
return "Internal error" + (detail.map { ": \($0)" } ?? "")
6992
case .serverError(_, let message):
7093
return "Server error: \(message)"
94+
case .urlElicitationRequired(let message, _):
95+
return "URL elicitation required: \(message)"
7196
case .connectionClosed:
7297
return "Connection closed"
7398
case .transportError(let error):
@@ -89,6 +114,8 @@ extension MCPError: LocalizedError {
89114
return "Internal JSON-RPC error"
90115
case .serverError:
91116
return "Server-defined error occurred"
117+
case .urlElicitationRequired:
118+
return "The server requires user authentication or input via external URL"
92119
case .connectionClosed:
93120
return "The connection to the server was closed"
94121
case .transportError(let error):
@@ -106,6 +133,11 @@ extension MCPError: LocalizedError {
106133
return "Check the method name and ensure it is supported by the server"
107134
case .invalidParams:
108135
return "Verify the parameters match the method's expected parameters"
136+
case .urlElicitationRequired(_, let elicitations):
137+
if let first = elicitations.first {
138+
return "Visit \(first.url) to complete the required authentication or input"
139+
}
140+
return "Complete the required URL-based elicitation"
109141
case .connectionClosed:
110142
return "Try reconnecting to the server"
111143
default:
@@ -154,6 +186,20 @@ extension MCPError: Codable {
154186
case .serverError(_, _):
155187
// No additional data for server errors
156188
break
189+
case .urlElicitationRequired(_, let elicitations):
190+
// Encode elicitations array as structured data
191+
let elicitationsData = elicitations.map { info -> [String: Value] in
192+
return [
193+
"mode": .string(info.mode),
194+
"elicitationId": .string(info.elicitationId),
195+
"url": .string(info.url),
196+
"message": .string(info.message)
197+
]
198+
}
199+
try container.encode(
200+
["elicitations": Value.array(elicitationsData.map { .object($0) })],
201+
forKey: .data
202+
)
157203
case .connectionClosed:
158204
break
159205
case .transportError(let error):
@@ -188,6 +234,25 @@ extension MCPError: Codable {
188234
self = .invalidParams(unwrapDetail(message))
189235
case -32603:
190236
self = .internalError(unwrapDetail(nil))
237+
case -32042:
238+
// Extract elicitations array from data
239+
var elicitations: [URLElicitationInfo] = []
240+
if case .array(let items) = data?["elicitations"] {
241+
for item in items {
242+
if case .object(let dict) = item,
243+
case .string(let mode) = dict["mode"],
244+
case .string(let elicitationId) = dict["elicitationId"],
245+
case .string(let url) = dict["url"],
246+
case .string(let msg) = dict["message"] {
247+
elicitations.append(URLElicitationInfo(
248+
mode: mode,
249+
elicitationId: elicitationId,
250+
url: url,
251+
message: msg))
252+
}
253+
}
254+
}
255+
self = .urlElicitationRequired(message: message, elicitations: elicitations)
191256
case -32000:
192257
self = .connectionClosed
193258
case -32001:
@@ -236,6 +301,9 @@ extension MCPError: Hashable {
236301
hasher.combine(detail)
237302
case .serverError(_, let message):
238303
hasher.combine(message)
304+
case .urlElicitationRequired(let message, let elicitations):
305+
hasher.combine(message)
306+
hasher.combine(elicitations)
239307
case .connectionClosed:
240308
break
241309
case .transportError(let error):

Sources/MCP/Base/Transports/HTTPClientTransport.swift

Lines changed: 17 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -444,25 +444,28 @@ public actor HTTPClientTransport: Transport {
444444
guard isConnected else { return }
445445

446446
// Wait for session ID to be available before opening SSE stream
447-
if self.sessionID == nil {
447+
if self.sessionID == nil, let signalTask = self.initialSessionIDSignalTask {
448448
logger.debug("⏳ Waiting for session ID to be set (timeout: \(self.sseInitializationTimeout)s)...")
449449

450450
let startTime = Date()
451451
let timeout = self.sseInitializationTimeout
452-
453-
// Poll for session ID with exponential backoff
454-
var attempt = 0
455-
while self.sessionID == nil && !Task.isCancelled {
456-
let elapsed = Date().timeIntervalSince(startTime)
457-
if elapsed >= timeout {
458-
logger.warning("⏱️ Timeout waiting for session ID (\(timeout)s). SSE stream will proceed anyway.")
459-
break
452+
do {
453+
try await withThrowingTaskGroup { group in
454+
group.addTask {
455+
try await Task.sleep(for: .seconds(timeout))
456+
}
457+
458+
group.addTask {
459+
await signalTask.value
460+
}
461+
462+
if let firstResult = try await group.next() {
463+
group.cancelAll()
464+
return firstResult
465+
}
460466
}
461-
462-
// Exponential backoff: 10ms, 20ms, 50ms, 100ms, 200ms, then 500ms
463-
let delay = min(500, max(10, 10 * (1 << attempt)))
464-
try? await Task.sleep(for: .milliseconds(delay))
465-
attempt += 1
467+
} catch {
468+
logger.warning("⏱️ Timeout waiting for session ID (\(timeout)s). SSE stream will proceed anyway.")
466469
}
467470

468471
if self.sessionID != nil {

Sources/MCP/Base/Transports/HTTPServer/StatefulHTTPServerTransport.swift

Lines changed: 15 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -370,24 +370,34 @@ public actor StatefulHTTPServerTransport: Transport {
370370

371371
// MARK: - Message Routing
372372

373-
private func routeResponse(_ data: Data, requestID: String) {
373+
/// Routes a message to a specific request's SSE stream without closing it.
374+
/// Used for server-initiated messages during request handling.
375+
private func routeToRequestStream(_ data: Data, requestID: String) {
374376
let eventID = storeEvent(streamID: requestID, message: data)
375377

376378
guard let continuation = requestSSEContinuations[requestID] else {
377379
logger.debug(
378-
"No active stream for request, response stored for replay",
380+
"No active stream for request, message stored for replay",
379381
metadata: ["requestID": "\(requestID)"]
380382
)
381383
return
382384
}
383385

384-
// Format as SSE and yield
386+
// Format as SSE and yield (but don't close the stream)
385387
let sseEvent = SSEEvent.message(data: data, id: eventID)
386388
continuation.yield(sseEvent.formatted())
389+
}
390+
391+
/// Routes a response to a specific request's SSE stream and closes it.
392+
/// Used for final responses to client requests.
393+
private func routeResponse(_ data: Data, requestID: String) {
394+
routeToRequestStream(data, requestID: requestID)
387395

388396
// Response means the request is complete — close the stream
389-
continuation.finish()
390-
requestSSEContinuations.removeValue(forKey: requestID)
397+
if let continuation = requestSSEContinuations[requestID] {
398+
continuation.finish()
399+
requestSSEContinuations.removeValue(forKey: requestID)
400+
}
391401
}
392402

393403
private func routeServerInitiatedMessage(_ data: Data) {

0 commit comments

Comments
 (0)