* Support 2025-06-18 Spec (#167)

* Support OpenAI _meta for Tool

* Update init

* Add title and outputSchema

* Concurreny issue

* General fields

* Add Generate Fields to Server elements

* 4 tab spacing

* Update Version

* Elicitation

* Format

* More tests and inits

* Elicitation in README

* Fix orphaned test

* Add title to more structs

* Public init

* Fix meta validation and make tests more generic

* Format and make Result properties public again

* Revert more visibility changes

* feat: 2025-11-25 phase 1, includes Version, Icon, Progress (#181)

* chore: fixed unit tests

* feat: added icon and progess base structs

* feat: added progress notification and meta to callTool

* feat: added tests

* feat: simplified RequestMeta encoding/decoding

* tests: added unit test to track progress

* chore: rebased v0.11.x

* feat: implemented cancellation

* feat: added overloaded callTool method

* fix: encoding Response _meta fix

* chore: added old version to the versions list

* feat: 2025-11-25 phase 2; added Logging and Completions (#196)

* feat: added Logging and Completions

* feat: updated EmbeddedResource for the latest spec

* chore: changed encoding of meta in Prompts to encodeIfPresent

* feat: compliance tests + http server transport (#197)

* feat: http server transport

* feat: added http server transport

* feat: removed unused files

* feat: added conformance tests

* feat: improved conformace testing

* chore: removed redundant 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

* chore: removed redundant commas

* chore: removed 6.0.3 swift version from ci

* chore: improved unit tests a little bit

* chore: update README.md

* chore: add server progress tracking example to README.md

* chore: updated README.md

* chore: updated transports table formatting

* chore: updated MockNetworkConnection

---------

Co-authored-by: Austin Evans <austinjevans@me.com>
Co-authored-by: yehorsobko <yehorsobko@macpaw.com>
This commit is contained in:
Maksym Mova 2026-02-19 13:09:31 +02:00 • committed by GitHub
commit 6112a3995a
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
28 changed files with 11854 additions and 828 deletions

View file

@ -6,6 +6,25 @@ import Foundation
@preconcurrency import SystemPackage
#endif
/// Information about a required URL elicitation
public struct URLElicitationInfo: Codable, Hashable, Sendable {
/// Elicitation mode (must be "url")
public var mode: String
/// Unique identifier for this elicitation
public var elicitationId: String
/// URL for the user to visit
public var url: String
/// Message describing the elicitation
public var message: String
public init(mode: String = "url", elicitationId: String, url: String, message: String) {
self.mode = mode
self.elicitationId = elicitationId
self.url = url
self.message = message
}
}
/// A model context protocol error.
public enum MCPError: Swift.Error, Sendable {
// Standard JSON-RPC 2.0 errors (-32700 to -32603)
@ -18,6 +37,9 @@ public enum MCPError: Swift.Error, Sendable {
// Server errors (-32000 to -32099)
case serverError(code: Int, message: String)
// MCP specific errors
case urlElicitationRequired(message: String, elicitations: [URLElicitationInfo]) // -32042
// Transport specific errors
case connectionClosed
case transportError(Swift.Error)
@ -31,6 +53,7 @@ public enum MCPError: Swift.Error, Sendable {
case .invalidParams: return -32602
case .internalError: return -32603
case .serverError(let code, _): return code
case .urlElicitationRequired: return -32042
case .connectionClosed: return -32000
case .transportError: return -32001
}
@ -68,6 +91,8 @@ extension MCPError: LocalizedError {
return "Internal error" + (detail.map { ": \($0)" } ?? "")
case .serverError(_, let message):
return "Server error: \(message)"
case .urlElicitationRequired(let message, _):
return "URL elicitation required: \(message)"
case .connectionClosed:
return "Connection closed"
case .transportError(let error):
@ -89,6 +114,8 @@ extension MCPError: LocalizedError {
return "Internal JSON-RPC error"
case .serverError:
return "Server-defined error occurred"
case .urlElicitationRequired:
return "The server requires user authentication or input via external URL"
case .connectionClosed:
return "The connection to the server was closed"
case .transportError(let error):
@ -106,6 +133,11 @@ extension MCPError: LocalizedError {
return "Check the method name and ensure it is supported by the server"
case .invalidParams:
return "Verify the parameters match the method's expected parameters"
case .urlElicitationRequired(_, let elicitations):
if let first = elicitations.first {
return "Visit \(first.url) to complete the required authentication or input"
}
return "Complete the required URL-based elicitation"
case .connectionClosed:
return "Try reconnecting to the server"
default:
@ -154,6 +186,20 @@ extension MCPError: Codable {
case .serverError(_, _):
// No additional data for server errors
break
case .urlElicitationRequired(_, let elicitations):
// Encode elicitations array as structured data
let elicitationsData = elicitations.map { info -> [String: Value] in
return [
"mode": .string(info.mode),
"elicitationId": .string(info.elicitationId),
"url": .string(info.url),
"message": .string(info.message)
]
}
try container.encode(
["elicitations": Value.array(elicitationsData.map { .object($0) })],
forKey: .data
)
case .connectionClosed:
break
case .transportError(let error):
@ -188,6 +234,25 @@ extension MCPError: Codable {
self = .invalidParams(unwrapDetail(message))
case -32603:
self = .internalError(unwrapDetail(nil))
case -32042:
// Extract elicitations array from data
var elicitations: [URLElicitationInfo] = []
if case .array(let items) = data?["elicitations"] {
for item in items {
if case .object(let dict) = item,
case .string(let mode) = dict["mode"],
case .string(let elicitationId) = dict["elicitationId"],
case .string(let url) = dict["url"],
case .string(let msg) = dict["message"] {
elicitations.append(URLElicitationInfo(
mode: mode,
elicitationId: elicitationId,
url: url,
message: msg))
}
}
}
self = .urlElicitationRequired(message: message, elicitations: elicitations)
case -32000:
self = .connectionClosed
case -32001:
@ -236,6 +301,9 @@ extension MCPError: Hashable {
hasher.combine(detail)
case .serverError(_, let message):
hasher.combine(message)
case .urlElicitationRequired(let message, let elicitations):
hasher.combine(message)
hasher.combine(elicitations)
case .connectionClosed:
break
case .transportError(let error):

View file

@ -0,0 +1,67 @@
import Foundation
/// An optionally-sized icon that can be displayed in a user interface.
///
/// Icons can be used to provide visual representation of tools, resources, prompts,
/// and other MCP entities.
///
/// - SeeAlso: https://modelcontextprotocol.io/specification/2025-11-25/schema#icon
public struct Icon: Hashable, Codable, Sendable {
/// The theme this icon is designed for.
public enum Theme: String, Hashable, Codable, Sendable {
/// Icon is designed for light backgrounds
case light
/// Icon is designed for dark backgrounds
case dark
}
/// A standard URI pointing to an icon resource.
///
/// May be an HTTP/HTTPS URL or a `data:` URI with Base64-encoded image data.
///
/// - Note: Consumers SHOULD take steps to ensure URLs serving icons are from
/// the same domain as the client/server or a trusted domain.
/// - Note: Consumers SHOULD take appropriate precautions when consuming SVGs
/// as they can contain executable JavaScript.
public let src: String
/// Optional MIME type override if the source MIME type is missing or generic.
///
/// For example: `"image/png"`, `"image/jpeg"`, or `"image/svg+xml"`.
public let mimeType: String?
/// Optional array of strings that specify sizes at which the icon can be used.
///
/// Each string should be in WxH format (e.g., `"48x48"`, `"96x96"`) or `"any"`
/// for scalable formats like SVG.
///
/// If not provided, the client should assume that the icon can be used at any size.
public let sizes: [String]?
/// Optional specifier for the theme this icon is designed for.
///
/// - `light`: Icon is designed to be used with a light background
/// - `dark`: Icon is designed to be used with a dark background
///
/// If not provided, the client should assume the icon can be used with any theme.
public let theme: Theme?
/// Creates a new icon.
///
/// - Parameters:
/// - src: A standard URI pointing to an icon resource (HTTP/HTTPS URL or data: URI)
/// - mimeType: Optional MIME type override
/// - sizes: Optional array of size strings (e.g., ["48x48", "96x96"])
/// - theme: Optional theme specifier
public init(
src: String,
mimeType: String? = nil,
sizes: [String]? = nil,
theme: Theme? = nil
) {
self.src = src
self.mimeType = mimeType
self.sizes = sizes
self.theme = theme
}
}

View file

@ -46,6 +46,43 @@ public enum Initialize: Method {
public let capabilities: Server.Capabilities
public let serverInfo: Server.Info
public let instructions: String?
public var _meta: Metadata?
public init(
protocolVersion: String,
capabilities: Server.Capabilities,
serverInfo: Server.Info,
instructions: String? = nil,
_meta: Metadata? = nil
) {
self.protocolVersion = protocolVersion
self.capabilities = capabilities
self.serverInfo = serverInfo
self.instructions = instructions
self._meta = _meta
}
private enum CodingKeys: String, CodingKey, CaseIterable {
case protocolVersion, capabilities, serverInfo, instructions, _meta
}
public func encode(to encoder: Encoder) throws {
var container = encoder.container(keyedBy: CodingKeys.self)
try container.encode(protocolVersion, forKey: .protocolVersion)
try container.encode(capabilities, forKey: .capabilities)
try container.encode(serverInfo, forKey: .serverInfo)
try container.encodeIfPresent(instructions, forKey: .instructions)
try container.encodeIfPresent(_meta, forKey: ._meta)
}
public init(from decoder: Decoder) throws {
let container = try decoder.container(keyedBy: CodingKeys.self)
protocolVersion = try container.decode(String.self, forKey: .protocolVersion)
capabilities = try container.decode(Server.Capabilities.self, forKey: .capabilities)
serverInfo = try container.decode(Server.Info.self, forKey: .serverInfo)
instructions = try container.decodeIfPresent(String.self, forKey: .instructions)
_meta = try container.decodeIfPresent(Metadata.self, forKey: ._meta)
}
}
}

View file

@ -37,8 +37,11 @@ struct AnyMethod: Method, Sendable {
}
extension Method where Parameters == Empty {
public static func request(id: ID = .random) -> Request<Self> {
Request(id: id, method: name, params: Empty())
public static func request(
id: ID = .random,
_meta: Metadata? = nil
) -> Request<Self> {
Request(id: id, method: name, params: Empty(), _meta: _meta)
}
}
@ -50,18 +53,30 @@ extension Method where Result == Empty {
extension Method {
/// Create a request with the given parameters.
public static func request(id: ID = .random, _ parameters: Self.Parameters) -> Request<Self> {
Request(id: id, method: name, params: parameters)
public static func request(
id: ID = .random,
_ parameters: Self.Parameters,
_meta: Metadata? = nil
) -> Request<Self> {
Request(id: id, method: name, params: parameters, _meta: _meta)
}
/// Create a response with the given result.
public static func response(id: ID, result: Self.Result) -> Response<Self> {
Response(id: id, result: result)
public static func response(
id: ID,
result: Self.Result,
_meta: Metadata? = nil
) -> Response<Self> {
Response(id: id, result: result, _meta: _meta)
}
/// Create a response with the given error.
public static func response(id: ID, error: MCPError) -> Response<Self> {
Response(id: id, error: error)
public static func response(
id: ID,
error: MCPError,
_meta: Metadata? = nil
) -> Response<Self> {
Response(id: id, error: error, _meta: _meta)
}
}
@ -75,15 +90,23 @@ public struct Request<M: Method>: Hashable, Identifiable, Codable, Sendable {
public let method: String
/// The request parameters.
public let params: M.Parameters
/// Metadata for this request (see spec for _meta usage, includes progressToken)
public let _meta: Metadata?
init(id: ID = .random, method: String, params: M.Parameters) {
init(
id: ID = .random,
method: String,
params: M.Parameters,
_meta: Metadata? = nil
) {
self.id = id
self.method = method
self.params = params
self._meta = _meta
}
private enum CodingKeys: String, CodingKey {
case jsonrpc, id, method, params
private enum CodingKeys: String, CodingKey, CaseIterable {
case jsonrpc, id, method, params, _meta
}
public func encode(to encoder: Encoder) throws {
@ -92,6 +115,7 @@ public struct Request<M: Method>: Hashable, Identifiable, Codable, Sendable {
try container.encode(id, forKey: .id)
try container.encode(method, forKey: .method)
try container.encode(params, forKey: .params)
try container.encodeIfPresent(_meta, forKey: ._meta)
}
}
@ -105,6 +129,7 @@ extension Request {
}
id = try container.decode(ID.self, forKey: .id)
method = try container.decode(String.self, forKey: .method)
_meta = try container.decodeIfPresent(Metadata.self, forKey: ._meta)
if M.Parameters.self is NotRequired.Type {
// For NotRequired parameters, use decodeIfPresent or init()
@ -196,25 +221,45 @@ public struct Response<M: Method>: Hashable, Identifiable, Codable, Sendable {
public let id: ID
/// The response result.
public let result: Swift.Result<M.Result, MCPError>
/// Metadata for this response (see spec for _meta usage)
public let _meta: Metadata?
public init(id: ID, result: M.Result) {
public init(
id: ID,
result: Swift.Result<M.Result, MCPError>,
_meta: Metadata? = nil
) {
self.id = id
self.result = .success(result)
self.result = result
self._meta = _meta
}
public init(id: ID, error: MCPError) {
self.id = id
self.result = .failure(error)
public init(
id: ID,
result: M.Result,
_meta: Metadata? = nil
) {
self.init(id: id, result: .success(result), _meta: _meta)
}
private enum CodingKeys: String, CodingKey {
case jsonrpc, id, result, error
public init(
id: ID,
error: MCPError,
_meta: Metadata? = nil
) {
self.init(id: id, result: .failure(error), _meta: _meta)
}
private enum CodingKeys: String, CodingKey, CaseIterable {
case jsonrpc, id, result, error, _meta
}
public func encode(to encoder: Encoder) throws {
var container = encoder.container(keyedBy: CodingKeys.self)
try container.encode(jsonrpc, forKey: .jsonrpc)
try container.encode(id, forKey: .id)
try container.encodeIfPresent(_meta, forKey: ._meta)
switch result {
case .success(let result):
try container.encode(result, forKey: .result)
@ -241,6 +286,7 @@ public struct Response<M: Method>: Hashable, Identifiable, Codable, Sendable {
codingPath: container.codingPath,
debugDescription: "Invalid response"))
}
_meta = try container.decodeIfPresent(Metadata.self, forKey: ._meta)
}
}
@ -249,18 +295,21 @@ typealias AnyResponse = Response<AnyMethod>
extension AnyResponse {
init<T: Method>(_ response: Response<T>) throws {
// Instead of re-encoding/decoding which might double-wrap the error,
// directly transfer the properties
self.id = response.id
switch response.result {
case .success(let result):
// For success, we still need to convert the result to a Value
let data = try JSONEncoder().encode(result)
let resultValue = try JSONDecoder().decode(Value.self, from: data)
self.result = .success(resultValue)
self = Response<AnyMethod>(
id: response.id,
result: .success(resultValue),
_meta: response._meta
)
case .failure(let error):
// Keep the original error without re-encoding/decoding
self.result = .failure(error)
self = Response<AnyMethod>(
id: response.id,
result: .failure(error),
_meta: response._meta
)
}
}
}

View file

@ -9,16 +9,20 @@ import Logging
import FoundationNetworking
#endif
// MARK: - Timeout Helpers
/// Error thrown when an operation times out
/// An implementation of the MCP Streamable HTTP transport protocol for clients.
///
/// This transport implements the [Streamable HTTP transport](https://modelcontextprotocol.io/specification/2025-03-26/basic/transports#streamable-http)
/// specification from the Model Context Protocol.
/// This transport implements the [Streamable HTTP transport](https://spec.modelcontextprotocol.io/specification/2025-11-25/basic/transports#streamable-http)
/// specification from the Model Context Protocol (version 2025-11-25).
///
/// It supports:
/// - Sending JSON-RPC messages via HTTP POST requests
/// - Receiving responses via both direct JSON responses and SSE streams
/// - Session management using the `Mcp-Session-Id` header
/// - Automatic reconnection for dropped SSE streams
/// - Session management using the `MCP-Session-Id` header
/// - Protocol version negotiation via `MCP-Protocol-Version` header
/// - Automatic reconnection for dropped SSE streams with resumability support
/// - Platform-specific optimizations for different operating systems
///
/// The transport supports two modes:
@ -56,6 +60,10 @@ public actor HTTPClientTransport: Transport {
/// The session ID assigned by the server, used for maintaining state across requests
public private(set) var sessionID: String?
/// The negotiated protocol version to send in MCP-Protocol-Version header
public var protocolVersion: String?
private let streaming: Bool
private var streamingTask: Task<Void, Never>?
@ -75,6 +83,16 @@ public actor HTTPClientTransport: Transport {
private var initialSessionIDSignalTask: Task<Void, Never>?
private var initialSessionIDContinuation: CheckedContinuation<Void, Never>?
/// The last event ID received from the server for SSE stream resumability
private var lastEventID: String?
/// The retry interval (in milliseconds) from the server's SSE `retry:` field
private var retryInterval: Int = 3000 // Default 3000ms per SSE spec
/// The underlying URLSession task for the active GET SSE stream.
/// Used to trigger reconnection when a POST SSE stream closes without delivering data.
private var activeGETSessionTask: URLSessionDataTask?
/// Creates a new HTTP transport client with the specified endpoint
///
/// - Parameters:
@ -82,6 +100,7 @@ public actor HTTPClientTransport: Transport {
/// - configuration: URLSession configuration to use for HTTP requests
/// - streaming: Whether to enable SSE streaming mode (default: true)
/// - sseInitializationTimeout: Maximum time to wait for session ID before proceeding with SSE (default: 10 seconds)
/// - protocolVersion: The MCP protocol version to use (default: "2025-11-25")
/// - requestModifier: Optional closure to customize requests before they are sent (default: no modification)
/// - logger: Optional logger instance for transport events
public init(
@ -89,6 +108,7 @@ public actor HTTPClientTransport: Transport {
configuration: URLSessionConfiguration = .default,
streaming: Bool = true,
sseInitializationTimeout: TimeInterval = 10,
protocolVersion: String = Version.latest,
requestModifier: @escaping (URLRequest) -> URLRequest = { $0 },
logger: Logger? = nil
) {
@ -97,6 +117,7 @@ public actor HTTPClientTransport: Transport {
session: URLSession(configuration: configuration),
streaming: streaming,
sseInitializationTimeout: sseInitializationTimeout,
protocolVersion: protocolVersion,
requestModifier: requestModifier,
logger: logger
)
@ -107,6 +128,7 @@ public actor HTTPClientTransport: Transport {
session: URLSession,
streaming: Bool = false,
sseInitializationTimeout: TimeInterval = 10,
protocolVersion: String = Version.latest,
requestModifier: @escaping (URLRequest) -> URLRequest = { $0 },
logger: Logger? = nil
) {
@ -114,6 +136,7 @@ public actor HTTPClientTransport: Transport {
self.session = session
self.streaming = streaming
self.sseInitializationTimeout = sseInitializationTimeout
self.protocolVersion = protocolVersion
self.requestModifier = requestModifier
// Create message stream
@ -144,7 +167,9 @@ public actor HTTPClientTransport: Transport {
if let continuation = self.initialSessionIDContinuation {
continuation.resume()
self.initialSessionIDContinuation = nil // Consume the continuation
logger.trace("Initial session ID signal triggered for SSE task.")
logger.debug("✓ Initial session ID signal triggered for SSE task")
} else {
logger.debug("✗ No continuation to trigger - signal already consumed or SSE task not waiting")
}
}
@ -202,6 +227,7 @@ public actor HTTPClientTransport: Transport {
/// the response according to the MCP Streamable HTTP specification. It handles:
///
/// - Adding appropriate Accept headers for both JSON and SSE
/// - Including the MCP-Protocol-Version header as required by the specification
/// - Including the session ID in requests if one has been established
/// - Processing different response types (JSON vs SSE)
/// - Handling HTTP error codes according to the specification
@ -219,9 +245,14 @@ public actor HTTPClientTransport: Transport {
request.addValue("application/json", forHTTPHeaderField: "Content-Type")
request.httpBody = data
// Add protocol version header (required by MCP specification 2025-11-25)
if let protocolVersion = protocolVersion {
request.addValue(protocolVersion, forHTTPHeaderField: "MCP-Protocol-Version")
}
// Add session ID if available
if let sessionID = sessionID {
request.addValue(sessionID, forHTTPHeaderField: "Mcp-Session-Id")
request.addValue(sessionID, forHTTPHeaderField: "MCP-Session-Id")
}
// Apply request modifier
@ -249,7 +280,7 @@ public actor HTTPClientTransport: Transport {
let contentType = httpResponse.value(forHTTPHeaderField: "Content-Type") ?? ""
// Extract session ID if present
if let newSessionID = httpResponse.value(forHTTPHeaderField: "Mcp-Session-Id") {
if let newSessionID = httpResponse.value(forHTTPHeaderField: "MCP-Session-Id") {
let wasSessionIDNil = (self.sessionID == nil)
self.sessionID = newSessionID
if wasSessionIDNil {
@ -286,7 +317,7 @@ public actor HTTPClientTransport: Transport {
let contentType = httpResponse.value(forHTTPHeaderField: "Content-Type") ?? ""
// Extract session ID if present
if let newSessionID = httpResponse.value(forHTTPHeaderField: "Mcp-Session-Id") {
if let newSessionID = httpResponse.value(forHTTPHeaderField: "MCP-Session-Id") {
let wasSessionIDNil = (self.sessionID == nil)
self.sessionID = newSessionID
if wasSessionIDNil {
@ -302,7 +333,15 @@ public actor HTTPClientTransport: Transport {
if contentType.contains("text/event-stream") {
// For SSE, processing happens via the stream
logger.trace("Received SSE response, processing in streaming task")
try await self.processSSE(stream)
let hadData = try await self.processSSE(stream)
// If the POST SSE stream closed without delivering a JSON-RPC response,
// trigger GET reconnection so the server can deliver it there.
// This implements standard SSE reconnection behavior per the spec.
if !hadData {
logger.debug("POST SSE stream closed without data, triggering GET reconnection")
self.activeGETSessionTask?.cancel()
}
} else if contentType.contains("application/json") {
// For JSON responses, collect and deliver the data
var buffer = Data()
@ -404,75 +443,96 @@ public actor HTTPClientTransport: Transport {
// This is the original code for platforms that support SSE
guard isConnected else { return }
// Wait for the initial session ID signal, but only if sessionID isn't already set
// Wait for session ID to be available before opening SSE stream
if self.sessionID == nil, let signalTask = self.initialSessionIDSignalTask {
logger.trace("SSE streaming task waiting for initial sessionID signal...")
logger.debug("⏳ Waiting for session ID to be set (timeout: \(self.sseInitializationTimeout)s)...")
// Race the signalTask against a timeout
let timeoutTask = Task {
try? await Task.sleep(for: .seconds(self.sseInitializationTimeout))
return false
}
let signalCompletionTask = Task {
await signalTask.value
return true // Indicates signal received
}
// Use TaskGroup to race the two tasks
var signalReceived = false
let startTime = Date()
let timeout = self.sseInitializationTimeout
do {
signalReceived = try await withThrowingTaskGroup(of: Bool.self) { group in
try await withThrowingTaskGroup { group in
group.addTask {
await signalCompletionTask.value
}
group.addTask {
await timeoutTask.value
try await Task.sleep(for: .seconds(timeout))
}
group.addTask {
await signalTask.value
}
// Take the first result and cancel the other task
if let firstResult = try await group.next() {
group.cancelAll()
return firstResult
}
return false
}
} catch {
logger.error("Error while waiting for session ID signal: \(error)")
logger.warning("⏱️ Timeout waiting for session ID (\(timeout)s). SSE stream will proceed anyway.")
}
// Clean up tasks
timeoutTask.cancel()
if signalReceived {
logger.trace("SSE streaming task proceeding after initial sessionID signal.")
} else {
logger.warning(
"Timeout waiting for initial sessionID signal. SSE stream will proceed (sessionID might be nil)."
)
if self.sessionID != nil {
let elapsed = Date().timeIntervalSince(startTime)
logger.debug("✓ Session ID received after \(Int(elapsed * 1000))ms, proceeding with SSE connection")
}
} else if self.sessionID != nil {
logger.trace(
"Initial sessionID already available. Proceeding with SSE streaming task immediately."
)
} else {
logger.trace(
"Proceeding with SSE connection attempt; sessionID is nil. This might be expected for stateless servers or if initialize hasn't provided one yet."
)
logger.debug("✓ Session ID already available, proceeding with SSE connection immediately")
}
// Retry loop for connection drops
var isFirstAttempt = true
var attemptCount = 0
logger.debug("🔄 Starting SSE retry loop", metadata: [
"isConnected": "\(isConnected)",
"isCancelled": "\(Task.isCancelled)"
])
while isConnected && !Task.isCancelled {
attemptCount += 1
logger.debug("🔄 SSE retry loop iteration", metadata: [
"attempt": "\(attemptCount)",
"isFirstAttempt": "\(isFirstAttempt)"
])
do {
try await connectToEventStream()
// Wait for retry interval before reconnecting (except first attempt)
if !isFirstAttempt {
let delayMs = self.retryInterval
logger.debug("⏳ Waiting before SSE reconnection", metadata: ["retryMs": "\(delayMs)"])
try await Task.sleep(for: .milliseconds(delayMs))
logger.debug("✓ Wait complete, reconnecting now")
}
isFirstAttempt = false
logger.debug("📡 Calling connectToEventStream (attempt #\(attemptCount))")
try await self.connectToEventStream()
// If connectToEventStream() returns without error,
// it means the stream closed gracefully - reconnect with retry interval
logger.info("🔌 SSE stream closed gracefully, will reconnect", metadata: [
"attempt": "\(attemptCount)",
"willRetryAfter": "\(self.retryInterval)ms"
])
} catch {
if !Task.isCancelled {
logger.error("SSE connection error: \(error)")
// Wait before retrying
try? await Task.sleep(for: .seconds(1))
logger.error("❌ SSE connection error (attempt #\(attemptCount)): \(error)")
// Error case - will also use retry interval on next iteration
} else {
logger.debug("⏹️ SSE task cancelled")
}
}
logger.debug("🔄 End of retry loop iteration", metadata: [
"isConnected": "\(isConnected)",
"isCancelled": "\(Task.isCancelled)",
"willContinue": "\(isConnected && !Task.isCancelled)"
])
}
logger.debug("⏹️ SSE retry loop exited", metadata: [
"isConnected": "\(isConnected)",
"isCancelled": "\(Task.isCancelled)",
"totalAttempts": "\(attemptCount)"
])
#endif
}
@ -481,19 +541,38 @@ public actor HTTPClientTransport: Transport {
///
/// This initiates a GET request to the server endpoint with appropriate
/// headers to establish an SSE stream according to the MCP specification.
/// Supports stream resumability via Last-Event-ID header.
///
/// - Throws: MCPError for connection failures or server errors
private func connectToEventStream() async throws {
guard isConnected else { return }
guard isConnected else {
logger.debug("⚠️ Skipping connectToEventStream - transport not connected")
return
}
logger.debug("🔌 Preparing SSE connection request")
var request = URLRequest(url: endpoint)
request.httpMethod = "GET"
request.addValue("text/event-stream", forHTTPHeaderField: "Accept")
request.addValue("no-cache", forHTTPHeaderField: "Cache-Control")
// Add protocol version header (required by MCP specification 2025-11-25)
if let protocolVersion = protocolVersion {
request.addValue(protocolVersion, forHTTPHeaderField: "MCP-Protocol-Version")
}
// Add session ID if available
if let sessionID = sessionID {
request.addValue(sessionID, forHTTPHeaderField: "Mcp-Session-Id")
request.addValue(sessionID, forHTTPHeaderField: "MCP-Session-Id")
}
// Add last event ID for resumability (if available)
if let lastEventID = lastEventID {
request.addValue(lastEventID, forHTTPHeaderField: "Last-Event-ID")
logger.info("→ Resuming SSE stream with Last-Event-ID", metadata: ["lastEventID": "\(lastEventID)"])
} else {
logger.info("→ Connecting to SSE stream (no last event ID to resume from)")
}
// Apply request modifier
@ -503,6 +582,7 @@ public actor HTTPClientTransport: Transport {
// Create URLSession task for SSE
let (stream, response) = try await session.bytes(for: request)
self.activeGETSessionTask = stream.task
guard let httpResponse = response as? HTTPURLResponse else {
throw MCPError.internalError("Invalid HTTP response")
@ -520,7 +600,7 @@ public actor HTTPClientTransport: Transport {
}
// Extract session ID if present
if let newSessionID = httpResponse.value(forHTTPHeaderField: "Mcp-Session-Id") {
if let newSessionID = httpResponse.value(forHTTPHeaderField: "MCP-Session-Id") {
let wasSessionIDNil = (self.sessionID == nil)
self.sessionID = newSessionID
if wasSessionIDNil {
@ -531,36 +611,62 @@ public actor HTTPClientTransport: Transport {
logger.debug("Session ID received", metadata: ["sessionID": "\(newSessionID)"])
}
defer { self.activeGETSessionTask = nil }
try await self.processSSE(stream)
}
/// Processes an SSE byte stream, extracting events and delivering them
/// Processes an SSE byte stream, extracting events and delivering them.
///
/// This method processes Server-Sent Events according to the MCP specification,
/// including support for event IDs for resumability.
///
/// - Parameter stream: The URLSession.AsyncBytes stream to process
/// - Returns: `true` if any data events were received, `false` otherwise.
/// - Throws: Error for stream processing failures
private func processSSE(_ stream: URLSession.AsyncBytes) async throws {
do {
for try await event in stream.events {
// Check if task has been cancelled
if Task.isCancelled { break }
@discardableResult
private func processSSE(_ stream: URLSession.AsyncBytes) async throws -> Bool {
logger.debug("📥 Starting SSE event processing")
var eventCount = 0
var hadDataEvent = false
logger.trace(
"SSE event received",
metadata: [
"type": "\(event.event ?? "message")",
"id": "\(event.id ?? "none")",
]
)
for try await event in stream.events {
eventCount += 1
// Convert the event data to Data and yield it to the message stream
if !event.data.isEmpty, let data = event.data.data(using: .utf8) {
messageContinuation.yield(data)
}
// Check if task has been cancelled
if Task.isCancelled {
logger.debug("⏹️ SSE processing cancelled", metadata: ["eventsProcessed": "\(eventCount)"])
break
}
logger.trace(
"SSE event received",
metadata: [
"type": "\(event.event ?? "message")",
"id": "\(event.id ?? "none")",
]
)
// Store event ID for resumability support
if let eventID = event.id, !eventID.isEmpty {
self.lastEventID = eventID
logger.debug("Stored event ID for resumability", metadata: ["eventID": "\(eventID)"])
}
// Store retry interval if provided by server
if let retry = event.retry {
self.retryInterval = retry
logger.debug("SSE retry interval updated", metadata: ["retryMs": "\(retry)"])
}
// Convert the event data to Data and yield it to the message stream
if !event.data.isEmpty, let data = event.data.data(using: .utf8) {
hadDataEvent = true
messageContinuation.yield(data)
}
} catch {
logger.error("Error processing SSE events: \(error)")
throw error
}
logger.debug("✓ SSE event stream completed", metadata: ["eventsProcessed": "\(eventCount)", "hadData": "\(hadDataEvent)"])
return hadDataEvent
}
#endif
}

View file

@ -0,0 +1,371 @@
import Foundation
// MARK: - Validation Protocol
/// Validates an incoming HTTP request before the transport processes it.
///
/// Validators are composed into a pipeline and executed in order. The first validator
/// that returns a non-nil response short-circuits the pipeline and that error response
/// is returned to the client.
///
/// Conform to this protocol to add custom validation (e.g., authentication):
/// ```swift
/// struct BearerTokenValidator: HTTPRequestValidator {
/// func validate(_ request: HTTPRequest, context: HTTPValidationContext) -> HTTPResponse? {
/// guard let auth = request.header("Authorization"),
/// auth.hasPrefix("Bearer ") else {
/// return .error(statusCode: 401, .invalidRequest("Missing bearer token"))
/// }
/// return nil
/// }
/// }
/// ```
public protocol HTTPRequestValidator: Sendable {
/// Validates the request. Returns an error response if invalid, or `nil` if valid.
func validate(_ request: HTTPRequest, context: HTTPValidationContext) -> HTTPResponse?
}
// MARK: - Validation Context
/// Context provided to validators for making validation decisions.
public struct HTTPValidationContext: Sendable {
/// The HTTP method of the request (GET, POST, DELETE).
public let httpMethod: String
/// The current session ID, if any (nil in stateless mode or before initialization).
public let sessionID: String?
/// Whether the request body contains an `initialize` JSON-RPC request.
public let isInitializationRequest: Bool
/// The set of protocol versions this server supports.
public let supportedProtocolVersions: Set<String>
public init(
httpMethod: String,
sessionID: String? = nil,
isInitializationRequest: Bool = false,
supportedProtocolVersions: Set<String> = Version.supported
) {
self.httpMethod = httpMethod
self.sessionID = sessionID
self.isInitializationRequest = isInitializationRequest
self.supportedProtocolVersions = supportedProtocolVersions
}
}
// MARK: - Accept Header Validator
/// Validates the `Accept` header based on the HTTP method and transport response mode.
///
/// - Stateful (SSE) mode: POST requests must accept both `application/json` and `text/event-stream`
/// - Stateless (JSON) mode: POST requests only need to accept `application/json`
/// - GET requests always require `text/event-stream`
public struct AcceptHeaderValidator: HTTPRequestValidator {
/// The response mode determines which content types are required.
public enum Mode: Sendable {
/// POST requires both `application/json` and `text/event-stream`.
case sseRequired
/// POST only requires `application/json`.
case jsonOnly
}
public let mode: Mode
public init(mode: Mode) {
self.mode = mode
}
public func validate(_ request: HTTPRequest, context: HTTPValidationContext) -> HTTPResponse? {
let accept = request.header(HTTPHeaderName.accept) ?? ""
let acceptTypes = accept.split(separator: ",").map {
$0.trimmingCharacters(in: .whitespaces)
}
let hasJSON = acceptTypes.contains { $0.hasPrefix(ContentType.json) }
let hasSSE = acceptTypes.contains { $0.hasPrefix(ContentType.sse) }
switch context.httpMethod {
case "POST":
switch mode {
case .sseRequired:
guard hasJSON, hasSSE else {
return .error(
statusCode: 406,
.invalidRequest(
"Not Acceptable: Client must accept both application/json and text/event-stream"
),
sessionID: context.sessionID
)
}
case .jsonOnly:
guard hasJSON else {
return .error(
statusCode: 406,
.invalidRequest(
"Not Acceptable: Client must accept application/json"
),
sessionID: context.sessionID
)
}
}
case "GET":
guard hasSSE else {
return .error(
statusCode: 406,
.invalidRequest(
"Not Acceptable: Client must accept text/event-stream"
),
sessionID: context.sessionID
)
}
default:
break
}
return nil
}
}
// MARK: - Content-Type Validator
/// Validates that POST requests have `Content-Type: application/json`.
public struct ContentTypeValidator: HTTPRequestValidator {
public init() {}
public func validate(_ request: HTTPRequest, context: HTTPValidationContext) -> HTTPResponse? {
guard context.httpMethod == "POST" else { return nil }
let contentType = request.header(HTTPHeaderName.contentType) ?? ""
let mainType = contentType.split(separator: ";").first?
.trimmingCharacters(in: .whitespaces) ?? ""
guard mainType == ContentType.json else {
return .error(
statusCode: 415,
.invalidRequest(
"Unsupported Media Type: Content-Type must be application/json"
),
sessionID: context.sessionID
)
}
return nil
}
}
// MARK: - Protocol Version Validator
/// Validates the `MCP-Protocol-Version` header against supported versions.
///
/// Per spec:
/// - If the header is absent, the server assumes the default negotiated version
/// - If the header is present but unsupported, the server returns 400 Bad Request
/// - Initialization requests are exempt (protocol version comes from the request body)
public struct ProtocolVersionValidator: HTTPRequestValidator {
public init() {}
public func validate(_ request: HTTPRequest, context: HTTPValidationContext) -> HTTPResponse? {
// Skip for initialization requests (version is in the body, not the header)
guard !context.isInitializationRequest else { return nil }
// Skip for non-POST methods (GET/DELETE don't carry protocol version)
// Actually, per spec, all subsequent requests should include it
guard let version = request.header(HTTPHeaderName.protocolVersion) else {
// Per spec: if not received, assume default version
return nil
}
guard context.supportedProtocolVersions.contains(version) else {
let supported = context.supportedProtocolVersions.sorted().joined(separator: ", ")
return .error(
statusCode: 400,
.invalidRequest(
"Bad Request: Unsupported protocol version: \(version). Supported: \(supported)"
),
sessionID: context.sessionID
)
}
return nil
}
}
// MARK: - Session Validator
/// Validates the `Mcp-Session-Id` header for stateful transports.
///
/// - Initialization requests are exempt (no session exists yet)
/// - Non-initialization requests must include the session ID header
/// - The session ID must match the active session
public struct SessionValidator: HTTPRequestValidator {
public init() {}
public func validate(_ request: HTTPRequest, context: HTTPValidationContext) -> HTTPResponse? {
// Skip validation for initialization requests
guard !context.isInitializationRequest else { return nil }
// If no session exists yet, skip (server hasn't been initialized)
guard let expectedSessionID = context.sessionID else { return nil }
let requestSessionID = request.header(HTTPHeaderName.sessionID)
guard let requestSessionID else {
return .error(
statusCode: 400,
.invalidRequest("Bad Request: Missing \(HTTPHeaderName.sessionID) header"),
sessionID: expectedSessionID
)
}
guard requestSessionID == expectedSessionID else {
return .error(
statusCode: 404,
.invalidRequest("Not Found: Invalid or expired session ID"),
sessionID: expectedSessionID
)
}
return nil
}
}
// MARK: - Origin Validator
/// DNS rebinding protection: validates `Origin` and `Host` headers.
///
/// Per spec, servers MUST validate the Origin header to prevent DNS rebinding attacks.
/// This is particularly important for servers running on localhost.
///
/// Use `.localhost()` for local development servers.
/// Use `.disabled` to skip validation (e.g., cloud deployments).
/// Use `init(allowedHosts:allowedOrigins:)` for custom configurations.
public struct OriginValidator: HTTPRequestValidator {
public let allowedHosts: [String]
public let allowedOrigins: [String]
private let enabled: Bool
public init(allowedHosts: [String], allowedOrigins: [String]) {
self.allowedHosts = allowedHosts
self.allowedOrigins = allowedOrigins
self.enabled = true
}
private init(disabled: Void) {
self.allowedHosts = []
self.allowedOrigins = []
self.enabled = false
}
/// Protection for localhost-bound servers.
/// Allows requests from `localhost`, `127.0.0.1`, and `[::1]` with the specified port.
public static func localhost(port: Int? = nil) -> OriginValidator {
let portPattern = port.map { String($0) } ?? "*"
return OriginValidator(
allowedHosts: [
"127.0.0.1:\(portPattern)",
"localhost:\(portPattern)",
"[::1]:\(portPattern)",
],
allowedOrigins: [
"http://127.0.0.1:\(portPattern)",
"http://localhost:\(portPattern)",
"http://[::1]:\(portPattern)",
]
)
}
/// Disables DNS rebinding protection.
/// Use for cloud deployments where DNS rebinding is not a threat.
public static var disabled: OriginValidator {
OriginValidator(disabled: ())
}
public func validate(_ request: HTTPRequest, context: HTTPValidationContext) -> HTTPResponse? {
guard enabled else { return nil }
// Validate Host header
if let host = request.header(HTTPHeaderName.host) {
let hostAllowed = allowedHosts.contains { pattern in
matchesPattern(host, pattern: pattern)
}
if !hostAllowed {
return .error(
statusCode: 421,
.invalidRequest("Misdirected Request: Host header not allowed"),
sessionID: context.sessionID
)
}
}
// Validate Origin header (only if present — non-browser clients won't send it)
if let origin = request.header(HTTPHeaderName.origin) {
let originAllowed = allowedOrigins.contains { pattern in
matchesPattern(origin, pattern: pattern)
}
if !originAllowed {
return .error(
statusCode: 403,
.invalidRequest("Forbidden: Origin not allowed"),
sessionID: context.sessionID
)
}
}
return nil
}
/// Matches a value against a pattern that may contain a port wildcard `:*`.
///
/// Examples:
/// - `"localhost:*"` matches `"localhost:8080"`, `"localhost:3000"`
/// - `"http://localhost:*"` matches `"http://localhost:8080"`
/// - `"localhost:8080"` matches only `"localhost:8080"` exactly
private func matchesPattern(_ value: String, pattern: String) -> Bool {
guard pattern.hasSuffix(":*") else {
return value == pattern
}
let prefix = String(pattern.dropLast(2))
guard value.hasPrefix(prefix + ":") else { return false }
let portPart = value.dropFirst(prefix.count + 1)
return !portPart.isEmpty && portPart.allSatisfy(\.isNumber)
}
}
// MARK: - Validation Pipeline Protocol
/// Runs a validation pipeline against an HTTP request.
///
/// Implementations execute a sequence of validators and return the first error,
/// or `nil` if all validations pass.
public protocol HTTPRequestValidationPipeline: Sendable {
/// Validates the request using the configured pipeline.
/// Returns an error response if validation fails, or `nil` if the request is valid.
func validate(_ request: HTTPRequest, context: HTTPValidationContext) -> HTTPResponse?
}
// MARK: - Standard Validation Pipeline
/// Standard implementation of `HTTPRequestValidationPipeline` that runs validators in sequence.
///
/// The first validator that returns a non-nil error response short-circuits the pipeline.
public struct StandardValidationPipeline: HTTPRequestValidationPipeline {
private let validators: [any HTTPRequestValidator]
/// Creates a pipeline with the given validators.
/// Validators are executed in the order provided.
public init(validators: [any HTTPRequestValidator]) {
self.validators = validators
}
public func validate(_ request: HTTPRequest, context: HTTPValidationContext) -> HTTPResponse? {
for validator in validators {
if let errorResponse = validator.validate(request, context: context) {
return errorResponse
}
}
return nil
}
}

View file

@ -0,0 +1,259 @@
import Foundation
// MARK: - Session ID Generator
/// Generates unique session identifiers for stateful HTTP server transports.
///
/// Conform to this protocol to provide custom session ID generation logic.
/// Session IDs **MUST** contain only visible ASCII characters (0x21–0x7E)
/// per the MCP specification.
///
/// A default implementation using UUID is provided via ``UUIDSessionIDGenerator``.
public protocol SessionIDGenerator: Sendable {
/// Generates a new unique session identifier.
func generateSessionID() -> String
}
/// Default session ID generator that produces UUID strings.
///
/// UUID strings consist of hexadecimal characters and hyphens,
/// which are all within the valid ASCII range (0x21–0x7E).
public struct UUIDSessionIDGenerator: SessionIDGenerator {
public init() {}
public func generateSessionID() -> String {
UUID().uuidString
}
}
// MARK: - HTTP Request
/// A framework-agnostic HTTP request representation.
///
/// This type decouples the transport from any specific HTTP framework.
/// The HTTP framework adapter converts its native request type into this before passing to the transport.
public struct HTTPRequest: Sendable {
/// The HTTP method (e.g., "GET", "POST", "DELETE").
public let method: String
/// HTTP headers as key-value pairs.
public let headers: [String: String]
/// The request body data, if any.
public let body: Data?
public init(method: String, headers: [String: String] = [:], body: Data? = nil) {
self.method = method
self.headers = headers
self.body = body
}
/// Case-insensitive header lookup.
public func header(_ name: String) -> String? {
let lowercased = name.lowercased()
return headers.first { $0.key.lowercased() == lowercased }?.value
}
}
// MARK: - HTTP Response
/// A framework-agnostic HTTP response.
///
/// The HTTP framework adapter converts this into its native response type.
///
/// Use computed properties (`statusCode`, `headers`, `bodyData`) for generic access,
/// or switch on the enum for case-specific handling (e.g., streaming):
///
/// ```swift
/// let response = await transport.handleRequest(request)
/// switch response {
/// case .stream(let sseStream, _):
/// // Pipe the async stream to the HTTP response body
/// default:
/// // Use response.bodyData for the body
/// }
/// ```
public enum HTTPResponse: Sendable {
/// 202 Accepted, no body. Used for notifications and client responses.
case accepted(headers: [String: String] = [:])
/// 200 OK, no body. Used for DELETE confirmation.
case ok(headers: [String: String] = [:])
/// 200 OK with data body (typically JSON).
case data(Data, headers: [String: String] = [:])
/// 200 OK with SSE streaming body.
case stream(AsyncThrowingStream<Data, Swift.Error>, headers: [String: String] = [:])
/// Error response with a JSON-RPC error body.
/// The status code, headers, and body are derived automatically.
case error(statusCode: Int, MCPError, sessionID: String? = nil)
// MARK: - Computed Properties
public var statusCode: Int {
switch self {
case .accepted: 202
case .ok, .data, .stream: 200
case .error(let code, _, _): code
}
}
public var headers: [String: String] {
switch self {
case .accepted(let headers), .ok(let headers), .data(_, let headers), .stream(_, let headers):
return headers
case .error(_, _, let sessionID):
var headers: [String: String] = [HTTPHeaderName.contentType: ContentType.json]
if let sessionID { headers[HTTPHeaderName.sessionID] = sessionID }
return headers
}
}
/// The response body as data. `nil` for `.accepted`, `.ok`, and `.stream`.
public var bodyData: Data? {
switch self {
case .accepted, .ok, .stream:
return nil
case .data(let data, _):
return data
case .error(_, let error, _):
let errorBody: [String: Any] = [
"jsonrpc": "2.0",
"error": [
"code": error.code,
"message": error.errorDescription ?? "Unknown error",
],
"id": NSNull(),
]
return try? JSONSerialization.data(withJSONObject: errorBody)
}
}
}
// MARK: - HTTP Header Names
/// Standard header names used by the MCP Streamable HTTP transport.
public enum HTTPHeaderName {
public static let sessionID = "Mcp-Session-Id"
public static let protocolVersion = "Mcp-Protocol-Version"
public static let lastEventID = "Last-Event-Id"
public static let accept = "Accept"
public static let contentType = "Content-Type"
public static let origin = "Origin"
public static let host = "Host"
public static let cacheControl = "Cache-Control"
public static let connection = "Connection"
public static let allow = "Allow"
}
// MARK: - Content Types
enum ContentType {
static let json = "application/json"
static let sse = "text/event-stream"
}
// MARK: - SSE Event
/// A Server-Sent Event (SSE) data structure.
///
/// Formats according to the SSE specification:
/// https://html.spec.whatwg.org/multipage/server-sent-events.html
struct SSEEvent: Sendable {
var id: String?
var event: String?
var data: String
var retry: Int?
/// Formats the event as SSE wire data.
func formatted() -> Data {
var result = ""
if let id {
result += "id: \(id)\n"
}
if let event {
result += "event: \(event)\n"
}
if let retry {
result += "retry: \(retry)\n"
}
result += "data: \(data)\n\n"
return Data(result.utf8)
}
/// Creates a priming event with an empty data field.
/// Per spec, this is sent immediately to prime the client for reconnection.
static func priming(id: String, retry: Int? = nil) -> SSEEvent {
SSEEvent(id: id, event: nil, data: "", retry: retry)
}
/// Creates a message event wrapping JSON-RPC data.
static func message(data: Data, id: String? = nil) -> SSEEvent {
SSEEvent(
id: id,
event: "message",
data: String(decoding: data, as: UTF8.self)
)
}
}
// MARK: - JSON-RPC Message Classification
/// Classifies a raw JSON-RPC message for routing purposes.
///
/// Used by transports to determine where to route outgoing messages:
/// - Responses are routed to the originating request's stream
/// - Notifications and server requests are routed to the standalone GET stream
package enum JSONRPCMessageKind {
case request(id: String, method: String)
case notification(method: String)
case response(id: String)
/// Attempts to classify raw JSON-RPC data.
/// Returns `nil` if the data cannot be parsed or classified.
package init?(data: Data) {
guard let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any] else {
return nil
}
let id = Self.extractID(from: json)
if let method = json["method"] as? String {
if let id {
self = .request(id: id, method: method)
} else {
self = .notification(method: method)
}
} else if json["result"] != nil || json["error"] != nil {
guard let id else { return nil }
self = .response(id: id)
} else {
return nil
}
}
/// Whether this message is a JSON-RPC response (success or error).
var isResponse: Bool {
if case .response = self { return true }
return false
}
/// Whether this message is an `initialize` request.
package var isInitializeRequest: Bool {
if case .request(_, let method) = self {
return method == "initialize"
}
return false
}
private static func extractID(from json: [String: Any]) -> String? {
if let stringID = json["id"] as? String {
return stringID
} else if let intID = json["id"] as? Int {
return String(intID)
}
return nil
}
}

View file

@ -0,0 +1,546 @@
import Foundation
import Logging
/// A stateful HTTP server transport that manages sessions and uses SSE for streaming responses.
///
/// This transport implements the MCP Streamable HTTP specification with full session management:
/// - Assigns a session ID during initialization (via `Mcp-Session-Id` header)
/// - POST requests receive SSE-streamed responses
/// - GET requests establish a standalone SSE stream for server-initiated messages
/// - DELETE requests terminate the session
/// - Built-in event store for resumability (reconnection with `Last-Event-ID`)
///
/// ## Usage
///
/// ```swift
/// let transport = StatefulHTTPServerTransport() // Uses UUID by default
///
/// // Start the MCP server with this transport
/// try await server.start(transport: transport)
///
/// // In your HTTP framework handler:
/// let response = await transport.handleRequest(httpRequest)
/// // Convert response to your framework's response type and return it
/// ```
///
/// ## Framework Integration
///
/// This transport is framework-agnostic. You provide incoming requests as `HTTPRequest`
/// and receive `HTTPResponse` values to convert to your framework's native types.
/// For SSE responses, the `.stream` case provides an `AsyncThrowingStream<Data, Error>`
/// to pipe to the client.
public actor StatefulHTTPServerTransport: Transport {
public nonisolated let logger: Logger
// MARK: - Dependencies
private let sessionIDGenerator: any SessionIDGenerator
private let validationPipeline: any HTTPRequestValidationPipeline
private let retryInterval: Int?
// MARK: - State
private var sessionID: String?
private var terminated = false
private var started = false
// MARK: - Incoming message stream (client → server)
private let incomingStream: AsyncThrowingStream<Data, Swift.Error>
private let incomingContinuation: AsyncThrowingStream<Data, Swift.Error>.Continuation
// MARK: - SSE streams for POST request responses
/// Maps request ID → SSE stream continuation for active POST request streams.
private var requestSSEContinuations: [String: AsyncThrowingStream<Data, Swift.Error>.Continuation] = [:]
// MARK: - Standalone GET SSE stream
/// The standalone SSE stream continuation for server-initiated messages.
/// Only one GET stream is allowed per session.
private var standaloneSSEContinuation: AsyncThrowingStream<Data, Swift.Error>.Continuation?
/// Internal identifier for the standalone GET stream in the event store.
private let standaloneStreamID = "_GET_stream"
// MARK: - Event Store (Resumability)
private struct StoredEvent {
let streamID: String
let eventID: String
let message: Data?
}
private var storedEvents: [StoredEvent] = []
private var eventCounter: Int = 0
// MARK: - Init
/// Creates a new stateful HTTP server transport.
///
/// - Parameters:
/// - sessionIDGenerator: Generator for session IDs. The IDs MUST contain
/// only visible ASCII characters (0x21-0x7E) per the MCP specification.
/// Defaults to ``UUIDSessionIDGenerator``.
/// - validationPipeline: Custom validation pipeline. If `nil`, uses sensible defaults:
/// origin validation (localhost), Accept header (SSE required), Content-Type,
/// protocol version, and session validation.
/// - retryInterval: Retry interval in milliseconds for SSE priming events.
/// Controls how long clients wait before attempting to reconnect.
/// - logger: Optional logger. If `nil`, a no-op logger is used.
public init(
sessionIDGenerator: any SessionIDGenerator = UUIDSessionIDGenerator(),
validationPipeline: (any HTTPRequestValidationPipeline)? = nil,
retryInterval: Int? = nil,
logger: Logger? = nil
) {
self.sessionIDGenerator = sessionIDGenerator
self.validationPipeline = validationPipeline ?? StandardValidationPipeline(validators: [
OriginValidator.localhost(),
AcceptHeaderValidator(mode: .sseRequired),
ContentTypeValidator(),
ProtocolVersionValidator(),
SessionValidator(),
])
self.retryInterval = retryInterval
self.logger = logger ?? Logger(
label: "mcp.transport.http.server.stateful",
factory: { _ in SwiftLogNoOpLogHandler() }
)
let (stream, continuation) = AsyncThrowingStream<Data, Swift.Error>.makeStream()
self.incomingStream = stream
self.incomingContinuation = continuation
}
// MARK: - Transport Conformance
public func connect() async throws {
guard !started else {
throw MCPError.internalError("Transport already started")
}
started = true
logger.debug("Stateful HTTP server transport started")
}
public func disconnect() async {
terminate()
}
/// Routes outgoing server messages to the appropriate client connection.
///
/// - Responses are routed to the SSE stream matching the response's JSON-RPC ID.
/// - Notifications and server-initiated requests are routed to the standalone GET stream.
public func send(_ data: Data) async throws {
guard !terminated else {
throw MCPError.connectionClosed
}
guard let kind = JSONRPCMessageKind(data: data) else {
logger.warning("Could not classify outgoing message for routing")
return
}
switch kind {
case .response(let id):
routeResponse(data, requestID: id)
case .notification, .request:
routeServerInitiatedMessage(data)
}
}
public func receive() -> AsyncThrowingStream<Data, Swift.Error> {
incomingStream
}
// MARK: - HTTP Request Handler
/// Handles an incoming HTTP request from the framework adapter.
///
/// Routes by HTTP method:
/// - **POST**: JSON-RPC messages (requests, notifications)
/// - **GET**: Establish standalone SSE stream for server-initiated messages
/// - **DELETE**: Terminate the session
/// - Others: 405 Method Not Allowed
public func handleRequest(_ request: HTTPRequest) async -> HTTPResponse {
if terminated {
return .error(
statusCode: 404,
.invalidRequest("Not Found: Session has been terminated"),
sessionID: sessionID
)
}
switch request.method.uppercased() {
case "POST":
return handlePost(request)
case "GET":
return handleGet(request)
case "DELETE":
return handleDelete(request)
default:
return .error(
statusCode: 405,
.invalidRequest("Method Not Allowed"),
sessionID: sessionID
)
}
}
// MARK: - POST Handler
private func handlePost(_ request: HTTPRequest) -> HTTPResponse {
// Parse body first so we can determine if it's an initialization request
guard let body = request.body, !body.isEmpty else {
return .error(
statusCode: 400,
.parseError("Empty request body"),
sessionID: sessionID
)
}
guard let messageKind = JSONRPCMessageKind(data: body) else {
return .error(
statusCode: 400,
.parseError("Invalid JSON-RPC message"),
sessionID: sessionID
)
}
// Build validation context
let context = HTTPValidationContext(
httpMethod: "POST",
sessionID: sessionID,
isInitializationRequest: messageKind.isInitializeRequest,
supportedProtocolVersions: Version.supported
)
// Run validation pipeline
if let errorResponse = validationPipeline.validate(request, context: context) {
return errorResponse
}
// Handle initialization request specially
if messageKind.isInitializeRequest {
return handleInitializationRequest(body, request: request)
}
// Handle by message type
switch messageKind {
case .notification, .response:
// Yield to server and return 202 Accepted
incomingContinuation.yield(body)
return .accepted(headers: sessionHeaders())
case .request(let id, _):
return handleJSONRPCRequest(body, requestID: id, request: request)
}
}
private func handleInitializationRequest(_ body: Data, request: HTTPRequest) -> HTTPResponse {
// Generate session ID
let newSessionID = sessionIDGenerator.generateSessionID()
// Validate session ID contains only visible ASCII (0x21-0x7E)
guard isValidSessionID(newSessionID) else {
logger.error("Generated session ID contains invalid characters")
return .error(
statusCode: 500,
.internalError("Internal error: Invalid session ID generated")
)
}
self.sessionID = newSessionID
logger.info("Session initialized", metadata: ["sessionID": "\(newSessionID)"])
// Extract request ID for routing the response
guard case .request(let requestID, _) = JSONRPCMessageKind(data: body) else {
return .error(
statusCode: 400,
.parseError("Invalid initialize request"),
sessionID: newSessionID
)
}
// For the initialize request, use SSE streaming like any other request
return handleJSONRPCRequest(body, requestID: requestID, request: request)
}
private func handleJSONRPCRequest(_ body: Data, requestID: String, request: HTTPRequest) -> HTTPResponse {
// Create SSE stream for this request
let (sseStream, sseContinuation) = AsyncThrowingStream<Data, Swift.Error>.makeStream()
requestSSEContinuations[requestID] = sseContinuation
// Extract protocol version for priming event decision
let protocolVersion = extractProtocolVersion(from: body, request: request)
// Send priming event for resumability
sendPrimingEvent(
streamID: requestID,
continuation: sseContinuation,
protocolVersion: protocolVersion
)
// Yield the incoming message to the server
incomingContinuation.yield(body)
// Build response headers
var headers = sessionHeaders()
headers[HTTPHeaderName.contentType] = ContentType.sse
headers[HTTPHeaderName.cacheControl] = "no-cache, no-transform"
headers[HTTPHeaderName.connection] = "keep-alive"
return .stream(sseStream, headers: headers)
}
// MARK: - GET Handler
private func handleGet(_ request: HTTPRequest) -> HTTPResponse {
// Build validation context (GET is never an initialization request)
let context = HTTPValidationContext(
httpMethod: "GET",
sessionID: sessionID,
isInitializationRequest: false,
supportedProtocolVersions: Version.supported
)
// Run validation pipeline
if let errorResponse = validationPipeline.validate(request, context: context) {
return errorResponse
}
// Handle resumability: check for Last-Event-ID header
if let lastEventID = request.header(HTTPHeaderName.lastEventID) {
return handleResumeRequest(lastEventID: lastEventID, request: request)
}
// Only one standalone GET stream per session
guard standaloneSSEContinuation == nil else {
return .error(
statusCode: 409,
.invalidRequest("Conflict: Only one SSE stream is allowed per session"),
sessionID: sessionID
)
}
// Create standalone SSE stream
let (sseStream, sseContinuation) = AsyncThrowingStream<Data, Swift.Error>.makeStream()
standaloneSSEContinuation = sseContinuation
// Extract protocol version for priming event
let protocolVersion = request.header(HTTPHeaderName.protocolVersion) ?? Version.latest
// Send priming event
sendPrimingEvent(
streamID: standaloneStreamID,
continuation: sseContinuation,
protocolVersion: protocolVersion
)
// Build response headers
var headers = sessionHeaders()
headers[HTTPHeaderName.contentType] = ContentType.sse
headers[HTTPHeaderName.cacheControl] = "no-cache, no-transform"
headers[HTTPHeaderName.connection] = "keep-alive"
return .stream(sseStream, headers: headers)
}
// MARK: - DELETE Handler
private func handleDelete(_ request: HTTPRequest) -> HTTPResponse {
// Validate session
let context = HTTPValidationContext(
httpMethod: "DELETE",
sessionID: sessionID,
isInitializationRequest: false,
supportedProtocolVersions: Version.supported
)
// Only run session validation for DELETE (not all validators)
let sessionValidator = SessionValidator()
if let errorResponse = sessionValidator.validate(request, context: context) {
return errorResponse
}
terminate()
return .ok(headers: sessionHeaders())
}
// MARK: - Message Routing
/// Routes a message to a specific request's SSE stream without closing it.
/// Used for server-initiated messages during request handling.
private func routeToRequestStream(_ data: Data, requestID: String) {
let eventID = storeEvent(streamID: requestID, message: data)
guard let continuation = requestSSEContinuations[requestID] else {
logger.debug(
"No active stream for request, message stored for replay",
metadata: ["requestID": "\(requestID)"]
)
return
}
// Format as SSE and yield (but don't close the stream)
let sseEvent = SSEEvent.message(data: data, id: eventID)
continuation.yield(sseEvent.formatted())
}
/// Routes a response to a specific request's SSE stream and closes it.
/// Used for final responses to client requests.
private func routeResponse(_ data: Data, requestID: String) {
routeToRequestStream(data, requestID: requestID)
// Response means the request is complete — close the stream
if let continuation = requestSSEContinuations[requestID] {
continuation.finish()
requestSSEContinuations.removeValue(forKey: requestID)
}
}
private func routeServerInitiatedMessage(_ data: Data) {
let eventID = storeEvent(streamID: standaloneStreamID, message: data)
guard let continuation = standaloneSSEContinuation else {
logger.debug("No standalone GET stream connected, message stored for replay")
return
}
let sseEvent = SSEEvent.message(data: data, id: eventID)
continuation.yield(sseEvent.formatted())
}
// MARK: - Resumability
private func handleResumeRequest(lastEventID: String, request: HTTPRequest) -> HTTPResponse {
guard let replay = replayEventsAfter(lastEventID) else {
return .error(
statusCode: 400,
.invalidRequest("Invalid Last-Event-ID"),
sessionID: sessionID
)
}
let (sseStream, sseContinuation) = AsyncThrowingStream<Data, Swift.Error>.makeStream()
// Replay stored events
for (eventID, message) in replay.events {
let sseEvent = SSEEvent.message(data: message, id: eventID)
sseContinuation.yield(sseEvent.formatted())
}
// Re-register the stream for future messages
if replay.streamID == standaloneStreamID {
standaloneSSEContinuation = sseContinuation
} else {
requestSSEContinuations[replay.streamID] = sseContinuation
}
// Send a new priming event so the client can resume again if disconnected
let protocolVersion = request.header(HTTPHeaderName.protocolVersion) ?? Version.latest
sendPrimingEvent(
streamID: replay.streamID,
continuation: sseContinuation,
protocolVersion: protocolVersion
)
var headers = sessionHeaders()
headers[HTTPHeaderName.contentType] = ContentType.sse
headers[HTTPHeaderName.cacheControl] = "no-cache, no-transform"
headers[HTTPHeaderName.connection] = "keep-alive"
return .stream(sseStream, headers: headers)
}
// MARK: - Internal Event Store
private func storeEvent(streamID: String, message: Data?) -> String {
eventCounter += 1
let eventID = "\(streamID)_\(eventCounter)"
storedEvents.append(StoredEvent(streamID: streamID, eventID: eventID, message: message))
return eventID
}
private func replayEventsAfter(_ lastEventID: String) -> (streamID: String, events: [(eventID: String, message: Data)])? {
guard let index = storedEvents.firstIndex(where: { $0.eventID == lastEventID }) else {
return nil
}
let streamID = storedEvents[index].streamID
let eventsToReplay = storedEvents[(index + 1)...]
.filter { $0.streamID == streamID && $0.message != nil }
.map { (eventID: $0.eventID, message: $0.message!) }
return (streamID, eventsToReplay)
}
// MARK: - SSE Helpers
private func sendPrimingEvent(
streamID: String,
continuation: AsyncThrowingStream<Data, Swift.Error>.Continuation,
protocolVersion: String
) {
// Priming events with empty data are only safe for clients >= 2025-11-25
guard protocolVersion >= "2025-11-25" else { return }
let primingEventID = storeEvent(streamID: streamID, message: nil)
let primingEvent = SSEEvent.priming(id: primingEventID, retry: retryInterval)
continuation.yield(primingEvent.formatted())
}
// MARK: - Session Helpers
private func sessionHeaders() -> [String: String] {
var headers: [String: String] = [:]
if let sessionID {
headers[HTTPHeaderName.sessionID] = sessionID
}
return headers
}
private func isValidSessionID(_ id: String) -> Bool {
guard !id.isEmpty else { return false }
return id.utf8.allSatisfy { $0 >= 0x21 && $0 <= 0x7E }
}
private func extractProtocolVersion(from body: Data, request: HTTPRequest) -> String {
// For initialize requests, extract from the request body params
if let json = try? JSONSerialization.jsonObject(with: body) as? [String: Any],
let method = json["method"] as? String, method == "initialize",
let params = json["params"] as? [String: Any],
let version = params["protocolVersion"] as? String
{
return version
}
// For other requests, use the header
return request.header(HTTPHeaderName.protocolVersion) ?? Version.latest
}
// MARK: - Termination
/// Terminates the session, closing all active streams.
/// After termination, all requests receive 404 Not Found.
private func terminate() {
guard !terminated else { return }
terminated = true
logger.info("Terminating session", metadata: ["sessionID": "\(sessionID ?? "none")"])
// Close all request SSE streams
for (_, continuation) in requestSSEContinuations {
continuation.finish()
}
requestSSEContinuations.removeAll()
// Close standalone GET stream
standaloneSSEContinuation?.finish()
standaloneSSEContinuation = nil
// Clear stored events
storedEvents.removeAll()
// Close incoming stream
incomingContinuation.finish()
}
}

View file

@ -0,0 +1,251 @@
import Foundation
import Logging
/// A stateless HTTP server transport that returns single JSON responses.
///
/// This transport implements a minimal subset of the MCP Streamable HTTP specification:
/// - No session management (no `Mcp-Session-Id` header)
/// - POST requests receive direct JSON responses (no SSE streaming)
/// - GET and DELETE requests return 405 Method Not Allowed
///
/// ## Usage
///
/// ```swift
/// let transport = StatelessHTTPServerTransport()
///
/// // Start the MCP server with this transport
/// try await server.start(transport: transport)
///
/// // In your HTTP framework handler:
/// let response = await transport.handleRequest(httpRequest)
/// // Convert response to your framework's response type and return it
/// ```
///
/// ## When to Use
///
/// Use this transport when:
/// - You don't need server-initiated messages (no GET SSE stream)
/// - You want simple request-response semantics
/// - Session management is handled externally or not needed
///
/// For full streaming and session support, use ``StatefulHTTPServerTransport`` instead.
public actor StatelessHTTPServerTransport: Transport {
public nonisolated let logger: Logger
// MARK: - Dependencies
private let validationPipeline: any HTTPRequestValidationPipeline
// MARK: - State
private var terminated = false
private var started = false
// MARK: - Incoming message stream (client → server)
private let incomingStream: AsyncThrowingStream<Data, Swift.Error>
private let incomingContinuation: AsyncThrowingStream<Data, Swift.Error>.Continuation
// MARK: - Response waiters
/// Maps request ID → continuation waiting for the server's response.
/// When the server calls `send()` with a response, the matching continuation is resumed.
private var responseWaiters: [String: CheckedContinuation<Data, any Error>] = [:]
// MARK: - Init
/// Creates a new stateless HTTP server transport.
///
/// - Parameters:
/// - validationPipeline: Custom validation pipeline. If `nil`, uses sensible defaults:
/// origin validation (localhost), Accept header (JSON only), Content-Type,
/// and protocol version validation.
/// - logger: Optional logger. If `nil`, a no-op logger is used.
public init(
validationPipeline: (any HTTPRequestValidationPipeline)? = nil,
logger: Logger? = nil
) {
self.validationPipeline = validationPipeline ?? StandardValidationPipeline(validators: [
OriginValidator.localhost(),
AcceptHeaderValidator(mode: .jsonOnly),
ContentTypeValidator(),
ProtocolVersionValidator(),
])
self.logger = logger ?? Logger(
label: "mcp.transport.http.server.stateless",
factory: { _ in SwiftLogNoOpLogHandler() }
)
let (stream, continuation) = AsyncThrowingStream<Data, Swift.Error>.makeStream()
self.incomingStream = stream
self.incomingContinuation = continuation
}
// MARK: - Transport Conformance
public func connect() async throws {
guard !started else {
throw MCPError.internalError("Transport already started")
}
started = true
logger.debug("Stateless HTTP server transport started")
}
public func disconnect() async {
await terminate()
}
/// Routes outgoing server messages to the appropriate waiting HTTP handler.
///
/// - Responses are matched by JSON-RPC ID and delivered to the waiting `handleRequest` call.
/// - Notifications and server-initiated requests are logged and dropped
/// (no streaming channel available in stateless mode).
public func send(_ data: Data) async throws {
guard !terminated else {
throw MCPError.connectionClosed
}
guard let kind = JSONRPCMessageKind(data: data) else {
logger.warning("Could not classify outgoing message for routing")
return
}
switch kind {
case .response(let id):
guard let continuation = responseWaiters.removeValue(forKey: id) else {
logger.debug(
"No waiter for response, may have timed out",
metadata: ["requestID": "\(id)"]
)
return
}
continuation.resume(returning: data)
case .notification(let method):
logger.debug(
"Server-initiated notification dropped in stateless mode (no GET SSE stream)",
metadata: ["method": "\(method)"]
)
case .request(_, let method):
logger.debug(
"Server-initiated request dropped in stateless mode (no GET SSE stream)",
metadata: ["method": "\(method)"]
)
}
}
public func receive() -> AsyncThrowingStream<Data, Swift.Error> {
incomingStream
}
// MARK: - HTTP Request Handler
/// Handles an incoming HTTP request from the framework adapter.
///
/// Only POST is supported:
/// - **POST**: JSON-RPC messages (requests, notifications)
/// - **GET**: 405 Method Not Allowed
/// - **DELETE**: 405 Method Not Allowed
/// - Others: 405 Method Not Allowed
public func handleRequest(_ request: HTTPRequest) async -> HTTPResponse {
if terminated {
return .error(
statusCode: 404,
.invalidRequest("Not Found: Transport has been terminated")
)
}
switch request.method.uppercased() {
case "POST":
return await handlePost(request)
default:
return .error(
statusCode: 405,
.invalidRequest("Method Not Allowed")
)
}
}
// MARK: - POST Handler
private func handlePost(_ request: HTTPRequest) async -> HTTPResponse {
// Parse body first to determine message type
guard let body = request.body, !body.isEmpty else {
return .error(
statusCode: 400,
.parseError("Empty request body")
)
}
guard let messageKind = JSONRPCMessageKind(data: body) else {
return .error(
statusCode: 400,
.parseError("Invalid JSON-RPC message")
)
}
// Build validation context
let context = HTTPValidationContext(
httpMethod: "POST",
sessionID: nil,
isInitializationRequest: messageKind.isInitializeRequest,
supportedProtocolVersions: Version.supported
)
// Run validation pipeline
if let errorResponse = validationPipeline.validate(request, context: context) {
return errorResponse
}
// Handle by message type
switch messageKind {
case .notification, .response:
// Yield to server and return 202 Accepted
incomingContinuation.yield(body)
return .accepted()
case .request(let id, _):
return await handleJSONRPCRequest(body, requestID: id)
}
}
private func handleJSONRPCRequest(_ body: Data, requestID: String) async -> HTTPResponse {
// Yield the incoming message to the server
incomingContinuation.yield(body)
// Wait for the server to process and send a response
let responseData: Data
do {
responseData = try await withCheckedThrowingContinuation { continuation in
responseWaiters[requestID] = continuation
}
} catch {
return .error(
statusCode: 500,
.internalError("Error processing request: \(error.localizedDescription)")
)
}
return .data(responseData, headers: [HTTPHeaderName.contentType: ContentType.json])
}
// MARK: - Termination
private func terminate() async {
guard !terminated else { return }
terminated = true
logger.debug("Stateless HTTP server transport terminated")
// Cancel all waiting continuations
for (id, continuation) in responseWaiters {
continuation.resume(throwing: MCPError.connectionClosed)
logger.debug("Cancelled waiter for request", metadata: ["requestID": "\(id)"])
}
responseWaiters.removeAll()
// Close incoming stream
incomingContinuation.finish()
}
}

View file

@ -242,9 +242,6 @@ import Logging
private let messageStream: AsyncThrowingStream<Data, Swift.Error>
private let messageContinuation: AsyncThrowingStream<Data, Swift.Error>.Continuation
// Track connection state for continuations
private var connectionContinuationResumed = false
// Connection is marked nonisolated(unsafe) to allow access from closures
private nonisolated(unsafe) var connection: NetworkConnectionProtocol
@ -317,67 +314,101 @@ import Logging
isStopping = false
reconnectAttempt = 0
// Reset continuation state
connectionContinuationResumed = false
// Retry loop with exponential backoff
while !isConnected && reconnectAttempt <= reconnectionConfig.maxAttempts {
do {
try await attemptConnection()
return // Success
} catch {
guard !isStopping,
reconnectionConfig.enabled,
reconnectAttempt < reconnectionConfig.maxAttempts
else {
throw error
}
// Wait for connection to be ready
try await withCheckedThrowingContinuation {
[weak self] (continuation: CheckedContinuation<Void, Swift.Error>) in
guard let self = self else {
continuation.resume(throwing: MCPError.internalError("Transport deallocated"))
return
// Schedule retry with backoff
reconnectAttempt += 1
let delay = reconnectionConfig.backoffDelay(for: reconnectAttempt)
logger.debug(
"Attempting reconnection (\(reconnectAttempt)/\(reconnectionConfig.maxAttempts))..."
)
try await Task.sleep(for: .seconds(delay))
connection.cancel()
}
}
throw MCPError.internalError("Failed to connect after \(reconnectAttempt) attempts")
}
/// Attempts a single connection
///
/// Creates a stream for connection state changes and waits for a terminal state.
///
/// - Throws: Error if the connection fails
private func attemptConnection() async throws {
// Create stream for state changes with proper cleanup
let stateStream = AsyncStream<NWConnection.State> { continuation in
continuation.onTermination = { [weak self] _ in
self?.connection.stateUpdateHandler = nil
}
connection.stateUpdateHandler = { [weak self] state in
guard let self = self else { return }
Task { @MainActor in
switch state {
case .ready:
await self.handleConnectionReady(continuation: continuation)
case .failed(let error):
await self.handleConnectionFailed(
error: error, continuation: continuation)
case .cancelled:
await self.handleConnectionCancelled(continuation: continuation)
case .waiting(let error):
self.logger.debug("Connection waiting: \(error)")
case .preparing:
self.logger.debug("Connection preparing...")
case .setup:
self.logger.debug("Connection setup...")
@unknown default:
self.logger.warning("Unknown connection state")
}
connection.stateUpdateHandler = { state in
continuation.yield(state)
switch state {
case .ready, .failed, .cancelled:
continuation.finish()
default:
break
}
}
}
connection.start(queue: .main)
connection.start(queue: .main)
// Process states until terminal state
for await state in stateStream {
switch state {
case .ready:
await handleConnectionReady()
return // Success - exits loop and stream
case .failed(let error):
logger.error("Connection failed: \(error)")
throw error // Exits loop and stream
case .cancelled:
logger.warning("Connection cancelled")
throw MCPError.internalError("Connection cancelled")
case .waiting(let error):
logger.debug("Connection waiting: \(error)")
case .preparing:
logger.debug("Connection preparing...")
case .setup:
logger.debug("Connection setup...")
@unknown default:
logger.warning("Unknown connection state")
}
}
}
/// Handles when the connection reaches the ready state
///
/// - Parameter continuation: The continuation to resume when connection is ready
private func handleConnectionReady(continuation: CheckedContinuation<Void, Swift.Error>)
async
{
if !connectionContinuationResumed {
connectionContinuationResumed = true
isConnected = true
private func handleConnectionReady() async {
isConnected = true
reconnectAttempt = 0
logger.debug("Network transport connected successfully")
// Reset reconnect attempt counter on successful connection
reconnectAttempt = 0
logger.debug("Network transport connected successfully")
continuation.resume()
// Start the receive loop after connection is established
Task { await self.receiveLoop() }
// Start the receive loop after connection is established
Task { await self.receiveLoop() }
// Start heartbeat task if enabled
if heartbeatConfig.enabled {
startHeartbeat()
}
// Start heartbeat task if enabled
if heartbeatConfig.enabled {
startHeartbeat()
}
}
@ -443,86 +474,6 @@ import Logging
logger.trace("Heartbeat sent")
}
/// Handles connection failure
///
/// - Parameters:
/// - error: The error that caused the connection to fail
/// - continuation: The continuation to resume with the error
private func handleConnectionFailed(
error: Swift.Error, continuation: CheckedContinuation<Void, Swift.Error>
) async {
if !connectionContinuationResumed {
connectionContinuationResumed = true
logger.error("Connection failed: \(error)")
await handleReconnection(
error: error,
continuation: continuation,
context: "failure"
)
}
}
/// Handles connection cancellation
///
/// - Parameter continuation: The continuation to resume with cancellation error
private func handleConnectionCancelled(continuation: CheckedContinuation<Void, Swift.Error>)
async
{
if !connectionContinuationResumed {
connectionContinuationResumed = true
logger.warning("Connection cancelled")
await handleReconnection(
error: MCPError.internalError("Connection cancelled"),
continuation: continuation,
context: "cancellation"
)
}
}
/// Common reconnection handling logic
///
/// - Parameters:
/// - error: The error that triggered the reconnection
/// - continuation: The continuation to resume with the error
/// - context: The context of the reconnection (for logging)
private func handleReconnection(
error: Swift.Error,
continuation: CheckedContinuation<Void, Swift.Error>,
context: String
) async {
if !isStopping,
reconnectionConfig.enabled,
reconnectAttempt < reconnectionConfig.maxAttempts
{
// Try to reconnect with exponential backoff
reconnectAttempt += 1
logger.debug(
"Attempting reconnection after \(context) (\(reconnectAttempt)/\(reconnectionConfig.maxAttempts))..."
)
// Calculate backoff delay
let delay = reconnectionConfig.backoffDelay(for: reconnectAttempt)
// Schedule reconnection attempt after delay
Task {
try? await Task.sleep(for: .seconds(delay))
if !isStopping {
// Cancel the current connection before attempting to reconnect.
self.connection.cancel()
// Resume original continuation with error; outer logic or a new call to connect() will handle retry.
continuation.resume(throwing: error)
} else {
continuation.resume(throwing: error) // Stopping, so fail.
}
}
} else {
// Not configured to reconnect, exceeded max attempts, or stopping
self.connection.cancel() // Ensure connection is cancelled
continuation.resume(throwing: error)
}
}
/// Disconnects from the transport
///

View file

@ -20,7 +20,7 @@ import struct Foundation.Data
#if canImport(Darwin) || canImport(Glibc) || canImport(Musl)
/// An implementation of the MCP stdio transport protocol.
///
/// This transport implements the [stdio transport](https://modelcontextprotocol.io/specification/2025-03-26/basic/transports#stdio)
/// This transport implements the [stdio transport](https://modelcontextprotocol.io/specification/2025-06-18/basic/transports#stdio)
/// specification from the Model Context Protocol.
///
/// The stdio transport works by:

View file

@ -0,0 +1,29 @@
import Foundation
/// The Model Context Protocol supports cancellation of requests through
/// notification messages. Either side can send a cancellation notification
/// to indicate that a previously-issued request should be cancelled.
///
/// Cancellation is advisory: the receiver should make a best-effort attempt
/// to cancel the operation, but may not always be able to do so.
///
/// - SeeAlso: https://modelcontextprotocol.io/specification/2025-11-25/basic/utilities/cancellation
public struct CancelledNotification: Notification {
public static let name: String = "notifications/cancelled"
public struct Parameters: Hashable, Codable, Sendable {
/// The ID of the request to cancel.
///
/// This MUST correspond to the ID of a request previously issued
/// in the same direction.
public let requestId: ID
/// An optional human-readable reason for the cancellation.
public let reason: String?
public init(requestId: ID, reason: String? = nil) {
self.requestId = requestId
self.reason = reason
}
}
}

View file

@ -0,0 +1,153 @@
import Foundation
/// Progress notifications are used to report progress on long-running operations.
///
/// The sender (either client or server) that issued the original request may
/// include a progress token in the request's `_meta` field. If the receiver
/// supports progress reporting, it can send progress notifications
/// containing that token to indicate how the operation is proceeding.
///
/// - SeeAlso: https://modelcontextprotocol.io/specification/2025-11-25/basic/utilities/progress
public struct ProgressNotification: Notification {
public static let name: String = "notifications/progress"
public struct Parameters: Hashable, Codable, Sendable {
/// The progress token from the original request.
///
/// This is used to associate the progress notification with its
/// originating request.
public let progressToken: ProgressToken
/// The current progress value.
///
/// This should increase monotonically as the operation proceeds.
/// It represents the amount of work completed so far.
public let progress: Double
/// The total expected progress value, if known.
///
/// When provided, `progress / total` can be used to calculate
/// a percentage completion.
public let total: Double?
/// An optional human-readable message describing the current progress.
public let message: String?
public init(
progressToken: ProgressToken,
progress: Double,
total: Double? = nil,
message: String? = nil
) {
self.progressToken = progressToken
self.progress = progress
self.total = total
self.message = message
}
}
}
/// A token used to associate progress notifications with their originating request.
///
/// Progress tokens can be either strings or integers.
/// Progress tokens MUST be unique across all active requests.
public enum ProgressToken: Hashable, Codable, Sendable {
case string(String)
case integer(Int)
public init(from decoder: Decoder) throws {
let container = try decoder.singleValueContainer()
if let stringValue = try? container.decode(String.self) {
self = .string(stringValue)
} else if let intValue = try? container.decode(Int.self) {
self = .integer(intValue)
} else {
throw DecodingError.dataCorruptedError(
in: container,
debugDescription: "Progress token must be a string or integer"
)
}
}
public func encode(to encoder: Encoder) throws {
var container = encoder.singleValueContainer()
switch self {
case .string(let value):
try container.encode(value)
case .integer(let value):
try container.encode(value)
}
}
/// Creates a unique progress token using UUID.
public static func unique() -> ProgressToken {
.string(UUID().uuidString)
}
}
/// Metadata that can be included in request parameters.
///
/// This structure represents the `_meta` field in MCP request parameters,
/// which can contain a progress token for receiving progress notifications.
///
/// - SeeAlso: https://modelcontextprotocol.io/specification/2025-11-25/basic/utilities/progress
public struct Metadata: Hashable, Codable, Sendable {
public subscript(_ key: String) -> Value? {
fields[key]
}
/// The underlying fields dictionary.
public var fields: [String: Value]
/// The progress token for receiving progress notifications.
///
/// If specified, the caller is requesting out-of-band progress notifications
/// for this request. The value of this parameter is an opaque token that will
/// be attached to any subsequent notifications. The receiver is not obligated
/// to provide these notifications.
public var progressToken: ProgressToken? {
get {
guard let value = fields["progressToken"] else { return nil }
if let stringValue = value.stringValue {
return .string(stringValue)
} else if let intValue = value.intValue {
return .integer(intValue)
}
return nil
}
set {
if let token = newValue {
switch token {
case .string(let s):
fields["progressToken"] = .string(s)
case .integer(let i):
fields["progressToken"] = .int(i)
}
} else {
fields.removeValue(forKey: "progressToken")
}
}
}
/// Creates request metadata.
///
/// - Parameters:
/// - progressToken: Optional progress token for receiving progress notifications.
/// If specified, the caller is requesting out-of-band progress notifications for this request.
/// - additionalFields: Optional dictionary of additional metadata fields.
/// These fields will be included in the `_meta` object alongside the progress token.
public init(progressToken: ProgressToken? = nil, additionalFields: [String: Value] = [:]) {
self.fields = additionalFields
self.progressToken = progressToken
}
public init(from decoder: Decoder) throws {
let container = try decoder.singleValueContainer()
self.fields = try container.decode([String: Value].self)
}
public func encode(to encoder: Encoder) throws {
var container = encoder.singleValueContainer()
try container.encode(fields)
}
}

View file

@ -0,0 +1,34 @@
import Foundation
/// A context object that wraps a pending request, providing both the request ID
/// and a Task handle for the asynchronous operation.
///
/// This allows you to track and cancel in-flight requests by sending
/// a CancelledNotification to the receiver (client or server).
///
/// - SeeAlso: https://modelcontextprotocol.io/specification/2025-11-25/basic/utilities/cancellation
public struct RequestContext<Output: Sendable & Decodable>: Sendable {
/// The unique identifier for this request.
public let requestID: ID
/// The Task representing the asynchronous work for this request.
private let requestTask: Task<Output, Error>
/// Convenience property to await the result.
///
/// Example:
/// ```swift
/// let context = try await client.send(request)
/// let result = try await context.value
/// ```
public var value: Output {
get async throws {
try await requestTask.value
}
}
public init(requestID: ID, requestTask: Task<Output, Error>) {
self.requestID = requestID
self.requestTask = requestTask
}
}

View file

@ -4,10 +4,12 @@ import Foundation
/// following the format YYYY-MM-DD, to indicate
/// the last date backwards incompatible changes were made.
///
/// - SeeAlso: https://spec.modelcontextprotocol.io/specification/2025-03-26/
/// - SeeAlso: https://modelcontextprotocol.io/specification/2025-11-25/
public enum Version {
/// All protocol versions supported by this implementation, ordered from newest to oldest.
static let supported: Set<String> = [
public static let supported: Set<String> = [
"2025-11-25",
"2025-06-18",
"2025-03-26",
"2024-11-05",
]

View file

@ -34,11 +34,14 @@ public actor Client {
public struct Info: Hashable, Codable, Sendable {
/// The client name
public var name: String
/// A human-readable title for display purposes
public var title: String?
/// The client version
public var version: String
public init(name: String, version: String) {
public init(name: String, version: String, title: String? = nil) {
self.name = name
self.title = title
self.version = version
}
}
@ -56,12 +59,55 @@ public actor Client {
}
/// The sampling capabilities
public struct Sampling: Hashable, Codable, Sendable {
public init() {}
public struct Sampling: Hashable, Sendable {
/// Tools sub-capability for sampling
public struct Tools: Hashable, Codable, Sendable {
public init() {}
}
/// Context sub-capability for sampling
public struct Context: Hashable, Codable, Sendable {
public init() {}
}
/// Whether tools are supported in sampling
public var tools: Tools?
/// Whether context is supported in sampling
public var context: Context?
public init(tools: Tools? = nil, context: Context? = nil) {
self.tools = tools
self.context = context
}
}
/// The elicitation capabilities
public struct Elicitation: Hashable, Sendable {
/// Form-based elicitation sub-capability
public struct Form: Hashable, Codable, Sendable {
public init() {}
}
/// URL-based elicitation sub-capability
public struct URL: Hashable, Codable, Sendable {
public init() {}
}
/// Whether form-based elicitation is supported
public var form: Form?
/// Whether URL-based elicitation is supported
public var url: URL?
public init(form: Form? = Form(), url: URL? = nil) {
self.form = form
self.url = url
}
}
/// Whether the client supports sampling
public var sampling: Sampling?
/// Whether the client supports elicitation
public var elicitation: Elicitation?
/// Experimental features supported by the client
public var experimental: [String: String]?
/// Whether the client supports roots
@ -69,10 +115,12 @@ public actor Client {
public init(
sampling: Sampling? = nil,
elicitation: Elicitation? = nil,
experimental: [String: String]? = nil,
roots: Capabilities.Roots? = nil
) {
self.sampling = sampling
self.elicitation = elicitation
self.experimental = experimental
self.roots = roots
}
@ -91,6 +139,8 @@ public actor Client {
private let clientInfo: Client.Info
/// The client name
public nonisolated var name: String { clientInfo.name }
/// A human-readable client title
public nonisolated var title: String? { clientInfo.title }
/// The client version
public nonisolated var version: String { clientInfo.version }
@ -108,6 +158,8 @@ public actor Client {
/// A dictionary of type-erased notification handlers, keyed by method name
private var notificationHandlers: [String: [NotificationHandlerBox]] = [:]
/// Method handlers for server-to-client requests
private var methodHandlers: [String: RequestHandlerBox] = [:]
/// The task for the message handling loop
private var task: Task<Void, Never>?
@ -120,8 +172,8 @@ public actor Client {
}
/// A type-erased pending request
private struct AnyPendingRequest {
private let _resume: (Result<Any, Swift.Error>) -> Void
private struct AnyPendingRequest: Sendable {
private let _resume: @Sendable (Result<Any, Swift.Error>) -> Void
init<T: Sendable & Decodable>(_ request: PendingRequest<T>) {
_resume = { result in
@ -160,10 +212,12 @@ public actor Client {
public init(
name: String,
version: String,
title: String? = nil,
capabilities: Capabilities = Capabilities(),
configuration: Configuration = .default
) {
self.clientInfo = Client.Info(name: name, version: version)
self.capabilities = Capabilities()
self.clientInfo = Client.Info(name: name, version: version, title: title)
self.capabilities = capabilities
self.configuration = configuration
}
@ -194,6 +248,8 @@ public actor Client {
await handleBatchResponse(batchResponse)
} else if let response = try? decoder.decode(AnyResponse.self, from: data) {
await handleResponse(response)
} else if let request = try? decoder.decode(AnyRequest.self, from: data) {
await handleIncomingRequest(request)
} else if let message = try? decoder.decode(AnyMessage.self, from: data) {
await handleMessage(message)
} else {
@ -219,6 +275,27 @@ public actor Client {
await self.logger?.debug("Client message handling loop task is terminating.")
}
// Register cancellation notification handler
await self.onNotification(CancelledNotification.self) { [weak self] message in
guard let self = self else { return }
let requestId = message.params.requestId
let reason = message.params.reason
await self.logger?.debug(
"Received cancellation notification",
metadata: [
"requestId": "\(requestId)",
"reason": reason.map { "\($0)" } ?? "none",
]
)
// Remove the pending request and resume with cancellation error
if let pendingRequest = await self.removePendingRequest(id: requestId) {
pendingRequest.resume(throwing: CancellationError())
}
}
// Automatically initialize after connecting
return try await _initialize()
}
@ -277,6 +354,19 @@ public actor Client {
return self
}
/// Register a handler for server-to-client requests
@discardableResult
public func withMethodHandler<M: Method>(
_ type: M.Type,
handler: @escaping @Sendable (M.Parameters) async throws -> M.Result
) -> Self {
methodHandlers[M.name] = TypedRequestHandler { (request: Request<M>) -> Response<M> in
let result = try await handler(request.params)
return Response(id: request.id, result: result)
}
return self
}
/// Send a notification to the server
public func notify<N: Notification>(_ notification: Message<N>) async throws {
guard let connection = connection else {
@ -287,43 +377,108 @@ public actor Client {
try await connection.send(notificationData)
}
/// Send a response back to the server for a server-to-client request
private func send<M: Method>(_ response: Response<M>) async throws {
guard let connection = connection else {
throw MCPError.internalError("Client connection not initialized")
}
let responseData = try encoder.encode(response)
try await connection.send(responseData)
}
// MARK: - Requests
/// Send a request and receive its response
public func send<M: Method>(_ request: Request<M>) async throws -> M.Result {
/// Send a request and return a RequestContext that wraps both the request ID and the Task.
///
/// This allows you to track and cancel the request by sending a CancelledNotification
/// to the server using the requestID.
///
/// Example:
/// ```swift
/// let context = try await client.send(request)
/// // Later, to cancel:
/// try await client.cancelRequest(context.requestID, reason: "User cancelled")
/// // Await the result:
/// let result = try await context.value
/// ```
///
/// - Parameter request: The request to send
/// - Returns: A RequestContext containing the request ID and Task
/// - Throws: MCPError if the client is not connected
/// - SeeAlso: https://modelcontextprotocol.io/specification/2025-11-25/basic/utilities/cancellation
public func send<M: Method>(_ request: Request<M>) throws -> RequestContext<M.Result> {
guard let connection = connection else {
throw MCPError.internalError("Client connection not initialized")
}
let requestData = try encoder.encode(request)
// Store the pending request first
return try await withCheckedThrowingContinuation { continuation in
Task {
// Add the pending request before attempting to send
self.addPendingRequest(
id: request.id,
continuation: continuation,
type: M.Result.self
)
let requestTask = Task<M.Result, Error> {
try await withCheckedThrowingContinuation { continuation in
Task {
// Add the pending request before attempting to send
self.addPendingRequest(
id: request.id,
continuation: continuation,
type: M.Result.self
)
// Send the request data
do {
// Use the existing connection send
try await connection.send(requestData)
} catch {
// If send fails, try to remove the pending request.
// Resume with the send error only if we successfully removed the request,
// indicating the response handler hasn't processed it yet.
if self.removePendingRequest(id: request.id) != nil {
continuation.resume(throwing: error)
// Send the request data
do {
try await connection.send(requestData)
} catch {
// If send fails, try to remove the pending request.
if self.removePendingRequest(id: request.id) != nil {
continuation.resume(throwing: error)
}
}
// Otherwise, the request was already removed by the response handler
// or by disconnect, so the continuation was already resumed.
// Do nothing here.
}
}
}
return RequestContext(requestID: request.id, requestTask: requestTask)
}
/// Cancel a request by sending a CancelledNotification to the server.
///
/// According to the MCP specification, cancellation is advisory:
/// - The server SHOULD stop processing and free resources
/// - The server MAY ignore the cancellation if the request is unknown, already completed,
/// or cannot be cancelled
/// - The client SHOULD ignore any response that arrives after cancellation
///
/// This method removes the pending request and resumes it with CancellationError,
/// ensuring that any response arriving after cancellation is ignored.
///
/// - Parameters:
/// - requestID: The ID of the request to cancel
/// - reason: An optional human-readable reason for the cancellation
/// - Throws: MCPError if the notification cannot be sent
/// - SeeAlso: https://modelcontextprotocol.io/specification/2025-11-25/basic/utilities/cancellation
public func cancelRequest(_ requestID: ID, reason: String? = nil) async throws {
// Remove the pending request and resume with cancellation error
// This ensures any response that arrives after cancellation is ignored
if let pendingRequest = removePendingRequest(id: requestID) {
pendingRequest.resume(throwing: CancellationError())
}
// Send cancellation notification to server
let notification = CancelledNotification.message(
.init(requestId: requestID, reason: reason)
)
try await notify(notification)
}
/// Send a request and receive its response immediately.
///
/// Internal convenience method for cases where cancellation tracking is not needed.
///
/// - Parameter request: The request to send
/// - Returns: The result of the request
/// - Throws: MCPError if the client is not connected
func sendAndAwait<M: Method>(_ request: Request<M>) async throws -> M.Result {
let context = try send(request)
return try await context.value
}
private func addPendingRequest<T: Sendable & Decodable>(
@ -331,7 +486,9 @@ public actor Client {
continuation: CheckedContinuation<T, Swift.Error>,
type: T.Type // Keep type for AnyPendingRequest internal logic
) {
pendingRequests[id] = AnyPendingRequest(PendingRequest(continuation: continuation))
pendingRequests[id] = AnyPendingRequest(
PendingRequest(continuation: continuation)
)
}
private func removePendingRequest(id: ID) -> AnyPendingRequest? {
@ -452,7 +609,7 @@ public actor Client {
/// Use this object to add requests to the batch.
/// - Throws: `MCPError.internalError` if the client is not connected.
/// Can also rethrow errors from the `body` closure or from sending the batch request.
public func withBatch(body: @escaping (Batch) async throws -> Void) async throws {
public func withBatch(body: @escaping @Sendable (Batch) async throws -> Void) async throws {
guard let connection = connection else {
throw MCPError.internalError("Client connection not initialized")
}
@ -508,7 +665,7 @@ public actor Client {
clientInfo: clientInfo
))
let result = try await send(request)
let result = try await sendAndAwait(request)
self.serverCapabilities = result.capabilities
self.serverVersion = result.protocolVersion
@ -521,7 +678,7 @@ public actor Client {
public func ping() async throws {
let request = Ping.request()
_ = try await send(request)
_ = try await sendAndAwait(request)
}
// MARK: - Prompts
@ -531,7 +688,7 @@ public actor Client {
{
try validateServerCapability(\.prompts, "Prompts")
let request = GetPrompt.request(.init(name: name, arguments: arguments))
let result = try await send(request)
let result = try await sendAndAwait(request)
return (description: result.description, messages: result.messages)
}
@ -545,7 +702,7 @@ public actor Client {
} else {
request = ListPrompts.request(.init())
}
let result = try await send(request)
let result = try await sendAndAwait(request)
return (prompts: result.prompts, nextCursor: result.nextCursor)
}
@ -554,7 +711,7 @@ public actor Client {
public func readResource(uri: String) async throws -> [Resource.Content] {
try validateServerCapability(\.resources, "Resources")
let request = ReadResource.request(.init(uri: uri))
let result = try await send(request)
let result = try await sendAndAwait(request)
return result.contents
}
@ -568,14 +725,14 @@ public actor Client {
} else {
request = ListResources.request(.init())
}
let result = try await send(request)
let result = try await sendAndAwait(request)
return (resources: result.resources, nextCursor: result.nextCursor)
}
public func subscribeToResource(uri: String) async throws {
try validateServerCapability(\.resources?.subscribe, "Resource subscription")
let request = ResourceSubscribe.request(.init(uri: uri))
_ = try await send(request)
_ = try await sendAndAwait(request)
}
public func listResourceTemplates(cursor: String? = nil) async throws -> (
@ -588,7 +745,7 @@ public actor Client {
} else {
request = ListResourceTemplates.request(.init())
}
let result = try await send(request)
let result = try await sendAndAwait(request)
return (templates: result.templates, nextCursor: result.nextCursor)
}
@ -604,19 +761,53 @@ public actor Client {
} else {
request = ListTools.request(.init())
}
let result = try await send(request)
let result = try await sendAndAwait(request)
return (tools: result.tools, nextCursor: result.nextCursor)
}
public func callTool(name: String, arguments: [String: Value]? = nil) async throws -> (
content: [Tool.Content], isError: Bool?
) {
/// Call a tool on the server.
///
/// - Parameters:
/// - name: The name of the tool to call.
/// - arguments: Arguments to use for the tool call.
/// - meta: Optional request metadata including progress token. If `progressToken` is specified,
/// the caller is requesting out-of-band progress notifications for this request.
/// Use `onNotification(ProgressNotification.self)` to receive progress updates.
/// - Returns: A tuple containing the tool's content response and an optional error flag.
/// - Note: For advanced use cases requiring cancellation support, use `send()` directly to get a `RequestContext`.
/// - SeeAlso: https://modelcontextprotocol.io/specification/2025-11-25/server/tools/#calling-tools
public func callTool(
name: String,
arguments: [String: Value]? = nil,
meta: Metadata? = nil
) async throws -> (content: [Tool.Content], isError: Bool?) {
try validateServerCapability(\.tools, "Tools")
let request = CallTool.request(.init(name: name, arguments: arguments))
let result = try await send(request)
let request = CallTool.request(.init(name: name, arguments: arguments, meta: meta))
let result = try await sendAndAwait(request)
return (content: result.content, isError: result.isError)
}
/// Call a tool on the server.
///
/// - Parameters:
/// - name: The name of the tool to call.
/// - arguments: Arguments to use for the tool call.
/// - meta: Optional request metadata including progress token. If `progressToken` is specified,
/// the caller is requesting out-of-band progress notifications for this request.
/// Use `onNotification(ProgressNotification.self)` to receive progress updates.
/// - Returns: A tuple containing the tool's content response and an optional error flag.
/// - Note: For advanced use cases requiring cancellation support, use `send()` directly to get a `RequestContext`.
/// - SeeAlso: https://modelcontextprotocol.io/specification/2025-11-25/server/tools/#calling-tools
public func callTool(
name: String,
arguments: [String: Value]? = nil,
meta: Metadata? = nil
) throws -> RequestContext<CallTool.Result> {
try validateServerCapability(\.tools, "Tools")
let request = CallTool.request(.init(name: name, arguments: arguments, meta: meta))
return try send(request)
}
// MARK: - Sampling
/// Register a handler for sampling requests from servers
@ -636,24 +827,145 @@ public actor Client {
/// - SeeAlso: https://modelcontextprotocol.io/docs/concepts/sampling#how-sampling-works
@discardableResult
public func withSamplingHandler(
_ handler: @escaping @Sendable (CreateSamplingMessage.Parameters) async throws ->
_ handler:
@escaping @Sendable (CreateSamplingMessage.Parameters) async throws ->
CreateSamplingMessage.Result
) -> Self {
// Note: This would require extending the client architecture to handle incoming requests from servers.
// The current MCP Swift SDK architecture assumes clients only send requests to servers,
// but sampling requires bidirectional communication where servers can send requests to clients.
//
// A full implementation would need:
// 1. Request handlers in the client (similar to how servers handle requests)
// 2. Bidirectional transport support
// 3. Request/response correlation for server-to-client requests
//
// For now, this serves as the correct API design for when bidirectional support is added.
return withMethodHandler(CreateSamplingMessage.self, handler: handler)
}
// This would register the handler similar to how servers register method handlers:
// methodHandlers[CreateSamplingMessage.name] = TypedRequestHandler(handler)
// MARK: - Elicitation
return self
/// Register a handler for elicitation requests from servers
///
/// The elicitation flow lets servers collect structured input from users during
/// ongoing interactions. Clients remain in control by mediating the prompt,
/// collecting the response, and returning the chosen action to the server.
///
/// - Parameter handler: A closure that processes elicitation requests and returns user actions
/// - Returns: Self for method chaining
/// - SeeAlso: https://modelcontextprotocol.io/specification/2025-06-18/client/elicitation
@discardableResult
public func withElicitationHandler(
_ handler:
@escaping @Sendable (CreateElicitation.Parameters) async throws ->
CreateElicitation.Result
) -> Self {
return withMethodHandler(CreateElicitation.self, handler: handler)
}
// MARK: - Roots
/// Register a handler for roots/list requests from servers
///
/// Roots define filesystem boundaries that servers can operate within.
/// Unlike other MCP features, roots use bidirectional communication where
/// servers send requests TO clients to discover available roots.
///
/// - Parameter handler: A closure that returns the list of available roots
/// - Returns: Self for method chaining
/// - SeeAlso: https://modelcontextprotocol.io/specification/2025-11-25/client/roots
@discardableResult
public func withRootsHandler(
_ handler: @escaping @Sendable () async throws -> [Root]
) -> Self {
return withMethodHandler(ListRoots.self) { _ in
let roots = try await handler()
return ListRoots.Result(roots: roots)
}
}
/// Notify the server that the list of roots has changed
///
/// Clients should send this notification when roots are added, removed,
/// or modified to inform connected servers of the change.
///
/// - Throws: MCPError if the client is not connected
/// - SeeAlso: https://modelcontextprotocol.io/specification/2025-11-25/client/roots
public func notifyRootsChanged() async throws {
let notification = RootsListChangedNotification.message()
try await notify(notification)
}
// MARK: - Logging
/// Set the minimum logging level for server log messages.
///
/// Servers that declare the `logging` capability will send log messages via
/// `notifications/message` notifications. Use this method to control which
/// severity levels the server should send.
///
/// - Parameter level: The minimum log level to receive
/// - Throws: MCPError if the client is not connected or if the server doesn't support logging
/// - SeeAlso: https://modelcontextprotocol.io/specification/2025-11-25/server/utilities/logging/
public func setLoggingLevel(_ level: LogLevel) async throws {
try validateServerCapability(\.logging, "Logging")
let request = SetLoggingLevel.request(.init(level: level))
_ = try await sendAndAwait(request)
}
// MARK: - Completions
/// Request completion suggestions for a prompt argument.
///
/// Servers that declare the `completions` capability can provide autocompletion
/// suggestions for prompt arguments as users type.
///
/// - Parameters:
/// - promptName: The name of the prompt
/// - argumentName: The name of the argument being completed
/// - argumentValue: The current (partial) value of the argument
/// - context: Optional context with already-resolved arguments
/// - Returns: A completion result containing suggested values
/// - Throws: MCPError if the client is not connected or if the server doesn't support completions
/// - SeeAlso: https://modelcontextprotocol.io/specification/2025-11-25/server/utilities/completion/
public func complete(
promptName: String,
argumentName: String,
argumentValue: String,
context: [String: Value]? = nil
) async throws -> Complete.Result.Completion {
try validateServerCapability(\.completions, "Completions")
let request = Complete.request(
.init(
ref: .prompt(.init(name: promptName)),
argument: .init(name: argumentName, value: argumentValue),
context: context.map { .init(arguments: $0) }
)
)
let result = try await sendAndAwait(request)
return result.completion
}
/// Request completion suggestions for a resource template argument.
///
/// Servers that declare the `completions` capability can provide autocompletion
/// suggestions for resource template arguments as users type.
///
/// - Parameters:
/// - resourceURI: The URI of the resource template
/// - argumentName: The name of the argument being completed
/// - argumentValue: The current (partial) value of the argument
/// - context: Optional context with already-resolved arguments
/// - Returns: A completion result containing suggested values
/// - Throws: MCPError if the client is not connected or if the server doesn't support completions
/// - SeeAlso: https://modelcontextprotocol.io/specification/2025-11-25/server/utilities/completion/
public func complete(
resourceURI: String,
argumentName: String,
argumentValue: String,
context: [String: Value]? = nil
) async throws -> Complete.Result.Completion {
try validateServerCapability(\.completions, "Completions")
let request = Complete.request(
.init(
ref: .resource(.init(uri: resourceURI)),
argument: .init(name: argumentName, value: argumentValue),
context: context.map { .init(arguments: $0) }
)
)
let result = try await sendAndAwait(request)
return result.completion
}
// MARK: -
@ -706,6 +1018,43 @@ public actor Client {
}
}
private func handleIncomingRequest(_ request: Request<AnyMethod>) async {
await logger?.trace(
"Processing incoming request from server",
metadata: ["method": "\(request.method)", "id": "\(request.id)"])
guard let handler = methodHandlers[request.method] else {
await logger?.warning(
"No handler registered for method",
metadata: ["method": "\(request.method)"])
let error = MCPError.methodNotFound("Unknown method: \(request.method)")
let response = AnyMethod.response(id: request.id, error: error)
do {
try await send(response)
} catch {
await logger?.error(
"Failed to send error response",
metadata: ["error": "\(error)"])
}
return
}
do {
let response = try await handler(request)
try await send(response)
} catch {
let mcpError = error as? MCPError ?? MCPError.internalError(error.localizedDescription)
let response = AnyMethod.response(id: request.id, error: mcpError)
do {
try await send(response)
} catch {
await logger?.error(
"Failed to send error response",
metadata: ["error": "\(error)"])
}
}
}
// MARK: -
/// Validate the server capabilities.
@ -751,3 +1100,57 @@ public actor Client {
}
}
}
// MARK: - Codable
extension Client.Capabilities.Sampling: Codable {
private enum CodingKeys: String, CodingKey {
case tools, context
}
public init(from decoder: Decoder) throws {
// Handle both empty object {} and object with sub-capabilities
if let container = try? decoder.container(keyedBy: CodingKeys.self) {
self.tools = try container.decodeIfPresent(Tools.self, forKey: .tools)
self.context = try container.decodeIfPresent(Context.self, forKey: .context)
} else {
// Empty object - no capabilities
self.tools = nil
self.context = nil
}
}
public func encode(to encoder: Encoder) throws {
var container = encoder.container(keyedBy: CodingKeys.self)
try container.encodeIfPresent(tools, forKey: .tools)
try container.encodeIfPresent(context, forKey: .context)
}
}
extension Client.Capabilities.Elicitation: Codable {
private enum CodingKeys: String, CodingKey {
case form, url
}
public init(from decoder: Decoder) throws {
// Handle both empty object {} and object with sub-capabilities
if let container = try? decoder.container(keyedBy: CodingKeys.self) {
self.form = try container.decodeIfPresent(Form.self, forKey: .form)
self.url = try container.decodeIfPresent(URL.self, forKey: .url)
// If both are nil, default to form for backward compatibility
if self.form == nil && self.url == nil {
self.form = Form()
}
} else {
// Empty object - default to form-only for backward compatibility
self.form = Form()
self.url = nil
}
}
public func encode(to encoder: Encoder) throws {
var container = encoder.container(keyedBy: CodingKeys.self)
try container.encodeIfPresent(form, forKey: .form)
try container.encodeIfPresent(url, forKey: .url)
}
}

View file

@ -0,0 +1,233 @@
import Foundation
/// Types supporting the MCP elicitation flow.
///
/// Servers use elicitation to collect structured input from users via the client.
/// The schema subset mirrors the 2025-11-25 revision of the specification.
public enum Elicitation {
/// Schema describing the expected response content.
public struct RequestSchema: Hashable, Codable, Sendable {
/// Supported top-level types. Currently limited to objects.
public enum SchemaType: String, Hashable, Codable, Sendable {
case object
}
/// Schema title presented to users.
public var title: String?
/// Schema description providing additional guidance.
public var description: String?
/// Raw JSON Schema fragments describing the requested fields.
public var properties: [String: Value]
/// List of required field keys.
public var required: [String]?
/// Top-level schema type. Defaults to `object`.
public var type: SchemaType
public init(
title: String? = nil,
description: String? = nil,
properties: [String: Value] = [:],
required: [String]? = nil,
type: SchemaType = .object
) {
self.title = title
self.description = description
self.properties = properties
self.required = required
self.type = type
}
private enum CodingKeys: String, CodingKey {
case title, description, properties, required, type
}
}
/// Elicitation mode indicating how user input is collected
public enum Mode: String, Hashable, Codable, Sendable {
/// Form-based elicitation (client displays UI)
case form
/// URL-based elicitation (client opens external URL)
case url
}
}
/// To request information from a user, servers send an `elicitation/create` request.
/// - SeeAlso: https://modelcontextprotocol.io/specification/2025-06-18/client/elicitation
public enum CreateElicitation: Method {
public static let name = "elicitation/create"
public enum Parameters: Hashable, Sendable {
/// Form-based elicitation parameters
case form(FormParameters)
/// URL-based elicitation parameters
case url(URLParameters)
/// Parameters for form-based elicitation
public struct FormParameters: Hashable, Codable, Sendable {
/// Message displayed to the user describing the request
public var message: String
/// Elicitation mode (optional for backward compatibility, defaults to form)
public var mode: Elicitation.Mode?
/// Optional schema describing the expected response content
public var requestedSchema: Elicitation.RequestSchema?
/// Optional metadata
public var _meta: Metadata?
public init(
message: String,
mode: Elicitation.Mode? = nil,
requestedSchema: Elicitation.RequestSchema? = nil,
_meta: Metadata? = nil
) {
self.message = message
self.mode = mode
self.requestedSchema = requestedSchema
self._meta = _meta
}
}
/// Parameters for URL-based elicitation
public struct URLParameters: Hashable, Codable, Sendable {
/// Message displayed to the user describing the request
public var message: String
/// Elicitation mode (always "url")
public var mode: Elicitation.Mode
/// URL for the user to visit
public var url: String
/// Unique identifier for this elicitation
public var elicitationId: String
/// Optional metadata
public var _meta: Metadata?
public init(
message: String,
url: String,
elicitationId: String,
_meta: Metadata? = nil
) {
self.message = message
self.mode = .url
self.url = url
self.elicitationId = elicitationId
self._meta = _meta
}
}
}
public struct Result: Hashable, Codable, Sendable {
/// Indicates how the user responded to the request.
public enum Action: String, Hashable, Codable, Sendable {
case accept
case decline
case cancel
}
/// Selected action.
public var action: Action
/// Submitted content when action is `.accept`.
public var content: [String: Value]?
/// Optional metadata about this result
public var _meta: Metadata?
public init(
action: Action,
content: [String: Value]? = nil,
_meta: Metadata? = nil
) {
self.action = action
self.content = content
self._meta = _meta
}
private enum CodingKeys: String, CodingKey, CaseIterable {
case action, content, _meta
}
public func encode(to encoder: Encoder) throws {
var container = encoder.container(keyedBy: CodingKeys.self)
try container.encode(action, forKey: .action)
try container.encodeIfPresent(content, forKey: .content)
try container.encodeIfPresent(_meta, forKey: ._meta)
}
public init(from decoder: Decoder) throws {
let container = try decoder.container(keyedBy: CodingKeys.self)
action = try container.decode(Action.self, forKey: .action)
content = try container.decodeIfPresent([String: Value].self, forKey: .content)
_meta = try container.decodeIfPresent(Metadata.self, forKey: ._meta)
}
}
}
// MARK: - Codable
extension CreateElicitation.Parameters: Codable {
private enum CodingKeys: String, CodingKey {
case mode, message, requestedSchema, url, elicitationId
case _meta
}
public init(from decoder: Decoder) throws {
let container = try decoder.container(keyedBy: CodingKeys.self)
// Read mode field (may be missing for backward compatibility)
let mode = try container.decodeIfPresent(Elicitation.Mode.self, forKey: .mode)
// Discriminate based on mode
if mode == .url {
// URL mode
let message = try container.decode(String.self, forKey: .message)
let url = try container.decode(String.self, forKey: .url)
let elicitationId = try container.decode(String.self, forKey: .elicitationId)
let _meta = try container.decodeIfPresent(Metadata.self, forKey: ._meta)
self = .url(URLParameters(
message: message,
url: url,
elicitationId: elicitationId,
_meta: _meta))
} else {
// Form mode (default for backward compatibility)
let message = try container.decode(String.self, forKey: .message)
let requestedSchema = try container.decodeIfPresent(
Elicitation.RequestSchema.self, forKey: .requestedSchema)
let _meta = try container.decodeIfPresent(Metadata.self, forKey: ._meta)
self = .form(FormParameters(
message: message,
mode: mode,
requestedSchema: requestedSchema,
_meta: _meta))
}
}
public func encode(to encoder: Encoder) throws {
var container = encoder.container(keyedBy: CodingKeys.self)
switch self {
case .form(let params):
try container.encode(params.message, forKey: .message)
try container.encodeIfPresent(params.mode, forKey: .mode)
try container.encodeIfPresent(params.requestedSchema, forKey: .requestedSchema)
try container.encodeIfPresent(params._meta, forKey: ._meta)
case .url(let params):
try container.encode(params.message, forKey: .message)
try container.encode(params.mode, forKey: .mode)
try container.encode(params.url, forKey: .url)
try container.encode(params.elicitationId, forKey: .elicitationId)
try container.encodeIfPresent(params._meta, forKey: ._meta)
}
}
}
/// Notification sent when a URL-based elicitation is complete
public struct ElicitationCompleteNotification: Notification {
public static let name = "notifications/elicitation/complete"
public struct Parameters: Hashable, Codable, Sendable {
/// The elicitation ID that was completed
public var elicitationId: String
public init(elicitationId: String) {
self.elicitationId = elicitationId
}
}
}

View file

@ -0,0 +1,93 @@
import Foundation
/// The Model Context Protocol (MCP) provides a mechanism for clients to expose
/// filesystem boundaries to servers through roots. Roots allow servers to understand
/// the scope of filesystem access they can request, enabling safe and controlled
/// file operations.
///
/// Unlike Resources/Tools/Prompts, Roots use bidirectional communication:
/// - Servers send `roots/list` requests TO clients
/// - Clients respond with available roots
/// - Clients send `notifications/roots/list_changed` when roots change
///
/// - SeeAlso: https://modelcontextprotocol.io/specification/2025-11-25/client/roots
public struct Root: Hashable, Codable, Sendable {
/// The root URI (must use file:// scheme)
public let uri: String
/// Optional human-readable name for the root
public let name: String?
public init(
uri: String,
name: String? = nil
) {
self.uri = uri
self.name = name
}
private enum CodingKeys: String, CodingKey {
case uri
case name
}
public init(from decoder: Decoder) throws {
let container = try decoder.container(keyedBy: CodingKeys.self)
uri = try container.decode(String.self, forKey: .uri)
name = try container.decodeIfPresent(String.self, forKey: .name)
}
public func encode(to encoder: Encoder) throws {
var container = encoder.container(keyedBy: CodingKeys.self)
try container.encode(uri, forKey: .uri)
try container.encodeIfPresent(name, forKey: .name)
}
}
// MARK: -
/// To discover available roots, servers send a `roots/list` request to the client.
/// - SeeAlso: https://modelcontextprotocol.io/specification/2025-11-25/client/roots
public enum ListRoots: Method {
public static let name: String = "roots/list"
public typealias Parameters = Empty
public struct Result: Hashable, Codable, Sendable {
public let roots: [Root]
/// Optional metadata about this result
public var _meta: Metadata?
public init(
roots: [Root],
_meta: Metadata? = nil
) {
self.roots = roots
self._meta = _meta
}
private enum CodingKeys: String, CodingKey, CaseIterable {
case roots, _meta
}
public func encode(to encoder: Encoder) throws {
var container = encoder.container(keyedBy: CodingKeys.self)
try container.encode(roots, forKey: .roots)
try container.encodeIfPresent(_meta, forKey: ._meta)
}
public init(from decoder: Decoder) throws {
let container = try decoder.container(keyedBy: CodingKeys.self)
roots = try container.decode([Root].self, forKey: .roots)
_meta = try container.decodeIfPresent(Metadata.self, forKey: ._meta)
}
}
}
/// When the list of roots changes, clients that declared the `roots` capability
/// SHOULD send this notification to inform servers.
/// - SeeAlso: https://modelcontextprotocol.io/specification/2025-11-25/client/roots
public struct RootsListChangedNotification: Notification {
public static let name: String = "notifications/roots/list_changed"
public typealias Parameters = Empty
}

View file

@ -48,10 +48,55 @@ public enum Sampling {
/// Content types for sampling messages
public enum Content: Hashable, Sendable {
/// Text content
case text(String)
/// Image content
case image(data: String, mimeType: String)
/// Single content block
case single(ContentBlock)
/// Multiple content blocks
case multiple([ContentBlock])
/// Individual content blocks in messages
public enum ContentBlock: Hashable, Sendable {
/// Text content
case text(String)
/// Image content
case image(data: String, mimeType: String)
/// Audio content
case audio(data: String, mimeType: String)
/// Tool use content
case toolUse(Sampling.ToolUseContent)
/// Tool result content
case toolResult(Sampling.ToolResultContent)
}
/// Returns true if this is a single content block
public var isSingle: Bool {
if case .single = self { return true }
return false
}
/// Returns content as an array of blocks
public var asArray: [ContentBlock] {
switch self {
case .single(let block):
return [block]
case .multiple(let blocks):
return blocks
}
}
/// Creates content from a text string (convenience)
public static func text(_ text: String) -> Content {
.single(.text(text))
}
/// Creates content from an image (convenience)
public static func image(data: String, mimeType: String) -> Content {
.single(.image(data: data, mimeType: mimeType))
}
/// Creates content from audio (convenience)
public static func audio(data: String, mimeType: String) -> Content {
.single(.audio(data: data, mimeType: mimeType))
}
}
}
@ -107,14 +152,79 @@ public enum Sampling {
case stopSequence
/// Reached maximum tokens
case maxTokens
/// Model wants to use a tool
case toolUse
}
/// Content representing a tool use request from the model
public struct ToolUseContent: Hashable, Codable, Sendable {
/// Unique identifier for this tool use
public let id: String
/// Name of the tool being invoked
public let name: String
/// Input parameters for the tool
public let input: [String: Value]
/// Optional metadata
public var _meta: Metadata?
public init(id: String, name: String, input: [String: Value], _meta: Metadata? = nil) {
self.id = id
self.name = name
self.input = input
self._meta = _meta
}
}
/// Content representing the result of a tool execution
public struct ToolResultContent: Hashable, Codable, Sendable {
/// ID of the tool use this result corresponds to
public let toolUseId: String
/// Content blocks from tool execution
public let content: [ContentBlock]
/// Structured data from tool execution
public let structuredContent: [String: Value]?
/// Whether the tool execution resulted in an error
public let isError: Bool?
/// Optional metadata
public var _meta: Metadata?
/// Individual content blocks in tool results
public enum ContentBlock: Hashable, Sendable {
/// Text content
case text(String)
/// Image content
case image(data: String, mimeType: String)
/// Audio content
case audio(data: String, mimeType: String)
/// Embedded resource content
case resource(resource: Resource.Content, annotations: Resource.Annotations?, _meta: Metadata?)
/// Resource link
case resourceLink(uri: String, name: String, title: String?, description: String?, mimeType: String?, annotations: Resource.Annotations?)
}
public init(
toolUseId: String,
content: [ContentBlock],
structuredContent: [String: Value]? = nil,
isError: Bool? = nil,
_meta: Metadata? = nil
) {
self.toolUseId = toolUseId
self.content = content
self.structuredContent = structuredContent
self.isError = isError
self._meta = _meta
}
}
}
// MARK: - Codable
extension Sampling.Message.Content: Codable {
extension Sampling.Message.Content.ContentBlock: Codable {
private enum CodingKeys: String, CodingKey {
case type, text, data, mimeType
case id, name, input, _meta
case toolUseId, content, structuredContent, isError
}
public init(from decoder: Decoder) throws {
@ -129,10 +239,32 @@ extension Sampling.Message.Content: Codable {
let data = try container.decode(String.self, forKey: .data)
let mimeType = try container.decode(String.self, forKey: .mimeType)
self = .image(data: data, mimeType: mimeType)
case "audio":
let data = try container.decode(String.self, forKey: .data)
let mimeType = try container.decode(String.self, forKey: .mimeType)
self = .audio(data: data, mimeType: mimeType)
case "toolUse":
let id = try container.decode(String.self, forKey: .id)
let name = try container.decode(String.self, forKey: .name)
let input = try container.decode([String: Value].self, forKey: .input)
let _meta = try container.decodeIfPresent(Metadata.self, forKey: ._meta)
self = .toolUse(Sampling.ToolUseContent(id: id, name: name, input: input, _meta: _meta))
case "toolResult":
let toolUseId = try container.decode(String.self, forKey: .toolUseId)
let content = try container.decode([Sampling.ToolResultContent.ContentBlock].self, forKey: .content)
let structuredContent = try container.decodeIfPresent([String: Value].self, forKey: .structuredContent)
let isError = try container.decodeIfPresent(Bool.self, forKey: .isError)
let _meta = try container.decodeIfPresent(Metadata.self, forKey: ._meta)
self = .toolResult(Sampling.ToolResultContent(
toolUseId: toolUseId,
content: content,
structuredContent: structuredContent,
isError: isError,
_meta: _meta))
default:
throw DecodingError.dataCorruptedError(
forKey: .type, in: container,
debugDescription: "Unknown sampling message content type")
debugDescription: "Unknown sampling message content block type")
}
}
@ -147,6 +279,52 @@ extension Sampling.Message.Content: Codable {
try container.encode("image", forKey: .type)
try container.encode(data, forKey: .data)
try container.encode(mimeType, forKey: .mimeType)
case .audio(let data, let mimeType):
try container.encode("audio", forKey: .type)
try container.encode(data, forKey: .data)
try container.encode(mimeType, forKey: .mimeType)
case .toolUse(let toolUse):
try container.encode("toolUse", forKey: .type)
try container.encode(toolUse.id, forKey: .id)
try container.encode(toolUse.name, forKey: .name)
try container.encode(toolUse.input, forKey: .input)
try container.encodeIfPresent(toolUse._meta, forKey: ._meta)
case .toolResult(let toolResult):
try container.encode("toolResult", forKey: .type)
try container.encode(toolResult.toolUseId, forKey: .toolUseId)
try container.encode(toolResult.content, forKey: .content)
try container.encodeIfPresent(toolResult.structuredContent, forKey: .structuredContent)
try container.encodeIfPresent(toolResult.isError, forKey: .isError)
try container.encodeIfPresent(toolResult._meta, forKey: ._meta)
}
}
}
extension Sampling.Message.Content: Codable {
public init(from decoder: Decoder) throws {
// Try to decode as an array first
if let blocks = try? [ContentBlock](from: decoder) {
// If it's a single-element array, unwrap it to single
if blocks.count == 1, let block = blocks.first {
self = .single(block)
} else {
self = .multiple(blocks)
}
} else {
// Try to decode as a single block
let block = try ContentBlock(from: decoder)
self = .single(block)
}
}
public func encode(to encoder: Encoder) throws {
switch self {
case .single(let block):
// Encode single block directly (not as array)
try block.encode(to: encoder)
case .multiple(let blocks):
// Encode as array
try blocks.encode(to: encoder)
}
}
}
@ -155,7 +333,7 @@ extension Sampling.Message.Content: Codable {
extension Sampling.Message.Content: ExpressibleByStringLiteral {
public init(stringLiteral value: String) {
self = .text(value)
self = .single(.text(value))
}
}
@ -163,7 +341,83 @@ extension Sampling.Message.Content: ExpressibleByStringLiteral {
extension Sampling.Message.Content: ExpressibleByStringInterpolation {
public init(stringInterpolation: DefaultStringInterpolation) {
self = .text(String(stringInterpolation: stringInterpolation))
self = .single(.text(String(stringInterpolation: stringInterpolation)))
}
}
extension Sampling.ToolResultContent.ContentBlock: Codable {
private enum CodingKeys: String, CodingKey {
case type, text, data, mimeType, resource, annotations, _meta
case uri, name, title, description
}
public init(from decoder: Decoder) throws {
let container = try decoder.container(keyedBy: CodingKeys.self)
let type = try container.decode(String.self, forKey: .type)
switch type {
case "text":
let text = try container.decode(String.self, forKey: .text)
self = .text(text)
case "image":
let data = try container.decode(String.self, forKey: .data)
let mimeType = try container.decode(String.self, forKey: .mimeType)
self = .image(data: data, mimeType: mimeType)
case "audio":
let data = try container.decode(String.self, forKey: .data)
let mimeType = try container.decode(String.self, forKey: .mimeType)
self = .audio(data: data, mimeType: mimeType)
case "resource":
let resourceContent = try container.decode(Resource.Content.self, forKey: .resource)
let annotations = try container.decodeIfPresent(Resource.Annotations.self, forKey: .annotations)
let _meta = try container.decodeIfPresent(Metadata.self, forKey: ._meta)
self = .resource(resource: resourceContent, annotations: annotations, _meta: _meta)
case "resourceLink":
let uri = try container.decode(String.self, forKey: .uri)
let name = try container.decode(String.self, forKey: .name)
let title = try container.decodeIfPresent(String.self, forKey: .title)
let description = try container.decodeIfPresent(String.self, forKey: .description)
let mimeType = try container.decodeIfPresent(String.self, forKey: .mimeType)
let annotations = try container.decodeIfPresent(Resource.Annotations.self, forKey: .annotations)
self = .resourceLink(
uri: uri, name: name, title: title, description: description,
mimeType: mimeType, annotations: annotations)
default:
throw DecodingError.dataCorruptedError(
forKey: .type, in: container,
debugDescription: "Unknown tool result content block type")
}
}
public func encode(to encoder: Encoder) throws {
var container = encoder.container(keyedBy: CodingKeys.self)
switch self {
case .text(let text):
try container.encode("text", forKey: .type)
try container.encode(text, forKey: .text)
case .image(let data, let mimeType):
try container.encode("image", forKey: .type)
try container.encode(data, forKey: .data)
try container.encode(mimeType, forKey: .mimeType)
case .audio(let data, let mimeType):
try container.encode("audio", forKey: .type)
try container.encode(data, forKey: .data)
try container.encode(mimeType, forKey: .mimeType)
case .resource(let resourceContent, let annotations, let _meta):
try container.encode("resource", forKey: .type)
try container.encode(resourceContent, forKey: .resource)
try container.encodeIfPresent(annotations, forKey: .annotations)
try container.encodeIfPresent(_meta, forKey: ._meta)
case .resourceLink(let uri, let name, let title, let description, let mimeType, let annotations):
try container.encode("resourceLink", forKey: .type)
try container.encode(uri, forKey: .uri)
try container.encode(name, forKey: .name)
try container.encodeIfPresent(title, forKey: .title)
try container.encodeIfPresent(description, forKey: .description)
try container.encodeIfPresent(mimeType, forKey: .mimeType)
try container.encodeIfPresent(annotations, forKey: .annotations)
}
}
}
@ -174,6 +428,26 @@ extension Sampling.Message.Content: ExpressibleByStringInterpolation {
public enum CreateSamplingMessage: Method {
public static let name = "sampling/createMessage"
/// Tool choice configuration for sampling
public struct ToolChoice: Hashable, Codable, Sendable {
/// Tool choice mode
public enum Mode: String, Hashable, Codable, Sendable {
/// Automatically decide whether to use tools
case auto
/// Require using at least one tool
case required
/// Do not use any tools
case none
}
/// The tool choice mode
public let mode: Mode
public init(mode: Mode) {
self.mode = mode
}
}
public struct Parameters: Hashable, Codable, Sendable {
/// The conversation history to send to the LLM
public let messages: [Sampling.Message]
@ -189,8 +463,12 @@ public enum CreateSamplingMessage: Method {
public let maxTokens: Int
/// Array of sequences that stop generation
public let stopSequences: [String]?
/// Additional provider-specific parameters
public let metadata: [String: Value]?
/// Optional request metadata
public var _meta: Metadata?
/// Tools available for the model to use
public let tools: [Tool]?
/// Tool choice configuration
public let toolChoice: ToolChoice?
public init(
messages: [Sampling.Message],
@ -200,7 +478,9 @@ public enum CreateSamplingMessage: Method {
temperature: Double? = nil,
maxTokens: Int,
stopSequences: [String]? = nil,
metadata: [String: Value]? = nil
_meta: Metadata? = nil,
tools: [Tool]? = nil,
toolChoice: ToolChoice? = nil
) {
self.messages = messages
self.modelPreferences = modelPreferences
@ -209,7 +489,9 @@ public enum CreateSamplingMessage: Method {
self.temperature = temperature
self.maxTokens = maxTokens
self.stopSequences = stopSequences
self.metadata = metadata
self._meta = _meta
self.tools = tools
self.toolChoice = toolChoice
}
}
@ -222,17 +504,44 @@ public enum CreateSamplingMessage: Method {
public let role: Sampling.Message.Role
/// The completion content
public let content: Sampling.Message.Content
/// Optional metadata about this result
public var _meta: Metadata?
public init(
model: String,
stopReason: Sampling.StopReason? = nil,
role: Sampling.Message.Role,
content: Sampling.Message.Content
content: Sampling.Message.Content,
_meta: Metadata? = nil
) {
self.model = model
self.stopReason = stopReason
self.role = role
self.content = content
self._meta = _meta
}
private enum CodingKeys: String, CodingKey, CaseIterable {
case model, stopReason, role, content, _meta
}
public func encode(to encoder: Encoder) throws {
var container = encoder.container(keyedBy: CodingKeys.self)
try container.encode(model, forKey: .model)
try container.encodeIfPresent(stopReason, forKey: .stopReason)
try container.encode(role, forKey: .role)
try container.encode(content, forKey: .content)
try container.encodeIfPresent(_meta, forKey: ._meta)
}
public init(from decoder: Decoder) throws {
let container = try decoder.container(keyedBy: CodingKeys.self)
model = try container.decode(String.self, forKey: .model)
stopReason = try container.decodeIfPresent(
Sampling.StopReason.self, forKey: .stopReason)
role = try container.decode(Sampling.Message.Role.self, forKey: .role)
content = try container.decode(Sampling.Message.Content.self, forKey: .content)
_meta = try container.decodeIfPresent(Metadata.self, forKey: ._meta)
}
}
}

View file

@ -0,0 +1,192 @@
import Foundation
/// The Model Context Protocol (MCP) provides a standardized way for servers to offer
/// autocompletion suggestions for the arguments of prompts and resource templates.
///
/// - SeeAlso: https://modelcontextprotocol.io/specification/2025-11-25/server/utilities/completion/
// MARK: - Reference Types
/// A reference to a prompt by name
public struct PromptReference: Hashable, Codable, Sendable {
/// The prompt name
public let name: String
public init(name: String) {
self.name = name
}
private enum CodingKeys: String, CodingKey {
case type, name
}
public func encode(to encoder: Encoder) throws {
var container = encoder.container(keyedBy: CodingKeys.self)
try container.encode("ref/prompt", forKey: .type)
try container.encode(name, forKey: .name)
}
public init(from decoder: Decoder) throws {
let container = try decoder.container(keyedBy: CodingKeys.self)
let type = try container.decode(String.self, forKey: .type)
guard type == "ref/prompt" else {
throw DecodingError.dataCorruptedError(
forKey: .type,
in: container,
debugDescription: "Expected ref/prompt type"
)
}
name = try container.decode(String.self, forKey: .name)
}
}
/// A reference to a resource by URI
public struct ResourceReference: Hashable, Codable, Sendable {
/// The resource URI
public let uri: String
public init(uri: String) {
self.uri = uri
}
private enum CodingKeys: String, CodingKey {
case type, uri
}
public func encode(to encoder: Encoder) throws {
var container = encoder.container(keyedBy: CodingKeys.self)
try container.encode("ref/resource", forKey: .type)
try container.encode(uri, forKey: .uri)
}
public init(from decoder: Decoder) throws {
let container = try decoder.container(keyedBy: CodingKeys.self)
let type = try container.decode(String.self, forKey: .type)
guard type == "ref/resource" else {
throw DecodingError.dataCorruptedError(
forKey: .type,
in: container,
debugDescription: "Expected ref/resource type"
)
}
uri = try container.decode(String.self, forKey: .uri)
}
}
/// A reference type for completion requests (either prompt or resource)
public enum CompletionReference: Hashable, Codable, Sendable {
/// References a prompt by name
case prompt(PromptReference)
/// References a resource URI
case resource(ResourceReference)
private enum CodingKeys: String, CodingKey {
case type
}
public init(from decoder: Decoder) throws {
let container = try decoder.container(keyedBy: CodingKeys.self)
let type = try container.decode(String.self, forKey: .type)
switch type {
case "ref/prompt":
self = .prompt(try PromptReference(from: decoder))
case "ref/resource":
self = .resource(try ResourceReference(from: decoder))
default:
throw DecodingError.dataCorruptedError(
forKey: .type,
in: container,
debugDescription: "Unknown reference type: \(type)"
)
}
}
public func encode(to encoder: Encoder) throws {
switch self {
case .prompt(let ref):
try ref.encode(to: encoder)
case .resource(let ref):
try ref.encode(to: encoder)
}
}
}
// MARK: - Completion Request
/// To get completion suggestions, clients send a `completion/complete` request.
/// - SeeAlso: https://modelcontextprotocol.io/specification/2025-11-25/server/utilities/completion/
public enum Complete: Method {
public static let name = "completion/complete"
public struct Parameters: Hashable, Codable, Sendable {
/// The reference to what is being completed
public let ref: CompletionReference
/// The argument being completed
public let argument: Argument
/// Optional context with already-resolved arguments
public let context: Context?
public init(
ref: CompletionReference,
argument: Argument,
context: Context? = nil
) {
self.ref = ref
self.argument = argument
self.context = context
}
/// The argument being completed
public struct Argument: Hashable, Codable, Sendable {
/// The argument name
public let name: String
/// The current value (partial or complete)
public let value: String
public init(name: String, value: String) {
self.name = name
self.value = value
}
}
/// Context containing already-resolved arguments
public struct Context: Hashable, Codable, Sendable {
/// A mapping of already-resolved argument names to their values
public let arguments: [String: Value]
public init(arguments: [String: Value]) {
self.arguments = arguments
}
}
}
public struct Result: Hashable, Codable, Sendable {
/// The completion result
public let completion: Completion
public init(completion: Completion) {
self.completion = completion
}
/// Completion result containing suggested values
public struct Completion: Hashable, Codable, Sendable {
/// Array of completion values (max 100 items)
public let values: [String]
/// Optional total number of available matches
public let total: Int?
/// Whether additional results exist
public let hasMore: Bool?
public init(
values: [String],
total: Int? = nil,
hasMore: Bool? = nil
) {
self.values = values
self.total = total
self.hasMore = hasMore
}
}
}
}

Some files were not shown because too many files have changed in this diff Show more