V0.11.x (#199)
* 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:
parent
6fa1a97b41
commit
6112a3995a
28 changed files with 11854 additions and 828 deletions
|
|
@ -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):
|
||||
|
|
|
|||
67
Sources/MCP/Base/Icon.swift
Normal file
67
Sources/MCP/Base/Icon.swift
Normal 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
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
259
Sources/MCP/Base/Transports/HTTPServer/HTTPServerTypes.swift
Normal file
259
Sources/MCP/Base/Transports/HTTPServer/HTTPServerTypes.swift
Normal 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
|
||||
}
|
||||
}
|
||||
|
|
@ -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()
|
||||
}
|
||||
}
|
||||
|
|
@ -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()
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
///
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
29
Sources/MCP/Base/Utilities/Cancellation.swift
Normal file
29
Sources/MCP/Base/Utilities/Cancellation.swift
Normal 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
|
||||
}
|
||||
}
|
||||
}
|
||||
153
Sources/MCP/Base/Utilities/Progress.swift
Normal file
153
Sources/MCP/Base/Utilities/Progress.swift
Normal 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)
|
||||
}
|
||||
}
|
||||
34
Sources/MCP/Base/Utilities/RequestContext.swift
Normal file
34
Sources/MCP/Base/Utilities/RequestContext.swift
Normal 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
|
||||
}
|
||||
}
|
||||
|
|
@ -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",
|
||||
]
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
233
Sources/MCP/Client/Elicitation.swift
Normal file
233
Sources/MCP/Client/Elicitation.swift
Normal 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
|
||||
}
|
||||
}
|
||||
}
|
||||
93
Sources/MCP/Client/Roots.swift
Normal file
93
Sources/MCP/Client/Roots.swift
Normal 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
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
192
Sources/MCP/Server/Completion.swift
Normal file
192
Sources/MCP/Server/Completion.swift
Normal 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
Loading…
Reference in a new issue