diff --git a/README.md b/README.md index 5bf1aaaa..182f94de 100644 --- a/README.md +++ b/README.md @@ -7,8 +7,9 @@ Official Swift SDK for the [Model Context Protocol][mcp] (MCP). The Model Context Protocol (MCP) defines a standardized way for applications to communicate with AI and ML models. This Swift SDK implements both client and server components -according to the [2025-11-25][mcp-spec-2025-11-25] (latest) version -of the MCP specification. +according to the [2026-07-28][mcp-spec-2026-07-28] (latest) version of the MCP +specification, with dual-era support for the legacy [2025-11-25][mcp-spec-2025-11-25] +`initialize` profile. ## Table of contents diff --git a/Sources/MCP/Base/Authorization/OAuthAuthorizationCodeFlow.swift b/Sources/MCP/Base/Authorization/OAuthAuthorizationCodeFlow.swift index 0c278f1c..323a13c2 100644 --- a/Sources/MCP/Base/Authorization/OAuthAuthorizationCodeFlow.swift +++ b/Sources/MCP/Base/Authorization/OAuthAuthorizationCodeFlow.swift @@ -23,6 +23,8 @@ protocol OAuthAuthorizationCodeFlowing: Sendable { authorizationURL: URL, redirectURI: URL, state: String, + expectedIssuer: String?, + issuerRequired: Bool, delegate: (any OAuthAuthorizationDelegate)?, session: URLSession ) async throws -> String @@ -121,6 +123,8 @@ public struct OAuthAuthorizationCodeFlow: Sendable { authorizationURL: URL, redirectURI: URL, state: String, + expectedIssuer: String? = nil, + issuerRequired: Bool = false, delegate: (any OAuthAuthorizationDelegate)?, session: URLSession ) async throws -> String { @@ -129,7 +133,9 @@ public struct OAuthAuthorizationCodeFlow: Sendable { return try extractCode( from: redirectURL, expectedRedirectURI: redirectURI, - expectedState: state + expectedState: state, + expectedIssuer: expectedIssuer, + issuerRequired: issuerRequired ) } @@ -168,7 +174,9 @@ public struct OAuthAuthorizationCodeFlow: Sendable { return try extractCode( from: redirectURL, expectedRedirectURI: redirectURI, - expectedState: state + expectedState: state, + expectedIssuer: expectedIssuer, + issuerRequired: issuerRequired ) } @@ -182,7 +190,9 @@ public struct OAuthAuthorizationCodeFlow: Sendable { public func extractCode( from redirectURL: URL, expectedRedirectURI: URL, - expectedState: String + expectedState: String, + expectedIssuer: String? = nil, + issuerRequired: Bool = false ) throws -> String { guard let redirectComponents = URLComponents(url: redirectURL, resolvingAgainstBaseURL: false), @@ -218,6 +228,20 @@ public struct OAuthAuthorizationCodeFlow: Sendable { ) } + let responseIssuer = redirectComponents.queryItems?.first(where: { + $0.name == OAuthParameterName.issuer + })?.value + if let responseIssuer { + guard let expectedIssuer, responseIssuer == expectedIssuer else { + throw OAuthAuthorizationError.authorizationServerIssuerMismatch( + expected: expectedIssuer ?? "", + actual: responseIssuer + ) + } + } else if issuerRequired { + throw OAuthAuthorizationError.authorizationResponseMissingIssuer + } + guard let code = redirectComponents.queryItems?.first(where: { $0.name == OAuthParameterName.code diff --git a/Sources/MCP/Base/Authorization/OAuthAuthorizer.swift b/Sources/MCP/Base/Authorization/OAuthAuthorizer.swift index b2e5be3b..5bf0a22d 100644 --- a/Sources/MCP/Base/Authorization/OAuthAuthorizer.swift +++ b/Sources/MCP/Base/Authorization/OAuthAuthorizer.swift @@ -115,14 +115,17 @@ public final class OAuthAuthorizer: HTTPClientAuthorizer, @unchecked Sendable { // MARK: - Mutable State private var configuration: OAuthConfiguration + private let configuredAuthentication: OAuthConfiguration.TokenEndpointAuthentication private let tokenStorage: TokenStorage private var selectedAuthorizationServer: URL? + private var lastAuthorizationServer: URL? private var protectedResourceMetadata: OAuthProtectedResourceMetadata? private var authorizationServerMetadata: OAuthAuthorizationServerMetadata? private var cachedProtectedResourceMetadataURL: URL? private var stepUpAttempts: [String: Int] = [:] private var clientRegistrationAttempted = false private var clientSecretExpiresAt: Date? + private let authorizationGate = OAuthAuthorizationGate() // MARK: - Composable Dependencies @@ -187,6 +190,7 @@ public final class OAuthAuthorizer: HTTPClientAuthorizer, @unchecked Sendable { authCodeFlow: any OAuthAuthorizationCodeFlowing ) { self.configuration = configuration + self.configuredAuthentication = configuration.authentication self.tokenStorage = tokenStorage self.scopeSelector = scopeSelector self.challengeParser = challengeParser @@ -230,6 +234,32 @@ public final class OAuthAuthorizer: HTTPClientAuthorizer, @unchecked Sendable { session: URLSession ) async throws -> Bool { try validateEndpointSecurity(for: endpoint) + try Task.checkCancellation() + try await authorizationGate.acquire() + do { + try Task.checkCancellation() + let result = try await handleChallengeAfterAcquiringGate( + statusCode: statusCode, + headers: headers, + endpoint: endpoint, + operationKey: operationKey, + session: session + ) + await authorizationGate.release() + return result + } catch { + await authorizationGate.release() + throw error + } + } + + private func handleChallengeAfterAcquiringGate( + statusCode: Int, + headers: [String: String], + endpoint: URL, + operationKey: String?, + session: URLSession + ) async throws -> Bool { let challenge = challengeParser.parseBearer(from: headers) switch statusCode { @@ -239,6 +269,7 @@ public final class OAuthAuthorizer: HTTPClientAuthorizer, @unchecked Sendable { let metadata = try await discoverProtectedResourceMetadata( endpoint: endpoint, challenge: challenge, + forceRefresh: true, session: session ) let asMetadata = try await resolveAuthorizationServerMetadata( @@ -266,6 +297,7 @@ public final class OAuthAuthorizer: HTTPClientAuthorizer, @unchecked Sendable { let metadata = try await discoverProtectedResourceMetadata( endpoint: endpoint, challenge: challenge, + forceRefresh: true, session: session ) let requestedScopes = scopeSelector.selectScopes( @@ -394,8 +426,15 @@ public final class OAuthAuthorizer: HTTPClientAuthorizer, @unchecked Sendable { private func discoverProtectedResourceMetadata( endpoint: URL, challenge: OAuthBearerChallenge?, + forceRefresh: Bool = false, session: URLSession ) async throws -> OAuthProtectedResourceMetadata { + if forceRefresh { + self.protectedResourceMetadata = nil + self.authorizationServerMetadata = nil + self.selectedAuthorizationServer = nil + self.cachedProtectedResourceMetadataURL = nil + } if let protectedResourceMetadata { let incomingURL = challenge?.resourceMetadataURL if let incomingURL, incomingURL != cachedProtectedResourceMetadataURL { @@ -501,7 +540,18 @@ public final class OAuthAuthorizer: HTTPClientAuthorizer, @unchecked Sendable { let (server, asMetadata) = try await discoveryClient.fetchAuthorizationServerMetadata( candidates: candidates, session: session) + if let previousServer = lastAuthorizationServer ?? selectedAuthorizationServer, + !authorizationServersMatch(previousServer, server) + { + tokenStorage.clear() + if case .none = configuredAuthentication { + configuration.authentication = configuredAuthentication + clientRegistrationAttempted = false + clientSecretExpiresAt = nil + } + } self.selectedAuthorizationServer = server + self.lastAuthorizationServer = server self.authorizationServerMetadata = asMetadata return asMetadata } @@ -600,6 +650,9 @@ public final class OAuthAuthorizer: HTTPClientAuthorizer, @unchecked Sendable { authorizationURL: authorizationURL, redirectURI: configuration.authorizationRedirectURI, state: state, + expectedIssuer: asMetadata.issuer?.absoluteString + ?? selectedAuthorizationServer?.absoluteString, + issuerRequired: asMetadata.authorizationResponseIssParameterSupported == true, delegate: configuration.authorizationDelegate, session: session ) @@ -871,3 +924,53 @@ public final class OAuthAuthorizer: HTTPClientAuthorizer, @unchecked Sendable { return normalized.isEmpty ? "" : normalized } } + +/// Serializes challenge handling across suspension points. OAuthAuthorizer is +/// called by an HTTP transport actor, but actor reentrancy can otherwise let two +/// concurrent 401 responses mutate registration and PKCE state at once. +private actor OAuthAuthorizationGate { + private var isHeld = false + private struct Waiter { + let id: UUID + let continuation: CheckedContinuation + } + private var waiters: [Waiter] = [] + + func acquire() async throws { + try Task.checkCancellation() + guard isHeld else { + isHeld = true + return + } + + let waiterID = UUID() + try await withTaskCancellationHandler { + try await withCheckedThrowingContinuation { + (continuation: CheckedContinuation) in + if Task.isCancelled { + continuation.resume(throwing: CancellationError()) + } else { + waiters.append(Waiter(id: waiterID, continuation: continuation)) + } + } + } onCancel: { + Task { await self.cancelWaiter(waiterID) } + } + // Once resumed, this task owns the gate. The caller checks cancellation + // inside its release-protected scope so it cannot leak that ownership. + } + + private func cancelWaiter(_ id: UUID) { + guard let index = waiters.firstIndex(where: { $0.id == id }) else { return } + let waiter = waiters.remove(at: index) + waiter.continuation.resume(throwing: CancellationError()) + } + + func release() { + guard !waiters.isEmpty else { + isHeld = false + return + } + waiters.removeFirst().continuation.resume() + } +} diff --git a/Sources/MCP/Base/Authorization/OAuthClientRegistrar.swift b/Sources/MCP/Base/Authorization/OAuthClientRegistrar.swift index 6267421c..f5bc3e42 100644 --- a/Sources/MCP/Base/Authorization/OAuthClientRegistrar.swift +++ b/Sources/MCP/Base/Authorization/OAuthClientRegistrar.swift @@ -90,6 +90,9 @@ struct OAuthClientRegistrar: Sendable { "client_name": configuration.clientName, "grant_types": grantTypes, "token_endpoint_auth_method": configuration.authentication.methodName, + "application_type": configuration.authorizationRedirectURI.scheme?.lowercased() == "https" + ? "web" + : "native", ] if !responseTypes.isEmpty { registrationPayload["response_types"] = responseTypes diff --git a/Sources/MCP/Base/Authorization/OAuthConstants.swift b/Sources/MCP/Base/Authorization/OAuthConstants.swift index 73cde0f1..cfef573b 100644 --- a/Sources/MCP/Base/Authorization/OAuthConstants.swift +++ b/Sources/MCP/Base/Authorization/OAuthConstants.swift @@ -26,6 +26,7 @@ enum OAuthParameterName { static let clientAssertionType = "client_assertion_type" static let refreshToken = "refresh_token" static let state = "state" + static let issuer = "iss" } // MARK: - OAuth Well-Known Paths diff --git a/Sources/MCP/Base/Authorization/OAuthDiscoveryClient.swift b/Sources/MCP/Base/Authorization/OAuthDiscoveryClient.swift index 8795ed7a..eeb12492 100644 --- a/Sources/MCP/Base/Authorization/OAuthDiscoveryClient.swift +++ b/Sources/MCP/Base/Authorization/OAuthDiscoveryClient.swift @@ -71,17 +71,14 @@ struct OAuthDiscoveryClient: Sendable { throw OAuthAuthorizationError.metadataDiscoveryFailed } - /// Fetches Authorization Server Metadata from candidates, preferring a response whose - /// `issuer` matches the candidate URL (RFC 8414 §3). If no candidate produces a matching - /// issuer, the first valid response is accepted and its own `issuer` is used as the - /// server identity — accommodating servers that serve metadata at one host but advertise - /// a different issuer. + /// Fetches Authorization Server Metadata from candidates and rejects responses whose + /// `issuer` does not exactly match the candidate URL (RFC 8414 §3). func fetchAuthorizationServerMetadata( candidates: [URL], session: URLSession ) async throws -> (server: URL, metadata: OAuthAuthorizationServerMetadata) { let decoder = JSONDecoder() - var firstValid: (server: URL, metadata: OAuthAuthorizationServerMetadata)? + var firstIssuerMismatch: (expected: String, actual: String)? for candidateServer in candidates { guard (try? urlValidator.validateAuthorizationServer( @@ -113,18 +110,15 @@ struct OAuthDiscoveryClient: Sendable { let asMetadata = try decoder.decode( OAuthAuthorizationServerMetadata.self, from: data) - // Prefer metadata whose issuer matches the candidate URL (RFC 8414 §3). - let issuerMatches = - asMetadata.issuer == nil - || asMetadata.issuer?.absoluteString.lowercased() - == candidateServer.absoluteString.lowercased() - if issuerMatches { + guard let issuer = asMetadata.issuer else { continue } + if issuer.absoluteString == candidateServer.absoluteString { return (server: candidateServer, metadata: asMetadata) } - // Keep as fallback in case no issuer-matching candidate is found. - if firstValid == nil { - let server = asMetadata.issuer ?? candidateServer - firstValid = (server: server, metadata: asMetadata) + if firstIssuerMismatch == nil { + firstIssuerMismatch = ( + expected: candidateServer.absoluteString, + actual: issuer.absoluteString + ) } } catch { continue @@ -132,8 +126,11 @@ struct OAuthDiscoveryClient: Sendable { } } - if let firstValid { - return firstValid + if let firstIssuerMismatch { + throw OAuthAuthorizationError.authorizationServerIssuerMismatch( + expected: firstIssuerMismatch.expected, + actual: firstIssuerMismatch.actual + ) } throw OAuthAuthorizationError.authorizationServerMetadataDiscoveryFailed } diff --git a/Sources/MCP/Base/Authorization/OAuthErrors.swift b/Sources/MCP/Base/Authorization/OAuthErrors.swift index 2f8249ce..cbc9be9e 100644 --- a/Sources/MCP/Base/Authorization/OAuthErrors.swift +++ b/Sources/MCP/Base/Authorization/OAuthErrors.swift @@ -108,6 +108,7 @@ public enum OAuthAuthorizationError: LocalizedError { /// The authorization response redirect URL is missing the `code` parameter. case authorizationResponseMissingCode + case authorizationResponseMissingIssuer /// The authorization server metadata does not include `code_challenge_methods_supported`, /// which is required for PKCE (RFC 7636). @@ -185,6 +186,8 @@ public enum OAuthAuthorizationError: LocalizedError { return "Authorization response state mismatch. Expected \(expected), got \(actual)" case .authorizationResponseMissingCode: return "Authorization response is missing the authorization code" + case .authorizationResponseMissingIssuer: + return "Authorization response is missing the required issuer (iss) parameter" case .pkceCodeChallengeMethodsMissing: return "Authorization server metadata must include code_challenge_methods_supported for PKCE" diff --git a/Sources/MCP/Base/Authorization/OAuthModels.swift b/Sources/MCP/Base/Authorization/OAuthModels.swift index 28043b9f..5b79d9ec 100644 --- a/Sources/MCP/Base/Authorization/OAuthModels.swift +++ b/Sources/MCP/Base/Authorization/OAuthModels.swift @@ -77,6 +77,7 @@ struct OAuthAuthorizationServerMetadata: Decodable, Sendable, Equatable { let codeChallengeMethodsSupported: [String]? let tokenEndpointAuthMethodsSupported: [String]? let clientIDMetadataDocumentSupported: Bool? + let authorizationResponseIssParameterSupported: Bool? enum CodingKeys: String, CodingKey { case issuer @@ -86,6 +87,7 @@ struct OAuthAuthorizationServerMetadata: Decodable, Sendable, Equatable { case codeChallengeMethodsSupported = "code_challenge_methods_supported" case tokenEndpointAuthMethodsSupported = "token_endpoint_auth_methods_supported" case clientIDMetadataDocumentSupported = "client_id_metadata_document_supported" + case authorizationResponseIssParameterSupported = "authorization_response_iss_parameter_supported" } } diff --git a/Sources/MCP/Base/Error.swift b/Sources/MCP/Base/Error.swift index b7196f07..9dbb7e34 100644 --- a/Sources/MCP/Base/Error.swift +++ b/Sources/MCP/Base/Error.swift @@ -36,9 +36,14 @@ public enum MCPError: Swift.Error, Sendable { // Server errors (-32000 to -32099) case serverError(code: Int, message: String) + case missingRequiredClientCapability([String: Value]) // MCP specific errors + /// Modern resources/read error with the requested URI in structured error data. + case resourceNotFound(uri: String) case urlElicitationRequired(message: String, elicitations: [URLElicitationInfo]) // -32042 + /// Modern protocol: requested version is unknown or unimplemented (-32022). + case unsupportedProtocolVersion(requested: String, supported: [String]) // Transport specific errors case connectionClosed @@ -51,9 +56,12 @@ public enum MCPError: Swift.Error, Sendable { case .invalidRequest: return -32600 case .methodNotFound: return -32601 case .invalidParams: return -32602 + case .resourceNotFound: return -32602 case .internalError: return -32603 case .serverError(let code, _): return code + case .missingRequiredClientCapability: return -32021 case .urlElicitationRequired: return -32042 + case .unsupportedProtocolVersion: return -32022 case .connectionClosed: return -32000 case .transportError: return -32001 } @@ -91,8 +99,15 @@ extension MCPError: LocalizedError { return "Internal error" + (detail.map { ": \($0)" } ?? "") case .serverError(_, let message): return "Server error: \(message)" + case .resourceNotFound(let uri): + return "Invalid params: Resource not found: \(uri)" + case .missingRequiredClientCapability: + return "Missing required client capability" case .urlElicitationRequired(let message, _): return "URL elicitation required: \(message)" + case .unsupportedProtocolVersion(let requested, let supported): + let list = supported.joined(separator: ", ") + return "Unsupported protocol version: \(requested). Supported: \(list)" case .connectionClosed: return "Connection closed" case .transportError(let error): @@ -114,8 +129,14 @@ extension MCPError: LocalizedError { return "Internal JSON-RPC error" case .serverError: return "Server-defined error occurred" + case .resourceNotFound: + return "The requested resource does not exist" + case .missingRequiredClientCapability: + return "The client did not declare a required capability" case .urlElicitationRequired: return "The server requires user authentication or input via external URL" + case .unsupportedProtocolVersion: + return "The requested MCP protocol version is not implemented by this server" case .connectionClosed: return "The connection to the server was closed" case .transportError(let error): @@ -183,10 +204,16 @@ extension MCPError: Codable { if let detail = detail { try container.encode(["detail": detail], forKey: .data) } + case .resourceNotFound(let uri): + try container.encode(errorDescription ?? "Resource not found", forKey: .message) + try container.encode(["uri": Value.string(uri)], forKey: .data) case .serverError(_, _): // No additional data for server errors try container.encode(errorDescription ?? "Unknown error", forKey: .message) break + case .missingRequiredClientCapability(let capabilities): + try container.encode("Missing required client capability", forKey: .message) + try container.encode(["requiredCapabilities": Value.object(capabilities)], forKey: .data) case .urlElicitationRequired(let message, let elicitations): // Encode the raw message so decode can round-trip without prefix doubling try container.encode(message, forKey: .message) @@ -203,6 +230,18 @@ extension MCPError: Codable { ["elicitations": Value.array(elicitationsData.map { .object($0) })], forKey: .data ) + case .unsupportedProtocolVersion(let requested, let supported): + try container.encode( + errorDescription ?? "Unsupported protocol version", + forKey: .message + ) + try container.encode( + [ + "requested": Value.string(requested), + "supported": Value.array(supported.map { .string($0) }), + ] as [String: Value], + forKey: .data + ) case .connectionClosed: try container.encode(errorDescription ?? "Unknown error", forKey: .message) case .transportError(let error): @@ -235,7 +274,11 @@ extension MCPError: Codable { case -32601: self = .methodNotFound(unwrapDetail(message)) case -32602: - self = .invalidParams(unwrapDetail(message)) + if let data, data.count == 1, case .string(let uri)? = data["uri"] { + self = .resourceNotFound(uri: uri) + } else { + self = .invalidParams(unwrapDetail(message)) + } case -32603: self = .internalError(unwrapDetail(nil)) case -32042: @@ -257,6 +300,30 @@ extension MCPError: Codable { } } self = .urlElicitationRequired(message: message, elicitations: elicitations) + case -32022: + let requested: String + if case .string(let value) = data?["requested"] { + requested = value + } else { + // Keep `requested` as a bare version (or empty). Do not re-feed + // the human-readable RPC message into this field. + requested = "" + } + var supported: [String] = [] + if case .array(let items) = data?["supported"] { + for item in items { + if case .string(let version) = item { + supported.append(version) + } + } + } + self = .unsupportedProtocolVersion(requested: requested, supported: supported) + case -32021: + if case .object(let capabilities) = data?["requiredCapabilities"] { + self = .missingRequiredClientCapability(capabilities) + } else { + self = .serverError(code: code, message: message) + } case -32000: self = .connectionClosed case -32001: @@ -291,8 +358,17 @@ extension MCPError: Equatable { case (.internalError(let a), .internalError(let b)): return a == b case (.serverError(let c1, let m1), .serverError(let c2, let m2)): return c1 == c2 && m1 == m2 + case (.resourceNotFound(let a), .resourceNotFound(let b)): + return a == b + case (.missingRequiredClientCapability(let c1), .missingRequiredClientCapability(let c2)): + return c1 == c2 case (.urlElicitationRequired(let m1, let e1), .urlElicitationRequired(let m2, let e2)): return m1 == m2 && e1 == e2 + case ( + .unsupportedProtocolVersion(let r1, let s1), + .unsupportedProtocolVersion(let r2, let s2) + ): + return r1 == r2 && s1 == s2 case (.connectionClosed, .connectionClosed): return true case (.transportError(let a), .transportError(let b)): return a.localizedDescription == b.localizedDescription @@ -319,9 +395,19 @@ extension MCPError: Hashable { hasher.combine(detail) case .serverError(_, let message): hasher.combine(message) + case .resourceNotFound(let uri): + hasher.combine(uri) + case .missingRequiredClientCapability(let capabilities): + for key in capabilities.keys.sorted() { + hasher.combine(key) + hasher.combine(capabilities[key]) + } case .urlElicitationRequired(let message, let elicitations): hasher.combine(message) hasher.combine(elicitations) + case .unsupportedProtocolVersion(let requested, let supported): + hasher.combine(requested) + hasher.combine(supported) case .connectionClosed: break case .transportError(let error): diff --git a/Sources/MCP/Base/ID.swift b/Sources/MCP/Base/ID.swift index 271b6150..95c0b61e 100644 --- a/Sources/MCP/Base/ID.swift +++ b/Sources/MCP/Base/ID.swift @@ -14,6 +14,17 @@ public enum ID: Hashable, Sendable { } } +extension ID { + /// Unambiguous key for transport bookkeeping. JSON-RPC string and numeric + /// ids share their textual description, so maps must retain the id kind. + package var transportKey: String { + switch self { + case .string(let value): "string:\(value)" + case .number(let value): "number:\(value)" + } + } +} + // MARK: - ExpressibleByStringLiteral extension ID: ExpressibleByStringLiteral { diff --git a/Sources/MCP/Base/Lifecycle.swift b/Sources/MCP/Base/Lifecycle.swift index 08ea967c..af741cff 100644 --- a/Sources/MCP/Base/Lifecycle.swift +++ b/Sources/MCP/Base/Lifecycle.swift @@ -14,7 +14,7 @@ public enum Initialize: Method { public let clientInfo: Client.Info public init( - protocolVersion: String = Version.latest, + protocolVersion: String = Version.latestLegacy, capabilities: Client.Capabilities, clientInfo: Client.Info ) { @@ -31,7 +31,7 @@ public enum Initialize: Method { let container = try decoder.container(keyedBy: CodingKeys.self) protocolVersion = try container.decodeIfPresent(String.self, forKey: .protocolVersion) - ?? Version.latest + ?? Version.latestLegacy capabilities = try container.decodeIfPresent(Client.Capabilities.self, forKey: .capabilities) ?? .init() diff --git a/Sources/MCP/Base/ModernProtocol.swift b/Sources/MCP/Base/ModernProtocol.swift new file mode 100644 index 00000000..08927e9a --- /dev/null +++ b/Sources/MCP/Base/ModernProtocol.swift @@ -0,0 +1,176 @@ +import Foundation + +/// Reserved `_meta` keys for MCP 2026-07-28 per-request protocol fields. +/// +/// - SeeAlso: https://modelcontextprotocol.io/specification/2026-07-28/basic/index#meta +public enum ProtocolMetaKey { + public static let protocolVersion = "io.modelcontextprotocol/protocolVersion" + public static let clientInfo = "io.modelcontextprotocol/clientInfo" + public static let clientCapabilities = "io.modelcontextprotocol/clientCapabilities" + public static let serverInfo = "io.modelcontextprotocol/serverInfo" + public static let subscriptionId = "io.modelcontextprotocol/subscriptionId" + public static let logLevel = "io.modelcontextprotocol/logLevel" +} + +/// Result discriminant for modern protocol responses. +/// +/// - SeeAlso: https://modelcontextprotocol.io/specification/2026-07-28/basic/index#result-responses +public enum ResultType: String, Hashable, Codable, Sendable { + case complete + case inputRequired = "input_required" +} + +/// Helpers for reading modern request `_meta` from type-erased JSON-RPC params. +public enum ModernRequestMeta { + /// Extracts the protocol version from `params._meta`, if present. + public static func protocolVersion(from params: Value) -> String? { + metaObject(from: params)?[ProtocolMetaKey.protocolVersion]?.stringValue + } + + /// Extracts the nested `_meta` object from request params. + public static func metaObject(from params: Value) -> [String: Value]? { + guard case .object(let root) = params, + case .object(let meta) = root["_meta"] + else { return nil } + return meta + } + + /// Whether the request params declare a modern protocol version. + public static func isModernRequest(params: Value) -> Bool { + guard let version = protocolVersion(from: params) else { return false } + return Version.isModern(version) + } + + /// Per-request client capabilities from `_meta` (empty object allowed). + public static func clientCapabilities(from params: Value) -> Value? { + metaObject(from: params)?[ProtocolMetaKey.clientCapabilities] + } + + /// Modern requests MUST carry `io.modelcontextprotocol/clientCapabilities` as an object. + public static func hasRequiredClientCapabilities(params: Value) -> Bool { + guard let caps = clientCapabilities(from: params), + case .object = caps + else { return false } + return true + } +} + +/// A modern `input_required` result (MRTR). +/// +/// At least one of `inputRequests` or `requestState` must be present. +/// +/// - Important: This type models the wire schema for MRTR. End-to-end emission +/// from typed `withMethodHandler` results and client-side recovery before +/// typed decode are not wired yet; do not build application flows on it until +/// that path lands. +/// +/// - SeeAlso: https://modelcontextprotocol.io/specification/2026-07-28/basic/patterns/mrtr +public struct InputRequiredResult: Hashable, Codable, Sendable { + public let resultType: ResultType + public var inputRequests: [String: Value]? + public var requestState: String? + public var _meta: Metadata? + + /// Creates an `input_required` result with input requests (and optional state). + public init( + inputRequests: [String: Value], + requestState: String? = nil, + _meta: Metadata? = nil + ) { + self.resultType = .inputRequired + self.inputRequests = inputRequests + self.requestState = requestState + self._meta = _meta + } + + /// Creates an `input_required` result from opaque request state only. + public init(requestState: String, _meta: Metadata? = nil) { + self.resultType = .inputRequired + self.inputRequests = nil + self.requestState = requestState + self._meta = _meta + } + + private enum CodingKeys: String, CodingKey { + case resultType, inputRequests, requestState, _meta + } + + public init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + resultType = try container.decode(ResultType.self, forKey: .resultType) + inputRequests = try container.decodeIfPresent([String: Value].self, forKey: .inputRequests) + requestState = try container.decodeIfPresent(String.self, forKey: .requestState) + _meta = try container.decodeIfPresent(Metadata.self, forKey: ._meta) + guard inputRequests != nil || requestState != nil else { + throw DecodingError.dataCorruptedError( + forKey: .inputRequests, + in: container, + debugDescription: + "input_required results require inputRequests and/or requestState" + ) + } + guard resultType == .inputRequired else { + throw DecodingError.dataCorruptedError( + forKey: .resultType, + in: container, + debugDescription: "InputRequiredResult requires resultType input_required" + ) + } + } + + public func encode(to encoder: Encoder) throws { + guard inputRequests != nil || requestState != nil else { + throw EncodingError.invalidValue( + self, + EncodingError.Context( + codingPath: encoder.codingPath, + debugDescription: + "input_required results require inputRequests and/or requestState" + ) + ) + } + var container = encoder.container(keyedBy: CodingKeys.self) + try container.encode(resultType, forKey: .resultType) + try container.encodeIfPresent(inputRequests, forKey: .inputRequests) + try container.encodeIfPresent(requestState, forKey: .requestState) + try container.encodeIfPresent(_meta, forKey: ._meta) + } +} + +extension Value { + /// Ensures a modern success result object carries `resultType: "complete"` + /// and optional server identity metadata. + public func ensuringModernCompleteResult( + serverInfo: [String: Value]? = nil, + cacheHints: Bool = false + ) -> Value { + guard case .object(var object) = self else { + // Non-object success payloads cannot carry `resultType` without inventing + // a non-schema wrapper key. Leave them unchanged; modern Method.Result + // values are expected to be objects. + return self + } + + if object["resultType"] == nil { + object["resultType"] = .string(ResultType.complete.rawValue) + } + + if cacheHints { + object["cacheScope"] = object["cacheScope"] ?? .string("private") + object["ttlMs"] = object["ttlMs"] ?? .int(0) + } + + if let serverInfo { + var meta: [String: Value] = [:] + if case .object(let existing) = object["_meta"] { + meta = existing + } + if meta[ProtocolMetaKey.serverInfo] == nil { + meta[ProtocolMetaKey.serverInfo] = .object(serverInfo) + } + object["_meta"] = .object(meta) + } + + return .object(object) + } +} diff --git a/Sources/MCP/Base/Transport.swift b/Sources/MCP/Base/Transport.swift index 4e4350fd..7866f3e5 100644 --- a/Sources/MCP/Base/Transport.swift +++ b/Sources/MCP/Base/Transport.swift @@ -18,3 +18,12 @@ public protocol Transport: Actor { /// Receives data in an async sequence func receive() -> AsyncThrowingStream } + +/// Client transports that can abort an in-flight request's response stream. +/// +/// ``Client/cancelRequest(_:reason:)`` uses this so a cancelled +/// `subscriptions/listen` (or any long-lived POST SSE) actually closes the +/// HTTP connection instead of only sending `notifications/cancelled`. +public protocol RequestStreamCancelling: Sendable { + func cancelInFlightRequestStream(for id: ID) async +} diff --git a/Sources/MCP/Base/Transports/HTTPClientTransport.swift b/Sources/MCP/Base/Transports/HTTPClientTransport.swift index 060cd133..73452fa6 100644 --- a/Sources/MCP/Base/Transports/HTTPClientTransport.swift +++ b/Sources/MCP/Base/Transports/HTTPClientTransport.swift @@ -21,6 +21,7 @@ import Logging /// - 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 +/// - A stable per-transport `MCP-Client-Identity` capability for sessionless cancellation /// - Protocol version negotiation via `MCP-Protocol-Version` header /// - Automatic reconnection for dropped SSE streams with resumability support /// - Platform-specific optimizations for different operating systems @@ -53,10 +54,12 @@ import Logging /// // The transport will automatically handle SSE events /// // and deliver them through the client's notification handlers /// ``` -public actor HTTPClientTransport: Transport { +public actor HTTPClientTransport: Transport, RequestStreamCancelling { /// The server endpoint URL to connect to public let endpoint: URL private let session: URLSession + /// Stable high-entropy identity used to scope sessionless cancellation across HTTP connections. + private let clientIdentity = UUID().uuidString /// The session ID assigned by the server, used for maintaining state across requests public private(set) var sessionID: String? @@ -96,6 +99,12 @@ public actor HTTPClientTransport: Transport { /// Used to trigger reconnection when a POST SSE stream closes without delivering data. private var activeGETSessionTask: URLSessionDataTask? + /// In-flight POST response tasks keyed by JSON-RPC request id string. + /// Cancelled by ``cancelInFlightRequestStream(for:)`` so listen refresh + /// does not leak open SSE connections. + private var inFlightPOSTTasks: [String: URLSessionTask] = [:] + private var toolParameterHeaders: [String: [MCPToolHeaderMapping]] = [:] + /// Creates a new HTTP transport client with the specified endpoint /// /// - Parameters: @@ -103,7 +112,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") + /// - protocolVersion: The MCP protocol version to use (default: latest legacy, currently "2025-11-25") /// - authorizer: Optional ``HTTPClientAuthorizer`` for automatic Bearer token acquisition and retries. /// - requestModifier: Optional closure to customize requests before they are sent (default: no modification) /// - logger: Optional logger instance for transport events @@ -112,7 +121,7 @@ public actor HTTPClientTransport: Transport { configuration: URLSessionConfiguration = .default, streaming: Bool = true, sseInitializationTimeout: TimeInterval = 10, - protocolVersion: String = Version.latest, + protocolVersion: String = Version.latestLegacy, authorizer: (any HTTPClientAuthorizer)? = nil, requestModifier: @escaping (URLRequest) -> URLRequest = { $0 }, logger: Logger? = nil @@ -134,7 +143,7 @@ public actor HTTPClientTransport: Transport { session: URLSession, streaming: Bool = false, sseInitializationTimeout: TimeInterval = 10, - protocolVersion: String = Version.latest, + protocolVersion: String = Version.latestLegacy, authorizer: (any HTTPClientAuthorizer)? = nil, requestModifier: @escaping (URLRequest) -> URLRequest = { $0 }, logger: Logger? = nil @@ -183,11 +192,19 @@ public actor HTTPClientTransport: Transport { /// Establishes connection with the transport public func connect() async throws { guard !isConnected else { return } + #if os(Linux) + guard !(protocolVersion.map(Version.isModern) ?? false) else { + throw MCPError.internalError( + "Modern Streamable HTTP is not supported on Linux because POST SSE streams cannot be consumed" + ) + } + #endif isConnected = true setupInitialSessionIDSignal() - if streaming { + // Legacy GET notification stream is not used on the modern profile. + if streaming, !(protocolVersion.map(Version.isModern) ?? false) { streamingTask = Task { await startListeningForServerEvents() } } @@ -218,11 +235,47 @@ public actor HTTPClientTransport: Transport { self.protocolVersion = version } + func updateToolDefinitions(_ tools: [Tool], reset: Bool) -> [Tool] { + var names = Set() + if !reset { names.formUnion(toolParameterHeaders.keys) } + var validTools: [Tool] = [] + var newMappings: [String: [MCPToolHeaderMapping]] = [:] + for tool in tools { + guard names.insert(tool.name).inserted else { continue } + let schema = Self.jsonObject(tool.inputSchema) + guard let mappings = MCPToolHeaderSupport.validatedMappings(in: schema) else { continue } + validTools.append(tool) + newMappings[tool.name] = mappings + } + if reset { toolParameterHeaders.removeAll() } + toolParameterHeaders.merge(newMappings) { _, new in new } + return validTools + } + + func hasToolDefinition(_ name: String) -> Bool { + toolParameterHeaders[name] != nil + } + + private static func jsonObject(_ value: Value) -> [String: Any] { + guard let data = try? JSONEncoder().encode(value), + let object = try? JSONSerialization.jsonObject(with: data) as? [String: Any] + else { return [:] } + return object + } + /// Sends data through an HTTP POST request public func send(_ data: Data) async throws { guard isConnected else { throw MCPError.internalError("Transport not connected") } + #if os(Linux) + let requestVersion = JSONRPCMessageKind.protocolVersion(inBody: data) ?? protocolVersion + guard !(requestVersion.map(Version.isModern) ?? false) else { + throw MCPError.internalError( + "Modern Streamable HTTP is not supported on Linux because POST SSE streams cannot be consumed" + ) + } + #endif if let authorizer { do { @@ -249,10 +302,28 @@ public actor HTTPClientTransport: Transport { forHTTPHeaderField: HTTPHeaderName.accept ) request.addValue(ContentType.json, forHTTPHeaderField: HTTPHeaderName.contentType) + request.setValue(clientIdentity, forHTTPHeaderField: HTTPHeaderName.clientIdentity) request.httpBody = data - if let protocolVersion = protocolVersion { - request.addValue(protocolVersion, forHTTPHeaderField: HTTPHeaderName.protocolVersion) + // Prefer per-request body `_meta` so modern Discover/listen agree with the header. + let headerVersion = + JSONRPCMessageKind.protocolVersion(inBody: data) ?? protocolVersion + if let headerVersion { + request.addValue(headerVersion, forHTTPHeaderField: HTTPHeaderName.protocolVersion) + if Version.isModern(headerVersion), + let message = JSONRPCMessageKind(data: data), + let method = message.methodName + { + request.setValue(method, forHTTPHeaderField: HTTPHeaderName.mcpMethod) + if let name = Self.mcpName(inBody: data, method: method) { + request.setValue(name, forHTTPHeaderField: HTTPHeaderName.mcpName) + } + if Version.isModern(headerVersion), + JSONRPCMessageKind(data: data)?.methodName == CallTool.name + { + Self.addToolParameterHeaders(to: &request, body: data, mappings: toolParameterHeaders) + } + } } if let sessionID = sessionID { @@ -265,13 +336,28 @@ public actor HTTPClientTransport: Transport { request = requestModifier(request) + let requestIDKey = JSONRPCMessageKind.routingIDKey(from: data) do { #if os(Linux) let (responseData, response) = try await session.data(for: request) - try await processResponse(response: response, data: responseData) + try await processResponse( + response: response, data: responseData, + expectedRequestID: requestIDKey + ) #else let (responseStream, response) = try await session.bytes(for: request) - try await processResponse(response: response, stream: responseStream) + if let requestIDKey { + inFlightPOSTTasks[requestIDKey] = responseStream.task + } + defer { + if let requestIDKey { + inFlightPOSTTasks.removeValue(forKey: requestIDKey) + } + } + try await processResponse( + response: response, stream: responseStream, + expectedRequestID: requestIDKey + ) #endif return } catch let authError as HTTPAuthenticationChallengeError { @@ -305,7 +391,9 @@ public actor HTTPClientTransport: Transport { } #if os(Linux) - private func processResponse(response: URLResponse, data: Data) async throws { + private func processResponse( + response: URLResponse, data: Data, expectedRequestID: String? + ) async throws { guard let httpResponse = response as? HTTPURLResponse else { throw MCPError.internalError("Invalid HTTP response") } @@ -321,7 +409,10 @@ public actor HTTPClientTransport: Transport { logger.debug("Session ID received", metadata: ["sessionID": "\(newSessionID)"]) } - try processHTTPResponse(httpResponse, contentType: contentType) + try processHTTPResponse( + httpResponse, contentType: contentType, body: data, + expectedRequestID: expectedRequestID + ) guard case 200..<300 = httpResponse.statusCode else { return } if contentType.contains(ContentType.sse) { @@ -335,7 +426,10 @@ public actor HTTPClientTransport: Transport { } } #else - private func processResponse(response: URLResponse, stream: URLSession.AsyncBytes) + private func processResponse( + response: URLResponse, stream: URLSession.AsyncBytes, + expectedRequestID: String? + ) async throws { guard let httpResponse = response as? HTTPURLResponse else { @@ -353,8 +447,20 @@ public actor HTTPClientTransport: Transport { logger.debug("Session ID received", metadata: ["sessionID": "\(newSessionID)"]) } - try processHTTPResponse(httpResponse, contentType: contentType) - guard case 200..<300 = httpResponse.statusCode else { return } + if !(200..<300).contains(httpResponse.statusCode) { + var errorBody = Data() + if contentType.contains(ContentType.json) || contentType.isEmpty { + let maxErrorBodyBytes = 16_384 + for try await byte in stream { + if errorBody.count >= maxErrorBodyBytes { break } + errorBody.append(byte) + } + } + try processHTTPResponse( + httpResponse, contentType: contentType, body: errorBody, + expectedRequestID: expectedRequestID) + return + } if contentType.contains(ContentType.sse) { logger.trace("Received SSE response, processing in streaming task") @@ -377,12 +483,38 @@ public actor HTTPClientTransport: Transport { } #endif - private func processHTTPResponse(_ response: HTTPURLResponse, contentType: String) throws { + + /// Parses a JSON-RPC error object from an HTTP error body when present. + private static func jsonRPCError(from body: Data?) -> MCPError? { + guard let body, !body.isEmpty else { return nil } + // Prefer a full JSON-RPC response envelope. + if let response = try? JSONDecoder().decode(AnyResponse.self, from: body), + case .failure(let error) = response.result + { + return error + } + // Some servers may return a bare `{ "error": ... }` object. + struct ErrorEnvelope: Decodable { let error: MCPError } + if let envelope = try? JSONDecoder().decode(ErrorEnvelope.self, from: body) { + return envelope.error + } + return nil + } + + private func processHTTPResponse( + _ response: HTTPURLResponse, + contentType: String, + body: Data? = nil, + expectedRequestID: String? = nil + ) throws { switch response.statusCode { case 200..<300: return case 400: + if let mcpError = Self.jsonRPCError(from: body) { + throw mcpError + } throw MCPError.internalError("Bad request") case 401: @@ -398,6 +530,12 @@ public actor HTTPClientTransport: Transport { ) case 404: + if let body, let expectedRequestID, + JSONRPCMessageKind.routingIDKey(from: body) == expectedRequestID, + let mcpError = Self.jsonRPCError(from: body) + { + throw mcpError + } if sessionID != nil { logger.warning("Session has expired") sessionID = nil @@ -447,6 +585,41 @@ public actor HTTPClientTransport: Transport { return headers } + private static func mcpName(inBody body: Data, method: String) -> String? { + guard let json = try? JSONSerialization.jsonObject(with: body) as? [String: Any], + let params = json["params"] as? [String: Any] + else { return nil } + let field: String + switch method { + case "tools/call", "prompts/get": field = "name" + case "resources/read": field = "uri" + case "tasks/get", "tasks/update", "tasks/cancel": field = "taskId" + default: return nil + } + return params[field] as? String + } + + private static func addToolParameterHeaders( + to request: inout URLRequest, + body: Data, + mappings: [String: [MCPToolHeaderMapping]] + ) { + guard let json = try? JSONSerialization.jsonObject(with: body) as? [String: Any], + let params = json["params"] as? [String: Any], + let name = params["name"] as? String, + let arguments = params["arguments"] as? [String: Any] + else { return } + for mapping in mappings[name] ?? [] { + guard let value = MCPToolHeaderSupport.lookupValue(at: mapping.path, in: arguments), !(value is NSNull), + let text = MCPToolHeaderSupport.primitiveString(value) + else { continue } + request.setValue( + MCPToolHeaderSupport.encodeHeaderValue(text), + forHTTPHeaderField: "Mcp-Param-\(mapping.suffix)" + ) + } + } + private func jsonRPCOperationKey(from data: Data) -> String? { guard let jsonObject = try? JSONSerialization.jsonObject(with: data) as? [String: Any], @@ -464,6 +637,13 @@ public actor HTTPClientTransport: Transport { return messageStream } + public func cancelInFlightRequestStream(for id: ID) { + let key = id.transportKey + guard let task = inFlightPOSTTasks.removeValue(forKey: key) else { return } + logger.debug("Cancelling in-flight POST stream", metadata: ["requestID": "\(key)"]) + task.cancel() + } + // MARK: - SSE private func startListeningForServerEvents() async { @@ -588,6 +768,7 @@ public actor HTTPClientTransport: Transport { var request = URLRequest(url: endpoint) request.httpMethod = "GET" + request.setValue(clientIdentity, forHTTPHeaderField: HTTPHeaderName.clientIdentity) request.addValue(ContentType.sse, forHTTPHeaderField: HTTPHeaderName.accept) request.addValue("no-cache", forHTTPHeaderField: HTTPHeaderName.cacheControl) diff --git a/Sources/MCP/Base/Transports/HTTPServer/HTTPRequestValidation.swift b/Sources/MCP/Base/Transports/HTTPServer/HTTPRequestValidation.swift index 51c7aaed..3a4013d6 100644 --- a/Sources/MCP/Base/Transports/HTTPServer/HTTPRequestValidation.swift +++ b/Sources/MCP/Base/Transports/HTTPServer/HTTPRequestValidation.swift @@ -1,3 +1,4 @@ +import CoreFoundation import Foundation // MARK: - Validation Protocol @@ -38,18 +39,39 @@ public struct HTTPValidationContext: Sendable { /// Whether the request body contains an `initialize` JSON-RPC request. public let isInitializationRequest: Bool + /// Whether the request is a modern `server/discover` call. + public let isDiscoverRequest: Bool + + /// Whether the request declares a modern (2026-07-28+) protocol version. + public let isModernRequest: Bool + + /// Protocol version from `params._meta.io.modelcontextprotocol/protocolVersion`, if present. + /// When set, this is authoritative for modern requests and must agree with the HTTP header. + public let bodyProtocolVersion: String? + /// The set of protocol versions this server supports. public let supportedProtocolVersions: Set + /// Requests that do not require a legacy MCP session id. + public var isSessionExempt: Bool { + isInitializationRequest || isDiscoverRequest || isModernRequest + } + public init( httpMethod: String, sessionID: String? = nil, isInitializationRequest: Bool = false, + isDiscoverRequest: Bool = false, + isModernRequest: Bool = false, + bodyProtocolVersion: String? = nil, supportedProtocolVersions: Set = Version.supported ) { self.httpMethod = httpMethod self.sessionID = sessionID self.isInitializationRequest = isInitializationRequest + self.isDiscoverRequest = isDiscoverRequest + self.isModernRequest = isModernRequest + self.bodyProtocolVersion = bodyProtocolVersion self.supportedProtocolVersions = supportedProtocolVersions } } @@ -143,7 +165,7 @@ public struct ContentTypeValidator: HTTPRequestValidator { guard mainType == ContentType.json else { return .error( statusCode: 415, - .invalidRequest( + .invalidParams( "Unsupported Media Type: Content-Type must be application/json" ), sessionID: context.sessionID @@ -156,39 +178,370 @@ public struct ContentTypeValidator: HTTPRequestValidator { // MARK: - Protocol Version Validator -/// Validates the `MCP-Protocol-Version` header against supported versions. +/// Validates protocol version from the `MCP-Protocol-Version` header and/or body `_meta`. /// -/// 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) +/// Per MCP 2026-07-28: +/// - Body `_meta` version is authoritative for modern requests +/// - Header and body MUST agree when both are present +/// - Unsupported versions return HTTP 400 with ``MCPError/unsupportedProtocolVersion(requested:supported:)`` (-32022) +/// - Legacy: if neither is present, assume the negotiated version +/// - Initialization requests are exempt (version is negotiated in the initialize 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) + // Skip for initialization requests (version is in the initialize body) 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 + let headerVersion = request.header(HTTPHeaderName.protocolVersion) + let bodyVersion = context.bodyProtocolVersion + let supported = Array(Array(context.supportedProtocolVersions).sorted().reversed()) + + if let bodyVersion, let headerVersion, headerVersion != bodyVersion { + // Both values may be supported — this is disagreement, not "unsupported". + return .error( + statusCode: 400, + .serverError( + code: -32020, + message: "MCP-Protocol-Version header (\(headerVersion)) disagrees with params._meta.\(ProtocolMetaKey.protocolVersion) (\(bodyVersion))" + ), + sessionID: context.sessionID + ) + } + + // A modern header without body `_meta.protocolVersion` would otherwise be + // accepted here and then mishandled as legacy by the server. Only JSON-RPC + // *requests* need `_meta`; responses/notifications and GET/DELETE do not. + if context.httpMethod == "POST", + let headerVersion, Version.isModern(headerVersion), bodyVersion == nil, + Self.isJSONRPCRequest(request.body) + { + return .error( + statusCode: 400, + .invalidParams( + "Modern MCP-Protocol-Version requires params._meta.\(ProtocolMetaKey.protocolVersion)" + ), + sessionID: context.sessionID + ) + } + + let version = bodyVersion ?? headerVersion + guard let version else { + // Modern-classified POSTs (discover/listen / modern body) require a version. + if context.httpMethod == "POST", + context.isModernRequest, + Self.isJSONRPCRequest(request.body) + { + return .error( + statusCode: 400, + .invalidParams( + "Modern requests require params._meta.\(ProtocolMetaKey.protocolVersion)" + ), + sessionID: context.sessionID + ) + } + // Legacy: 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)" - ), + .unsupportedProtocolVersion(requested: version, supported: supported), sessionID: context.sessionID ) } return nil } + + private static func isJSONRPCRequest(_ body: Data?) -> Bool { + guard let body, + let json = try? JSONSerialization.jsonObject(with: body) as? [String: Any], + json["method"] is String, + json["id"] != nil + else { return false } + return true + } +} + +/// Validates SEP-2243 routing headers on modern Streamable HTTP POST requests. +public struct MCPRoutingHeaderValidator: HTTPRequestValidator { + public init() {} + + public func validate(_ request: HTTPRequest, context: HTTPValidationContext) -> HTTPResponse? { + guard context.httpMethod == "POST", context.isModernRequest, + let body = request.body, + let json = try? JSONSerialization.jsonObject(with: body) as? [String: Any], + let method = json["method"] as? String + else { return nil } + + guard let methodHeader = request.header(HTTPHeaderName.mcpMethod)? + .trimmingCharacters(in: .whitespacesAndNewlines), !methodHeader.isEmpty + else { + return mismatch("Missing \(HTTPHeaderName.mcpMethod) header") + } + guard methodHeader == method else { + return mismatch("\(HTTPHeaderName.mcpMethod) header does not match the JSON-RPC method") + } + + guard let params = json["params"] as? [String: Any], + let nameField = Self.nameField(for: method), + let expectedName = params[nameField] as? String + else { return nil } + guard let nameHeader = request.header(HTTPHeaderName.mcpName)? + .trimmingCharacters(in: .whitespacesAndNewlines), !nameHeader.isEmpty + else { + return mismatch("Missing \(HTTPHeaderName.mcpName) header") + } + guard nameHeader == expectedName else { + return mismatch("\(HTTPHeaderName.mcpName) header does not match params.\(nameField)") + } + return nil + } + + private static func nameField(for method: String) -> String? { + switch method { + case "tools/call", "prompts/get": return "name" + case "resources/read": return "uri" + case "tasks/get", "tasks/update", "tasks/cancel": return "taskId" + default: return nil + } + } + + private func mismatch(_ message: String) -> HTTPResponse { + .error(statusCode: 400, .serverError(code: -32020, message: message)) + } +} + +/// Shared SEP-2243 helpers for schema-driven `Mcp-Param-*` headers. +package struct MCPToolHeaderMapping { + package let path: [String] + package let suffix: String + package let requiredPath: [Bool] +} + +/// Per-caller tool schema cache shared by HTTP server transport profiles. +package struct MCPToolSchemaRegistry { + private static let maximumCallerCount = 256 + private var schemasByCaller: [String: [String: Any]] = [:] + private var callerInsertionOrder: [String] = [] + private var completeCallers = Set() + + package init() {} + + package mutating func record(responseData: Data, for request: HTTPRequest) { + guard let schemas = MCPToolHeaderSupport.schemaMap(from: responseData) else { return } + let key = request.toolSchemaCacheKey + let hadEntry = schemasByCaller[key] != nil + let isToolsListRequest = Self.isToolsListRequest(request) + let hasNextCursor = Self.hasNextCursor(in: responseData) + if schemasByCaller[key] == nil { + if callerInsertionOrder.count == Self.maximumCallerCount { + let evictedKey = callerInsertionOrder.removeFirst() + schemasByCaller.removeValue(forKey: evictedKey) + completeCallers.remove(evictedKey) + } + callerInsertionOrder.append(key) + schemasByCaller[key] = schemas + } else if request.startsToolListAtFirstPage { + schemasByCaller[key] = schemas + } else { + schemasByCaller[key, default: [:]].merge(schemas) { _, new in new } + } + + if request.startsToolListAtFirstPage { + if hasNextCursor { + completeCallers.remove(key) + } else { + completeCallers.insert(key) + } + } else if isToolsListRequest && hasNextCursor { + completeCallers.remove(key) + } else if isToolsListRequest { + if hadEntry { completeCallers.insert(key) } + } else { + // The server-side provider returns one response containing every page. + completeCallers.insert(key) + } + } + + package func schemas(for request: HTTPRequest) -> [String: Any] { + schemasByCaller[request.toolSchemaCacheKey] ?? [:] + } + + package func isComplete(for request: HTTPRequest) -> Bool { + completeCallers.contains(request.toolSchemaCacheKey) + } + + package mutating func invalidateAll() { + schemasByCaller.removeAll(keepingCapacity: true) + callerInsertionOrder.removeAll(keepingCapacity: true) + completeCallers.removeAll(keepingCapacity: true) + } + + private static func isToolsListRequest(_ request: HTTPRequest) -> Bool { + guard let body = request.body, + let root = try? JSONSerialization.jsonObject(with: body) as? [String: Any] + else { return false } + return root["method"] as? String == ListTools.name + } + + private static func hasNextCursor(in responseData: Data) -> Bool { + guard let root = try? JSONSerialization.jsonObject(with: responseData) as? [String: Any], + let result = root["result"] as? [String: Any] + else { return false } + return result["nextCursor"] is String + } +} + +package enum MCPToolHeaderSupport { + package static func toolName(in body: Data) -> String? { + guard let root = try? JSONSerialization.jsonObject(with: body) as? [String: Any], + let params = root["params"] as? [String: Any] + else { return nil } + return params["name"] as? String + } + + package static func schemaMap(from responseData: Data) -> [String: Any]? { + guard let root = try? JSONSerialization.jsonObject(with: responseData) as? [String: Any], + let result = root["result"] as? [String: Any], + let tools = result["tools"] as? [[String: Any]] + else { return nil } + return tools.reduce(into: [String: Any]()) { result, tool in + guard let name = tool["name"] as? String, + let schema = tool["inputSchema"], result[name] == nil + else { return } + result[name] = schema + } + } + + package static func validateCall( + body: Data, request: HTTPRequest, schemas: [String: Any] + ) -> String? { + guard let root = try? JSONSerialization.jsonObject(with: body) as? [String: Any], + let params = root["params"] as? [String: Any], + let name = params["name"] as? String, + let schema = schemas[name] as? [String: Any] + else { return nil } + let args = params["arguments"] as? [String: Any] ?? [:] + guard let mappings = validatedMappings(in: schema) else { + return "Tool \(name) declares invalid x-mcp-header annotations" + } + for mapping in mappings { + let value = lookupValue(at: mapping.path, in: args) + let parameter = mapping.path.joined(separator: ".") + let headerName = "Mcp-Param-\(mapping.suffix)" + let headerValue = request.header(headerName) + if value == nil || value is NSNull { + if headerValue != nil { + return "\(headerName) must be omitted when \(parameter) is null or omitted" + } + let parentExists = lookupValue(at: Array(mapping.path.dropLast()), in: args) != nil + if mapping.requiredPath.allSatisfy({ $0 }) + || (parentExists && mapping.requiredPath.last == true) + { return "Required parameter \(parameter) is missing" } + continue + } + guard let expected = primitiveString(value!), let headerValue else { + return "Missing or invalid \(headerName) header" + } + guard let decoded = decodeHeaderValue(headerValue), decoded == expected else { + return "\(headerName) does not match params.arguments.\(parameter)" + } + } + return nil + } + + package static func validatedMappings(in schema: [String: Any]) -> [MCPToolHeaderMapping]? { + var suffixes = Set() + func collect(_ schema: [String: Any], path: [String], requiredPath: [Bool]) -> [MCPToolHeaderMapping]? { + guard let properties = schema["properties"] as? [String: Any] else { return [] } + let required = Set(schema["required"] as? [String] ?? []) + var mappings: [MCPToolHeaderMapping] = [] + for (name, raw) in properties { + guard let definition = raw as? [String: Any] else { continue } + let propertyPath = path + [name] + let propertyRequired = required.contains(name) + if let annotation = definition["x-mcp-header"] { + guard let suffix = annotation as? String, + isValidSuffix(suffix), + suffixes.insert(suffix.lowercased()).inserted, + let type = definition["type"] as? String, + ["string", "integer", "boolean"].contains(type) + else { return nil } + mappings.append(MCPToolHeaderMapping( + path: propertyPath, + suffix: suffix, + requiredPath: requiredPath + [propertyRequired] + )) + } else if let nestedMappings = collect( + definition, + path: propertyPath, + requiredPath: requiredPath + [propertyRequired] + ) { + mappings.append(contentsOf: nestedMappings) + } else { + return nil + } + } + return mappings + } + return collect(schema, path: [], requiredPath: []) + } + + package static func lookupValue(at path: [String], in root: [String: Any]) -> Any? { + var current: Any = root + for component in path { + guard let object = current as? [String: Any], let next = object[component] else { return nil } + current = next + } + return current + } + + package static func primitiveString(_ value: Any) -> String? { + if let value = value as? String { return value } + if let value = value as? NSNumber { + if CFGetTypeID(value) == CFBooleanGetTypeID() { return value.boolValue ? "true" : "false" } + let number = value.doubleValue + guard number.isFinite, number.rounded() == number, abs(number) <= 9_007_199_254_740_991 else { + return nil + } + return value.stringValue + } + return nil + } + + package static func encodeHeaderValue(_ value: String) -> String { + let bytes = Array(value.utf8) + let safe = bytes.allSatisfy { (0x20...0x7e).contains($0) } + && value == value.trimmingCharacters(in: .whitespacesAndNewlines) + && !hasBase64Sentinel(value) + guard !safe else { return value } + return "=?base64?\(Data(bytes).base64EncodedString())?=" + } + + package static func decodeHeaderValue(_ value: String) -> String? { + guard hasBase64Sentinel(value) else { return value } + let encoded = String(value.dropFirst(9).dropLast(2)) + guard let data = Data(base64Encoded: encoded, options: []), + data.base64EncodedString() == encoded, + let decoded = String(data: data, encoding: .utf8) + else { return nil } + return decoded + } + + private static func hasBase64Sentinel(_ value: String) -> Bool { + value.hasPrefix("=?base64?") && value.hasSuffix("?=") + } + + package static func isValidSuffix(_ value: String) -> Bool { + let punctuation = Set("!#$%&'*+-.^_`|~".utf8) + return !value.isEmpty && value.utf8.allSatisfy { + (0x30...0x39).contains($0) || (0x41...0x5a).contains($0) + || (0x61...0x7a).contains($0) || punctuation.contains($0) + } + } } // MARK: - Session Validator @@ -202,8 +555,8 @@ 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 } + // Skip validation for initialize, discover, and modern per-request profile. + guard !context.isSessionExempt else { return nil } // Non-initialization requests require an established session. guard let expectedSessionID = context.sessionID else { diff --git a/Sources/MCP/Base/Transports/HTTPServer/HTTPServerTypes.swift b/Sources/MCP/Base/Transports/HTTPServer/HTTPServerTypes.swift index bd56191c..64543955 100644 --- a/Sources/MCP/Base/Transports/HTTPServer/HTTPServerTypes.swift +++ b/Sources/MCP/Base/Transports/HTTPServer/HTTPServerTypes.swift @@ -1,5 +1,46 @@ import Foundation +/// Shares one asynchronous initialization result across concurrent callers. +package actor AsyncInitialization { + private var cachedValue: Value? + private var activeTask: (id: UUID, task: Task)? + + package init() {} + + package func value( + create: @escaping @Sendable () async throws -> Value + ) async throws -> Value { + if let cachedValue { return cachedValue } + let active: (id: UUID, task: Task) + if let activeTask { + active = activeTask + } else { + let id = UUID() + let task = Task { try await create() } + active = (id, task) + activeTask = active + } + do { + let result = try await active.task.value + cachedValue = result + if activeTask?.id == active.id { activeTask = nil } + return result + } catch { + if activeTask?.id == active.id { activeTask = nil } + throw error + } + } + + package func existingValue() async -> Value? { + if let cachedValue { return cachedValue } + guard let activeTask else { return nil } + guard let result = try? await activeTask.task.value else { return nil } + cachedValue = result + return result + } +} +import CoreFoundation + // MARK: - Session ID Generator /// Generates unique session identifiers for stateful HTTP server transports. @@ -46,11 +87,27 @@ public struct HTTPRequest: Sendable { /// Used by validators that need to match on specific paths. public let path: String? - public init(method: String, headers: [String: String] = [:], body: Data? = nil, path: String? = nil) { + /// An optional stable caller identity supplied by the hosting adapter. + /// Sessionless transports can use it to scope client messages such as + /// cancellation when Authorization or Cookie headers are unavailable. An + /// adapter may opt into `MCP-Client-Identity`, which SDK clients populate + /// with a random per-transport capability that remains stable across + /// connections; treat that value as a bearer capability and do not replace + /// it with an unauthenticated, low-entropy client identifier. + public let clientIdentity: String? + + public init( + method: String, + headers: [String: String] = [:], + body: Data? = nil, + path: String? = nil, + clientIdentity: String? = nil + ) { self.method = method self.headers = headers self.body = body self.path = path + self.clientIdentity = clientIdentity } /// Case-insensitive header lookup. @@ -58,6 +115,51 @@ public struct HTTPRequest: Sendable { let lowercased = name.lowercased() return headers.first { $0.key.lowercased() == lowercased }?.value } + + package var toolSchemaCacheKey: String { + if let clientIdentity, !clientIdentity.isEmpty { return "client:\(clientIdentity)" } + if let explicitIdentity = header(HTTPHeaderName.clientIdentity), !explicitIdentity.isEmpty { + return "client:\(explicitIdentity)" + } + if let authorization = header(HTTPHeaderName.authorization) { return "authorization:\(authorization)" } + if let cookie = header("Cookie") { return "cookie:\(cookie)" } + if let sessionID = header(HTTPHeaderName.sessionID) { return "session:\(sessionID)" } + return "anonymous" + } + + package var startsToolListAtFirstPage: Bool { + guard let body, let json = try? JSONSerialization.jsonObject(with: body) as? [String: Any], + json["method"] as? String == ListTools.name + else { return false } + return (json["params"] as? [String: Any])?["cursor"] == nil + } + + /// Selects an identity for sessionless request scoping. Explicit MCP client + /// identities take precedence; authenticated requests without one defer to + /// their credentials instead of a connection-local fallback. + package static func stableClientIdentity( + headers: [String: String], + connectionIdentity: String + ) -> String? { + func value(_ name: String) -> String? { + headers.first { $0.key.caseInsensitiveCompare(name) == .orderedSame }?.value + } + if let identity = value(HTTPHeaderName.clientIdentity), !identity.isEmpty { + return identity + } + if value(HTTPHeaderName.authorization) != nil || value("Cookie") != nil { + return nil + } + return connectionIdentity + } +} + +/// Allows the typed MCP server to provide current tool schemas when an HTTP +/// caller invokes a tool before its first `tools/list` request. +package protocol HTTPToolSchemaProviderInstalling: Sendable { + func setToolSchemaProvider( + _ provider: (@Sendable (HTTPRequest) async -> Data?)? + ) async } // MARK: - HTTP Response @@ -88,6 +190,9 @@ public enum HTTPResponse: Sendable { /// 200 OK with data body (typically JSON). case data(Data, headers: [String: String] = [:]) + /// JSON body with an explicit non-200 status, used for JSON-RPC HTTP errors. + case httpData(statusCode: Int, Data, headers: [String: String] = [:]) + /// 200 OK with SSE streaming body. case stream(AsyncThrowingStream, headers: [String: String] = [:]) @@ -101,13 +206,14 @@ public enum HTTPResponse: Sendable { switch self { case .accepted: 202 case .ok, .data, .stream: 200 + case .httpData(let statusCode, _, _): statusCode 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): + case .accepted(let headers), .ok(let headers), .data(_, let headers), .stream(_, let headers), .httpData(_, _, let headers): return headers case .error(_, _, let sessionID, let extraHeaders): var headers: [String: String] = [HTTPHeaderName.contentType: ContentType.json] @@ -122,28 +228,39 @@ public enum HTTPResponse: Sendable { switch self { case .accepted, .ok, .stream: return nil - case .data(let data, _): + case .data(let data, _), .httpData(_, 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) + // Encode via MCPError's Codable so `data` (e.g. -32022 requested/supported) is preserved. + struct Envelope: Encodable { + let jsonrpc = "2.0" + let error: MCPError + let id: Value? = nil + + enum CodingKeys: String, CodingKey { case jsonrpc, error, id } + + func encode(to encoder: Encoder) throws { + var container = encoder.container(keyedBy: CodingKeys.self) + try container.encode(jsonrpc, forKey: .jsonrpc) + try container.encode(error, forKey: .error) + try container.encodeNil(forKey: .id) + } + } + return try? JSONEncoder().encode(Envelope(error: error)) } } } // MARK: - HTTP Header Names -/// Standard header names used by the MCP Streamable HTTP transport. +/// Standard MCP Streamable HTTP and SDK extension header names. public enum HTTPHeaderName { public static let sessionID = "MCP-Session-Id" + /// Stable per-transport capability used to associate requests across connections. + public static let clientIdentity = "MCP-Client-Identity" public static let protocolVersion = "MCP-Protocol-Version" + public static let mcpMethod = "Mcp-Method" + public static let mcpName = "Mcp-Name" public static let lastEventID = "Last-Event-ID" public static let accept = "Accept" public static let contentType = "Content-Type" @@ -223,6 +340,21 @@ public protocol HTTPContextProviding: Sendable { /// transport still has it on hand. Implementations must return `nil` once the /// corresponding response has been delivered or the session has terminated. func httpRequestContext(for id: ID) async -> HTTPRequest? + + /// Closes the per-request SSE (or equivalent) stream for `id` without sending + /// a JSON-RPC response. + /// + /// Used when a modern request is cancelled (`notifications/cancelled` or + /// `CancellationError`) so the client is not left with an open stream that + /// still receives fan-out notifications. Idempotent when the stream is + /// already gone. + func closeRequestStream(for id: ID) async +} + +extension HTTPContextProviding { + /// Default behavior for context providers without a separately managed + /// per-request response stream. Stream-capable transports can override it. + public func closeRequestStream(for id: ID) async {} } // MARK: - JSON-RPC Message Classification @@ -244,7 +376,7 @@ package enum JSONRPCMessageKind { return nil } - let id = Self.extractID(from: json) + let id = Self.extractID(from: json)?.transportKey if let method = json["method"] as? String { if let id { @@ -274,12 +406,157 @@ package enum JSONRPCMessageKind { return false } - private static func extractID(from json: [String: Any]) -> String? { + package var isDiscoverRequest: Bool { + if case .request(_, let method) = self { + return method == "server/discover" + } + return false + } + + package var isSubscriptionsListenRequest: Bool { + if case .request(_, let method) = self { + return method == SubscriptionsListen.name + } + return false + } + + /// Shared modern-profile classifier for HTTP transports and Server. + /// + /// A message is modern when it is `server/discover` / `subscriptions/listen`, + /// or when body `_meta` (requests) / header (responses & notifications) + /// carries a modern protocol version. + package static func isModernProfile( + kind: JSONRPCMessageKind, + body: Data, + headerProtocolVersion: String? + ) -> Bool { + if kind.isDiscoverRequest || kind.isSubscriptionsListenRequest { + return true + } + let bodyVersion = protocolVersion(inBody: body) + if let bodyVersion, Version.isModern(bodyVersion) { + return true + } + let isRequest: Bool = { + if case .request = kind { return true } + return false + }() + if !isRequest, let headerProtocolVersion, Version.isModern(headerProtocolVersion) { + return true + } + return false + } + + /// Method name for request messages; nil otherwise. + package var methodName: String? { + switch self { + case .request(_, let method), .notification(let method): + return method + case .response: + return nil + } + } + + private static func extractID(from json: [String: Any]) -> ID? { if let stringID = json["id"] as? String { - return stringID + return .string(stringID) } else if let intID = json["id"] as? Int { - return String(intID) + return .number(intID) + } + if let number = json["id"] as? NSNumber { + // Avoid treating Bool as Int (Bool is an NSNumber subclass). + let typeID = CFGetTypeID(number as CFTypeRef) + if typeID != CFBooleanGetTypeID() { + return .number(number.intValue) + } } return nil } + + /// Type-preserving JSON-RPC id key from a raw body, if present. + package static func routingIDKey(from data: Data) -> String? { + guard let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any] else { + return nil + } + return extractID(from: json)?.transportKey + } + + /// Protocol version from params._meta, if present in the raw body. + package static func protocolVersion(inBody data: Data) -> String? { + guard let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any], + let params = json["params"] as? [String: Any], + let meta = params["_meta"] as? [String: Any], + let version = meta[ProtocolMetaKey.protocolVersion] as? String + else { return nil } + return version + } + + /// Preserves the JSON kind of a progress token while extracting it from a request or notification. + package static func progressToken(inBody data: Data) -> ProgressToken? { + guard let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any], + let params = json["params"] as? [String: Any] + else { return nil } + if let token = params["progressToken"], let parsed = progressToken(token) { return parsed } + guard let meta = params["_meta"] as? [String: Any], + let token = meta["progressToken"] + else { return nil } + return progressToken(token) + } + + private static func progressToken(_ value: Any) -> ProgressToken? { + if let string = value as? String { return .string(string) } + guard let number = value as? NSNumber, + CFGetTypeID(number as CFTypeRef) != CFBooleanGetTypeID() + else { return nil } + return .integer(number.intValue) + } + + /// Preserves JSON-RPC id type (string vs number) from a raw request body. + package static func jsonRPCID(inBody data: Data) -> ID? { + guard let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any] else { + return nil + } + return extractID(from: json) + } + + /// Extracts the subscription correlation id from a notification's params._meta. + package static func subscriptionID(inBody data: Data) -> ID? { + guard let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any], + let params = json["params"] as? [String: Any], + let meta = params["_meta"] as? [String: Any] + else { return nil } + + if let stringID = meta[ProtocolMetaKey.subscriptionId] as? String { + return .string(stringID) + } + if let intID = meta[ProtocolMetaKey.subscriptionId] as? Int { + return .number(intID) + } + if let number = meta[ProtocolMetaKey.subscriptionId] as? NSNumber { + let typeID = CFGetTypeID(number as CFTypeRef) + if typeID != CFBooleanGetTypeID() { + return .number(number.intValue) + } + } + return nil + } + + /// Adds the originating listen request id to a notification's params._meta. + package static func taggingSubscriptionNotification( + _ data: Data, + subscriptionID: ID + ) -> Data { + guard var json = try? JSONSerialization.jsonObject(with: data) as? [String: Any] else { + return data + } + var params = json["params"] as? [String: Any] ?? [:] + var meta = params["_meta"] as? [String: Any] ?? [:] + switch subscriptionID { + case .string(let value): meta[ProtocolMetaKey.subscriptionId] = value + case .number(let value): meta[ProtocolMetaKey.subscriptionId] = value + } + params["_meta"] = meta + json["params"] = params + return (try? JSONSerialization.data(withJSONObject: json)) ?? data + } } diff --git a/Sources/MCP/Base/Transports/HTTPServer/StatefulHTTPServerTransport.swift b/Sources/MCP/Base/Transports/HTTPServer/StatefulHTTPServerTransport.swift index c7a4d485..e26b8ac7 100644 --- a/Sources/MCP/Base/Transports/HTTPServer/StatefulHTTPServerTransport.swift +++ b/Sources/MCP/Base/Transports/HTTPServer/StatefulHTTPServerTransport.swift @@ -29,7 +29,7 @@ import Logging /// and receive `HTTPResponse` values to convert to your framework's native types. /// For SSE responses, the `.stream` case provides an `AsyncThrowingStream` /// to pipe to the client. -public actor StatefulHTTPServerTransport: Transport, HTTPContextProviding { +public actor StatefulHTTPServerTransport: Transport, HTTPContextProviding, HTTPToolSchemaProviderInstalling { public nonisolated let logger: Logger // MARK: - Dependencies @@ -43,6 +43,8 @@ public actor StatefulHTTPServerTransport: Transport, HTTPContextProviding { private var sessionID: String? private var terminated = false private var started = false + private var toolSchemas = MCPToolSchemaRegistry() + private var toolSchemaProvider: (@Sendable (HTTPRequest) async -> Data?)? // MARK: - Incoming message stream (client → server) @@ -54,6 +56,28 @@ public actor StatefulHTTPServerTransport: Transport, HTTPContextProviding { /// Maps request ID → SSE stream continuation for active POST request streams. private var requestSSEContinuations: [String: AsyncThrowingStream.Continuation] = [:] + /// Generation counters so a delayed `onTermination` for an old stream cannot + /// cancel a newer request that reused the same JSON-RPC id string. + private var requestSSEGenerations: [String: UInt64] = [:] + private var sseGenerationCounter: UInt64 = 0 + + /// Original JSON-RPC request ids (preserving string vs number) keyed by the + /// transport's string form of the id. + private var requestJSONRPCIds: [String: ID] = [:] + + /// Whether the active POST stream is a modern-profile request (cancel on SSE close). + private var requestIsModern: [String: Bool] = [:] + + /// Maps modern request progress tokens to their active POST SSE streams. + private var progressTokenRequestIDs: [ProgressToken: String] = [:] + + /// Active `subscriptions/listen` request streams and their filters. + private var listenFilters: [String: SubscriptionsListen.NotificationFilter] = [:] + private var pendingListenFilters: [String: SubscriptionsListen.NotificationFilter] = [:] + + /// True once this session was opened by a modern-profile request. + private var isModernSession = false + /// Maps request ID → originating HTTP request, surfaced to handlers via /// ``Server/currentHTTPContext``. Cleared when the response is routed, /// the stream is closed, or the session terminates. @@ -105,6 +129,7 @@ public actor StatefulHTTPServerTransport: Transport, HTTPContextProviding { AcceptHeaderValidator(mode: .sseRequired), ContentTypeValidator(), ProtocolVersionValidator(), + MCPRoutingHeaderValidator(), SessionValidator(), ]) self.retryInterval = retryInterval @@ -132,6 +157,12 @@ public actor StatefulHTTPServerTransport: Transport, HTTPContextProviding { terminate() } + package func setToolSchemaProvider( + _ provider: (@Sendable (HTTPRequest) async -> Data?)? + ) async { + toolSchemaProvider = provider + } + /// Routes outgoing server messages to the appropriate client connection. /// /// - Responses are routed to the SSE stream matching the response's JSON-RPC ID. @@ -146,6 +177,14 @@ public actor StatefulHTTPServerTransport: Transport, HTTPContextProviding { return } + if case .notification(let method) = kind, method == ToolListChangedNotification.name { + toolSchemas.invalidateAll() + } + + if case .response(let id) = kind, let context = httpRequestContexts[id] { + toolSchemas.record(responseData: data, for: context) + } + switch kind { case .response(let id): routeResponse(data, requestID: id) @@ -178,7 +217,7 @@ public actor StatefulHTTPServerTransport: Transport, HTTPContextProviding { switch request.method.uppercased() { case "POST": - return handlePost(request) + return await handlePost(request) case "GET": return handleGet(request) case "DELETE": @@ -195,7 +234,7 @@ public actor StatefulHTTPServerTransport: Transport, HTTPContextProviding { // MARK: - POST Handler - private func handlePost(_ request: HTTPRequest) -> HTTPResponse { + private func handlePost(_ request: HTTPRequest) async -> HTTPResponse { // Parse body first so we can determine if it's an initialization request guard let body = request.body, !body.isEmpty else { return .error( @@ -213,17 +252,54 @@ public actor StatefulHTTPServerTransport: Transport, HTTPContextProviding { ) } + let bodyProtocolVersion = JSONRPCMessageKind.protocolVersion(inBody: body) + let headerProtocolVersion = request.header(HTTPHeaderName.protocolVersion) + let isModern = JSONRPCMessageKind.isModernProfile( + kind: messageKind, + body: body, + headerProtocolVersion: headerProtocolVersion + ) + // Build validation context let context = HTTPValidationContext( httpMethod: "POST", - sessionID: sessionID, + sessionID: isModern ? nil : sessionID, isInitializationRequest: messageKind.isInitializeRequest, - supportedProtocolVersions: Version.supported + isDiscoverRequest: messageKind.isDiscoverRequest, + isModernRequest: isModern, + bodyProtocolVersion: bodyProtocolVersion, + // Modern-classified POSTs must only accept modern revisions so + // UnsupportedProtocolVersionError is returned as HTTP 400. + supportedProtocolVersions: isModern ? Version.modern : Version.supported ) // Run validation pipeline if let errorResponse = validationPipeline.validate(request, context: context) { - return errorResponse + return Self.responsePreservingRequestID(errorResponse, body: body) + } + + if isModern, messageKind.methodName == CallTool.name { + var schemas = toolSchemas.schemas(for: request) + if let toolName = MCPToolHeaderSupport.toolName(in: body), schemas[toolName] == nil, + !toolSchemas.isComplete(for: request) + { + guard let provider = toolSchemaProvider, + let responseData = await provider(request) + else { + return Self.responsePreservingRequestID( + .error(statusCode: 400, .serverError(code: -32020, message: "Unable to resolve tool schema for custom-header validation"), sessionID: nil), + body: body + ) + } + toolSchemas.record(responseData: responseData, for: request) + schemas = toolSchemas.schemas(for: request) + } + if let mismatch = MCPToolHeaderSupport.validateCall(body: body, request: request, schemas: schemas) { + return Self.responsePreservingRequestID( + .error(statusCode: 400, .serverError(code: -32020, message: mismatch), sessionID: nil), + body: body + ) + } } // Handle initialization request specially @@ -231,12 +307,18 @@ public actor StatefulHTTPServerTransport: Transport, HTTPContextProviding { return handleInitializationRequest(body, request: request) } + // Modern requests are sessionless on the wire, even when this transport + // also retains legacy initialize-session state internally. + if isModern { + self.isModernSession = true + } + // Handle by message type switch messageKind { case .notification, .response: // Yield to server and return 202 Accepted incomingContinuation.yield(body) - return .accepted(headers: sessionHeaders()) + return .accepted(headers: sessionHeaders(includeSessionID: !isModern)) case .request(let id, _): return handleJSONRPCRequest(body, requestID: id, request: request) @@ -282,10 +364,66 @@ public actor StatefulHTTPServerTransport: Transport, HTTPContextProviding { } private func handleJSONRPCRequest(_ body: Data, requestID: String, request: HTTPRequest) -> HTTPResponse { + let bodyVersion = JSONRPCMessageKind.protocolVersion(inBody: body) + let method = JSONRPCMessageKind(data: body)?.methodName + let isModern = method == Discover.name || method == SubscriptionsListen.name + || (bodyVersion.map(Version.isModern) ?? false) + let listenFilter: SubscriptionsListen.NotificationFilter? + if method == SubscriptionsListen.name { + guard let filter = Self.notificationFilter(fromBody: body) else { + return Self.responsePreservingRequestID(.error( + statusCode: 400, + .invalidParams( + "subscriptions/listen requires params.notifications" + ), + sessionID: isModern ? nil : sessionID + ), body: body) + } + listenFilter = filter + } else { + listenFilter = nil + } + + // Do not let a concurrent duplicate replace the continuation and + // request context of an already active request. + guard requestSSEContinuations[requestID] == nil else { + return Self.responsePreservingRequestID( + .error( + statusCode: 409, + .invalidRequest("A request with this JSON-RPC id is already active"), + sessionID: isModern ? nil : sessionID + ), + body: body + ) + } + // Create SSE stream for this request let (sseStream, sseContinuation) = AsyncThrowingStream.makeStream() + sseGenerationCounter &+= 1 + let generation = sseGenerationCounter requestSSEContinuations[requestID] = sseContinuation + requestSSEGenerations[requestID] = generation + requestJSONRPCIds[requestID] = JSONRPCMessageKind.jsonRPCID(inBody: body) ?? .string(requestID) + requestIsModern[requestID] = + method == Discover.name + || method == SubscriptionsListen.name + || (bodyVersion.map(Version.isModern) ?? false) + if let progressToken = JSONRPCMessageKind.progressToken(inBody: body) { + progressTokenRequestIDs[progressToken] = requestID + } + if let listenFilter { + pendingListenFilters[requestID] = listenFilter + // A modern listen on this session owns notification delivery — do not + // also store/push on the legacy GET channel (must-hold dual-era). + isModernSession = true + } httpRequestContexts[requestID] = request + // Client closing the SSE cancels the in-flight request (MCP: closing the + // stream cancels that request). Intentional finish after a response clears + // the generation so a delayed onTermination cannot cancel a reused id. + sseContinuation.onTermination = { [weak self] _ in + Task { await self?.handleRequestSSETerminated(requestID, generation: generation) } + } // Extract protocol version for priming event decision let protocolVersion = extractProtocolVersion(from: body, request: request) @@ -301,7 +439,7 @@ public actor StatefulHTTPServerTransport: Transport, HTTPContextProviding { incomingContinuation.yield(body) // Build response headers - var headers = sessionHeaders() + var headers = sessionHeaders(includeSessionID: !isModern) headers[HTTPHeaderName.contentType] = ContentType.sse headers[HTTPHeaderName.cacheControl] = "no-cache, no-transform" headers[HTTPHeaderName.connection] = "keep-alive" @@ -309,15 +447,70 @@ public actor StatefulHTTPServerTransport: Transport, HTTPContextProviding { return .stream(sseStream, headers: headers) } + /// Invoked when a POST request SSE stream ends. If this generation is still + /// the active stream for `requestID`, the client disconnected before a final + /// response — inject `notifications/cancelled` so the server cancels the handler. + private func handleRequestSSETerminated(_ requestID: String, generation: UInt64) { + guard requestSSEGenerations[requestID] == generation else { return } + let wasModern = requestIsModern[requestID] ?? false + requestSSEGenerations.removeValue(forKey: requestID) + requestSSEContinuations.removeValue(forKey: requestID) + let jsonRPCID = requestJSONRPCIds.removeValue(forKey: requestID) + requestIsModern.removeValue(forKey: requestID) + progressTokenRequestIDs = progressTokenRequestIDs.filter { $0.value != requestID } + listenFilters.removeValue(forKey: requestID) + pendingListenFilters.removeValue(forKey: requestID) + httpRequestContexts.removeValue(forKey: requestID) + // Legacy SEP-1699 expects reconnect/resume, not cancel-on-drop. + guard wasModern else { return } + injectClientCancellation( + id: jsonRPCID ?? .string(requestID), + reason: "SSE stream closed" + ) + } + + private func injectClientCancellation(id: ID, reason: String) { + let notification = CancelledNotification.message( + .init(requestId: id, reason: reason) + ) + guard let data = try? JSONEncoder().encode(notification) else { return } + incomingContinuation.yield(data) + logger.debug( + "Injected cancellation after SSE close", + metadata: ["requestID": "\(id)"] + ) + } + + private func clearRequestStreamState(_ requestID: String) { + requestSSEContinuations.removeValue(forKey: requestID) + requestSSEGenerations.removeValue(forKey: requestID) + requestJSONRPCIds.removeValue(forKey: requestID) + requestIsModern.removeValue(forKey: requestID) + progressTokenRequestIDs = progressTokenRequestIDs.filter { $0.value != requestID } + listenFilters.removeValue(forKey: requestID) + pendingListenFilters.removeValue(forKey: requestID) + httpRequestContexts.removeValue(forKey: requestID) + } + // MARK: - GET Handler private func handleGet(_ request: HTTPRequest) -> HTTPResponse { + if isModernSession { + return .error( + statusCode: 405, + .invalidRequest( + "Modern sessions use subscriptions/listen; legacy GET SSE is not available" + ), + sessionID: sessionID + ) + } + // Build validation context (GET is never an initialization request) let context = HTTPValidationContext( httpMethod: "GET", sessionID: sessionID, isInitializationRequest: false, - supportedProtocolVersions: Version.supported + supportedProtocolVersions: Version.legacy ) // Run validation pipeline @@ -344,7 +537,7 @@ public actor StatefulHTTPServerTransport: Transport, HTTPContextProviding { standaloneSSEContinuation = sseContinuation // Extract protocol version for priming event - let protocolVersion = request.header(HTTPHeaderName.protocolVersion) ?? Version.latest + let protocolVersion = request.header(HTTPHeaderName.protocolVersion) ?? Version.latestLegacy // Send priming event sendPrimingEvent( @@ -407,15 +600,59 @@ public actor StatefulHTTPServerTransport: Transport, HTTPContextProviding { private func routeResponse(_ data: Data, requestID: String) { routeToRequestStream(data, requestID: requestID) - // Response means the request is complete — close the stream + // Response means the request is complete — close the stream. + // Clear generation before finish so onTermination does not inject cancel. if let continuation = requestSSEContinuations[requestID] { + clearRequestStreamState(requestID) continuation.finish() - requestSSEContinuations.removeValue(forKey: requestID) + } else { + clearRequestStreamState(requestID) } - httpRequestContexts.removeValue(forKey: requestID) } private func routeServerInitiatedMessage(_ data: Data) { + if JSONRPCMessageKind(data: data)?.methodName == "notifications/progress", + let token = JSONRPCMessageKind.progressToken(inBody: data), + let requestID = progressTokenRequestIDs[token] + { + routeToRequestStream(data, requestID: requestID) + return + } + + // Modern subscriptions/listen streams receive filtered notifications. + // Do not store listen deliveries in the legacy resumability event log. + let method = JSONRPCMessageKind(data: data)?.methodName + if method == SubscriptionsAcknowledgedNotification.name { + guard let subscriptionID = JSONRPCMessageKind.subscriptionID(inBody: data), + let requestID = requestJSONRPCIds.first(where: { $0.value == subscriptionID })?.key, + let continuation = requestSSEContinuations[requestID] + else { return } + guard let requestedFilter = pendingListenFilters.removeValue(forKey: requestID), + let acceptedFilter = Self.notificationFilter(fromBody: data) + else { return } + listenFilters[requestID] = requestedFilter.intersection(with: acceptedFilter) + continuation.yield(SSEEvent.message(data: data).formatted()) + return + } + + for (requestID, filter) in listenFilters { + guard let continuation = requestSSEContinuations[requestID] else { continue } + guard Self.notification(method, matches: filter, body: data) else { continue } + let id = requestJSONRPCIds[requestID] ?? .string(requestID) + let tagged = JSONRPCMessageKind.taggingSubscriptionNotification( + data, + subscriptionID: id + ) + continuation.yield(SSEEvent.message(data: tagged).formatted()) + } + + // Modern sessions use subscriptions/listen only — never the legacy GET + // event store. Legacy sessions keep storing for SEP-1699 resume even + // while the GET stream is temporarily disconnected. + if isModernSession { + return + } + let eventID = storeEvent(streamID: standaloneStreamID, message: data) guard let continuation = standaloneSSEContinuation else { @@ -427,6 +664,46 @@ public actor StatefulHTTPServerTransport: Transport, HTTPContextProviding { continuation.yield(sseEvent.formatted()) } + private static func notificationFilter(fromBody body: Data) + -> SubscriptionsListen.NotificationFilter? + { + guard let json = try? JSONSerialization.jsonObject(with: body) as? [String: Any], + let params = json["params"] as? [String: Any], + let notifications = params["notifications"] as? [String: Any] + else { return nil } + let data = try? JSONSerialization.data(withJSONObject: notifications) + return data.flatMap { try? JSONDecoder().decode(SubscriptionsListen.NotificationFilter.self, from: $0) } + } + + /// Testable notification filter used by listen fan-out. + package static func notification( + _ method: String?, + matches filter: SubscriptionsListen.NotificationFilter, + body: Data + ) -> Bool { + guard let method else { return false } + switch method { + case ToolListChangedNotification.name: + return filter.toolsListChanged == true + case ResourceListChangedNotification.name: + return filter.resourcesListChanged == true + case PromptListChangedNotification.name: + return filter.promptsListChanged == true + case ResourceUpdatedNotification.name: + guard let uris = filter.resourceSubscriptions, !uris.isEmpty else { + return false + } + guard let json = try? JSONSerialization.jsonObject(with: body) as? [String: Any], + let params = json["params"] as? [String: Any], + let uri = params["uri"] as? String + else { return false } + return uris.contains(uri) + default: + // Unfiltered progressive/logging notifications are not opted into by default. + return false + } + } + // MARK: - Resumability private func handleResumeRequest(lastEventID: String, request: HTTPRequest) -> HTTPResponse { @@ -450,11 +727,24 @@ public actor StatefulHTTPServerTransport: Transport, HTTPContextProviding { if replay.streamID == standaloneStreamID { standaloneSSEContinuation = sseContinuation } else { - requestSSEContinuations[replay.streamID] = sseContinuation + let resumedID = replay.streamID + sseGenerationCounter &+= 1 + let generation = sseGenerationCounter + requestSSEContinuations[resumedID] = sseContinuation + requestSSEGenerations[resumedID] = generation + // Preserve JSON-RPC id type across SEP-1699 resume (stream id is the + // request id string form from the original POST). + if requestJSONRPCIds[resumedID] == nil { + requestJSONRPCIds[resumedID] = + Int(resumedID).map { .number($0) } ?? .string(resumedID) + } + sseContinuation.onTermination = { [weak self] _ in + Task { await self?.handleRequestSSETerminated(resumedID, generation: generation) } + } } // Send a new priming event so the client can resume again if disconnected - let protocolVersion = request.header(HTTPHeaderName.protocolVersion) ?? Version.latest + let protocolVersion = request.header(HTTPHeaderName.protocolVersion) ?? Version.latestLegacy sendPrimingEvent( streamID: replay.streamID, continuation: sseContinuation, @@ -514,19 +804,31 @@ public actor StatefulHTTPServerTransport: Transport, HTTPContextProviding { return version } // For other requests, use the header - return request.header(HTTPHeaderName.protocolVersion) ?? Version.latest + return request.header(HTTPHeaderName.protocolVersion) ?? Version.latestLegacy } // MARK: - Session Helpers - private func sessionHeaders() -> [String: String] { + private func sessionHeaders(includeSessionID: Bool = true) -> [String: String] { var headers: [String: String] = [:] - if let sessionID { + if includeSessionID, let sessionID { headers[HTTPHeaderName.sessionID] = sessionID } return headers } + private static func responsePreservingRequestID(_ response: HTTPResponse, body: Data) -> HTTPResponse { + guard case .error(let status, _, _, let extraHeaders) = response, + let request = try? JSONSerialization.jsonObject(with: body) as? [String: Any], + let id = request["id"], + var envelope = response.bodyData.flatMap({ try? JSONSerialization.jsonObject(with: $0) as? [String: Any] }) + else { return response } + envelope["id"] = id + guard let encoded = try? JSONSerialization.data(withJSONObject: envelope) else { return response } + let headers = [HTTPHeaderName.contentType: ContentType.json].merging(extraHeaders) { _, new in new } + return .httpData(statusCode: status, encoded, headers: headers) + } + private func isValidSessionID(_ id: String) -> Bool { guard !id.isEmpty else { return false } return id.utf8.allSatisfy { $0 >= 0x21 && $0 <= 0x7E } @@ -538,17 +840,28 @@ public actor StatefulHTTPServerTransport: Transport, HTTPContextProviding { /// /// Used to trigger client reconnection mid-call (SEP-1699). The response, /// when eventually sent, will be stored and replayed to the reconnected stream. - package func closeSSEStream(forRequestID requestID: String) { + package func closeSSEStream(forRequestID id: ID) { + let requestID = id.transportKey guard let continuation = requestSSEContinuations[requestID] else { return } - continuation.finish() + // Keep `requestJSONRPCIds` so a SEP-1699 resume can cancel with the + // original id type; clear generation so onTermination is a no-op. requestSSEContinuations.removeValue(forKey: requestID) + requestSSEGenerations.removeValue(forKey: requestID) httpRequestContexts.removeValue(forKey: requestID) + listenFilters.removeValue(forKey: requestID) + pendingListenFilters.removeValue(forKey: requestID) + requestIsModern.removeValue(forKey: requestID) + continuation.finish() } // MARK: - HTTPContextProviding public func httpRequestContext(for id: ID) -> HTTPRequest? { - httpRequestContexts[id.description] + httpRequestContexts[id.transportKey] + } + + public func closeRequestStream(for id: ID) async { + closeSSEStream(forRequestID: id) } // MARK: - Termination @@ -558,15 +871,23 @@ public actor StatefulHTTPServerTransport: Transport, HTTPContextProviding { private func terminate() { guard !terminated else { return } terminated = true + isModernSession = false logger.info("Terminating session", metadata: ["sessionID": "\(sessionID ?? "none")"]) - // Close all request SSE streams - for (_, continuation) in requestSSEContinuations { - continuation.finish() - } + // Close all request SSE streams (clear generation first to avoid cancel inject) + let continuations = Array(requestSSEContinuations.values) requestSSEContinuations.removeAll() + requestSSEGenerations.removeAll() + requestJSONRPCIds.removeAll() + requestIsModern.removeAll() + progressTokenRequestIDs.removeAll() + listenFilters.removeAll() + pendingListenFilters.removeAll() httpRequestContexts.removeAll() + for continuation in continuations { + continuation.finish() + } // Close standalone GET stream standaloneSSEContinuation?.finish() diff --git a/Sources/MCP/Base/Transports/HTTPServer/StatelessHTTPServerTransport.swift b/Sources/MCP/Base/Transports/HTTPServer/StatelessHTTPServerTransport.swift index abe9576c..c81a566e 100644 --- a/Sources/MCP/Base/Transports/HTTPServer/StatelessHTTPServerTransport.swift +++ b/Sources/MCP/Base/Transports/HTTPServer/StatelessHTTPServerTransport.swift @@ -5,7 +5,10 @@ import Logging /// /// 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) +/// - No standalone GET event stream; listen and progress requests can open per-request SSE streams +/// - Client cancellation notifications are scoped by Authorization, Cookie, or +/// the hosting adapter's `HTTPRequest.clientIdentity`; notifications without +/// a matching identity are ignored because request IDs alone are not client-scoped. /// - GET and DELETE requests return 405 Method Not Allowed /// /// ## Usage @@ -29,7 +32,7 @@ import Logging /// - Session management is handled externally or not needed /// /// For full streaming and session support, use ``StatefulHTTPServerTransport`` instead. -public actor StatelessHTTPServerTransport: Transport, HTTPContextProviding { +public actor StatelessHTTPServerTransport: Transport, HTTPContextProviding, HTTPToolSchemaProviderInstalling { public nonisolated let logger: Logger // MARK: - Dependencies @@ -40,6 +43,8 @@ public actor StatelessHTTPServerTransport: Transport, HTTPContextProviding { private var terminated = false private var started = false + private var toolSchemas = MCPToolSchemaRegistry() + private var toolSchemaProvider: (@Sendable (HTTPRequest) async -> Data?)? // MARK: - Incoming message stream (client → server) @@ -51,11 +56,20 @@ public actor StatelessHTTPServerTransport: Transport, HTTPContextProviding { /// 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] = [:] + /// Prevents concurrent clients that reuse an ID from replacing each other's + /// response, listen, progress, or HTTP-context state. + private var activeRequestIDs: Set = [] /// Maps request ID → originating HTTP request, surfaced to handlers via /// ``Server/currentHTTPContext``. Entries live only while a JSON-RPC request /// is in flight. private var httpRequestContexts: [String: HTTPRequest] = [:] + private var listenStreams: [String: AsyncThrowingStream.Continuation] = [:] + private var listenFilters: [String: SubscriptionsListen.NotificationFilter] = [:] + private var pendingListenFilters: [String: SubscriptionsListen.NotificationFilter] = [:] + private var listenIDs: [String: ID] = [:] + private var progressStreams: [String: AsyncThrowingStream.Continuation] = [:] + private var progressTokens: [String: ProgressToken] = [:] // MARK: - Init @@ -75,6 +89,7 @@ public actor StatelessHTTPServerTransport: Transport, HTTPContextProviding { AcceptHeaderValidator(mode: .jsonOnly), ContentTypeValidator(), ProtocolVersionValidator(), + MCPRoutingHeaderValidator(), ]) self.logger = logger ?? Logger( label: "mcp.transport.http.server.stateless", @@ -100,6 +115,12 @@ public actor StatelessHTTPServerTransport: Transport, HTTPContextProviding { await terminate() } + package func setToolSchemaProvider( + _ provider: (@Sendable (HTTPRequest) async -> Data?)? + ) async { + toolSchemaProvider = provider + } + /// Routes outgoing server messages to the appropriate waiting HTTP handler. /// /// - Responses are matched by JSON-RPC ID and delivered to the waiting `handleRequest` call. @@ -115,8 +136,30 @@ public actor StatelessHTTPServerTransport: Transport, HTTPContextProviding { return } + if case .notification(let method) = kind, method == ToolListChangedNotification.name { + toolSchemas.invalidateAll() + } + + if case .response(let id) = kind, let context = httpRequestContexts[id] { + toolSchemas.record(responseData: data, for: context) + } + switch kind { case .response(let id): + activeRequestIDs.remove(id) + if let continuation = listenStreams[id] { + continuation.yield(SSEEvent.message(data: data).formatted()) + continuation.finish() + clearListen(id) + return + } + if let continuation = progressStreams.removeValue(forKey: id) { + continuation.yield(SSEEvent.message(data: data).formatted()) + continuation.finish() + progressTokens.removeValue(forKey: id) + httpRequestContexts.removeValue(forKey: id) + return + } guard let continuation = responseWaiters.removeValue(forKey: id) else { logger.debug( "No waiter for response, may have timed out", @@ -127,10 +170,29 @@ public actor StatelessHTTPServerTransport: Transport, HTTPContextProviding { continuation.resume(returning: data) case .notification(let method): - logger.debug( - "Server-initiated notification dropped in stateless mode (no GET SSE stream)", - metadata: ["method": "\(method)"] - ) + if method == SubscriptionsAcknowledgedNotification.name { + guard let subscriptionID = JSONRPCMessageKind.subscriptionID(inBody: data), + let requestID = listenIDs.first(where: { $0.value == subscriptionID })?.key, + let continuation = listenStreams[requestID] + else { return } + guard let requestedFilter = pendingListenFilters.removeValue(forKey: requestID), + let acceptedFilter = Self.notificationFilter(from: data) + else { return } + listenFilters[requestID] = requestedFilter.intersection(with: acceptedFilter) + continuation.yield(SSEEvent.message(data: data).formatted()) + return + } + + for (requestID, filter) in listenFilters where Self.notification(method, matches: filter, body: data) { + guard let continuation = listenStreams[requestID] else { continue } + continuation.yield(SSEEvent.message(data: Self.tagged(data, subscriptionID: listenIDs[requestID] ?? .string(requestID))).formatted()) + } + if method == ProgressNotification.name, + let token = JSONRPCMessageKind.progressToken(inBody: data) { + for (requestID, expectedToken) in progressTokens where expectedToken == token { + progressStreams[requestID]?.yield(SSEEvent.message(data: data).formatted()) + } + } case .request(_, let method): logger.debug( @@ -191,31 +253,138 @@ public actor StatelessHTTPServerTransport: Transport, HTTPContextProviding { ) } + let bodyProtocolVersion = JSONRPCMessageKind.protocolVersion(inBody: body) + let headerProtocolVersion = request.header(HTTPHeaderName.protocolVersion) + let isModern = JSONRPCMessageKind.isModernProfile( + kind: messageKind, + body: body, + headerProtocolVersion: headerProtocolVersion + ) + let isModernHTTPRequest = isModern || (headerProtocolVersion.map(Version.isModern) ?? false) + // Build validation context let context = HTTPValidationContext( httpMethod: "POST", sessionID: nil, isInitializationRequest: messageKind.isInitializeRequest, - supportedProtocolVersions: Version.supported + isDiscoverRequest: messageKind.isDiscoverRequest, + isModernRequest: isModernHTTPRequest, + bodyProtocolVersion: bodyProtocolVersion, + supportedProtocolVersions: isModernHTTPRequest ? Version.modern : Version.supported ) // Run validation pipeline if let errorResponse = validationPipeline.validate(request, context: context) { - return errorResponse + return Self.responsePreservingRequestID(errorResponse, body: body) + } + + if isModernHTTPRequest, messageKind.methodName == CallTool.name { + var schemas = toolSchemas.schemas(for: request) + if let toolName = MCPToolHeaderSupport.toolName(in: body), schemas[toolName] == nil, + !toolSchemas.isComplete(for: request) + { + guard let provider = toolSchemaProvider, + let responseData = await provider(request) + else { + return Self.responsePreservingRequestID( + .error(statusCode: 400, .serverError(code: -32020, message: "Unable to resolve tool schema for custom-header validation")), + body: body + ) + } + toolSchemas.record(responseData: responseData, for: request) + schemas = toolSchemas.schemas(for: request) + } + if let mismatch = MCPToolHeaderSupport.validateCall(body: body, request: request, schemas: schemas) { + return Self.responsePreservingRequestID( + .error(statusCode: 400, .serverError(code: -32020, message: mismatch)), + body: body + ) + } + } + + if isModernHTTPRequest, case .request(_, let method) = messageKind, method == Initialize.name { + return Self.responsePreservingRequestID( + .error(statusCode: 404, .methodNotFound("initialize is not available in stateless modern HTTP")), + body: body + ) } // Handle by message type switch messageKind { - case .notification, .response: + case .notification(let method): + if method == CancelledNotification.name, + !cancellationMatchesRequestPrincipal(body: body, request: request) + { + // This transport has no session identity, and cancellation carries + // only a request ID. Do not let an unauthenticated or differently + // authenticated caller cancel another caller's in-flight request. + return .accepted() + } + incomingContinuation.yield(body) + return .accepted() + + case .response: // Yield to server and return 202 Accepted incomingContinuation.yield(body) return .accepted() - case .request(let id, _): + case .request(let id, let method): + guard activeRequestIDs.insert(id).inserted else { + return Self.responsePreservingRequestID( + .error( + statusCode: 409, + .invalidRequest("A request with this JSON-RPC id is already active") + ), + body: body + ) + } + // subscriptions/listen parks until cancel and requires a per-request + // SSE stream; the stateless transport waits for a single JSON body. + if method == SubscriptionsListen.name { + return handleListen(body, requestID: id, request: request) + } + if JSONRPCMessageKind.progressToken(inBody: body) != nil { + return handleProgressRequest(body, requestID: id, request: request) + } return await handleJSONRPCRequest(body, requestID: id, request: request) } } + private func cancellationMatchesRequestPrincipal(body: Data, request: HTTPRequest) -> Bool { + guard let message = try? JSONDecoder().decode(Message.self, from: body), + let requestID = message.params.requestId, + let activeRequest = httpRequestContexts[requestID.transportKey], + let callerIdentity = Self.cancellationIdentity(for: request), + callerIdentity == Self.cancellationIdentity(for: activeRequest) + else { return false } + return true + } + + private static func cancellationIdentity(for request: HTTPRequest) -> String? { + if let clientIdentity = request.clientIdentity, !clientIdentity.isEmpty { + return "client:\(clientIdentity)" + } + if let authorization = request.header(HTTPHeaderName.authorization) { + return "authorization:\(authorization)" + } + if let cookie = request.header("Cookie") { + return "cookie:\(cookie)" + } + return nil + } + + private static func responsePreservingRequestID(_ response: HTTPResponse, body: Data) -> HTTPResponse { + guard case .error(let status, _, _, let extraHeaders) = response, + let bodyObject = try? JSONSerialization.jsonObject(with: body) as? [String: Any], + let id = bodyObject["id"], + var envelope = response.bodyData.flatMap({ try? JSONSerialization.jsonObject(with: $0) as? [String: Any] }) else { + return response + } + envelope["id"] = id + guard let encoded = try? JSONSerialization.data(withJSONObject: envelope) else { return response } + return .httpData(statusCode: status, encoded, headers: [HTTPHeaderName.contentType: ContentType.json].merging(extraHeaders) { _, new in new }) + } + private func handleJSONRPCRequest( _ body: Data, requestID: String, @@ -232,6 +401,7 @@ public actor StatelessHTTPServerTransport: Transport, HTTPContextProviding { responseWaiters[requestID] = continuation } } catch { + activeRequestIDs.remove(requestID) httpRequestContexts.removeValue(forKey: requestID) return .error( statusCode: 500, @@ -239,14 +409,148 @@ public actor StatelessHTTPServerTransport: Transport, HTTPContextProviding { ) } + activeRequestIDs.remove(requestID) httpRequestContexts.removeValue(forKey: requestID) - return .data(responseData, headers: [HTTPHeaderName.contentType: ContentType.json]) + let headers = [HTTPHeaderName.contentType: ContentType.json] + if let statusCode = Self.httpStatus(forJSONRPCError: responseData) { + return .httpData(statusCode: statusCode, responseData, headers: headers) + } + return .data(responseData, headers: headers) + } + + private static func httpStatus(forJSONRPCError data: Data) -> Int? { + guard let envelope = try? JSONSerialization.jsonObject(with: data) as? [String: Any], + let error = envelope["error"] as? [String: Any], + let code = error["code"] as? Int else { return nil } + switch code { + case -32601: return 404 + case -32600, -32602, -32020, -32021: return 400 + default: return nil + } + } + + private func handleListen(_ body: Data, requestID: String, request: HTTPRequest) -> HTTPResponse { + guard let filter = Self.notificationFilter(from: body), + let jsonID = JSONRPCMessageKind.jsonRPCID(inBody: body) else { + activeRequestIDs.remove(requestID) + return Self.responsePreservingRequestID( + .error(statusCode: 400, .invalidParams("subscriptions/listen requires params.notifications")), + body: body + ) + } + let (stream, continuation) = AsyncThrowingStream.makeStream() + listenStreams[requestID] = continuation + pendingListenFilters[requestID] = filter + listenIDs[requestID] = jsonID + httpRequestContexts[requestID] = request + continuation.onTermination = { [weak self] _ in + Task { await self?.listenTerminated(requestID) } + } + incomingContinuation.yield(body) + return .stream(stream, headers: [ + HTTPHeaderName.contentType: ContentType.sse, + HTTPHeaderName.cacheControl: "no-cache, no-transform", + HTTPHeaderName.connection: "keep-alive", + ]) + } + + private func handleProgressRequest(_ body: Data, requestID: String, request: HTTPRequest) -> HTTPResponse { + guard let token = JSONRPCMessageKind.progressToken(inBody: body) else { + activeRequestIDs.remove(requestID) + return .error(statusCode: 400, .invalidParams("Missing progress token")) + } + let (stream, continuation) = AsyncThrowingStream.makeStream() + progressStreams[requestID] = continuation + progressTokens[requestID] = token + httpRequestContexts[requestID] = request + continuation.onTermination = { [weak self] _ in + Task { await self?.progressTerminated(requestID) } + } + incomingContinuation.yield(body) + return .stream(stream, headers: [ + HTTPHeaderName.contentType: ContentType.sse, + HTTPHeaderName.cacheControl: "no-cache, no-transform", + HTTPHeaderName.connection: "keep-alive", + ]) + } + + private func progressTerminated(_ requestID: String) { + activeRequestIDs.remove(requestID) + guard let id = JSONRPCMessageKind.jsonRPCID(inBody: httpRequestContexts[requestID]?.body ?? Data()) else { return } + progressStreams.removeValue(forKey: requestID) + progressTokens.removeValue(forKey: requestID) + httpRequestContexts.removeValue(forKey: requestID) + if let data = try? JSONEncoder().encode(CancelledNotification.message(.init(requestId: id, reason: "SSE stream closed"))) { + incomingContinuation.yield(data) + } + } + + private func listenTerminated(_ requestID: String) { + guard let id = listenIDs[requestID] else { return } + clearListen(requestID) + if let data = try? JSONEncoder().encode(CancelledNotification.message(.init(requestId: id, reason: "SSE stream closed"))) { + incomingContinuation.yield(data) + } + } + + private func clearListen(_ requestID: String) { + activeRequestIDs.remove(requestID) + listenStreams.removeValue(forKey: requestID) + listenFilters.removeValue(forKey: requestID) + pendingListenFilters.removeValue(forKey: requestID) + listenIDs.removeValue(forKey: requestID) + httpRequestContexts.removeValue(forKey: requestID) + } + + private static func notificationFilter(from body: Data) -> SubscriptionsListen.NotificationFilter? { + guard let object = try? JSONSerialization.jsonObject(with: body) as? [String: Any], + let params = object["params"] as? [String: Any], + let notifications = params["notifications"] as? [String: Any], + let data = try? JSONSerialization.data(withJSONObject: notifications) else { return nil } + return try? JSONDecoder().decode(SubscriptionsListen.NotificationFilter.self, from: data) + } + + private static func notification(_ method: String, matches filter: SubscriptionsListen.NotificationFilter, body: Data) -> Bool { + switch method { + case "notifications/tools/list_changed": return filter.toolsListChanged == true + case "notifications/resources/list_changed": return filter.resourcesListChanged == true + case "notifications/prompts/list_changed": return filter.promptsListChanged == true + case "notifications/resources/updated": + guard let object = try? JSONSerialization.jsonObject(with: body) as? [String: Any], + let params = object["params"] as? [String: Any], + let uri = params["uri"] as? String else { return false } + return filter.resourceSubscriptions?.contains(uri) == true + default: return false + } + } + + private static func tagged(_ data: Data, subscriptionID: ID) -> Data { + JSONRPCMessageKind.taggingSubscriptionNotification(data, subscriptionID: subscriptionID) } // MARK: - HTTPContextProviding public func httpRequestContext(for id: ID) -> HTTPRequest? { - httpRequestContexts[id.description] + httpRequestContexts[id.transportKey] + } + + public func closeRequestStream(for id: ID) async { + let key = id.transportKey + if let continuation = listenStreams[key] { + clearListen(key) + continuation.finish() + } + if let continuation = progressStreams.removeValue(forKey: key) { + activeRequestIDs.remove(key) + progressTokens.removeValue(forKey: key) + httpRequestContexts.removeValue(forKey: key) + continuation.finish() + } + if let waiter = responseWaiters.removeValue(forKey: key) { + activeRequestIDs.remove(key) + httpRequestContexts.removeValue(forKey: key) + waiter.resume(throwing: CancellationError()) + } } // MARK: - Termination @@ -263,6 +567,15 @@ public actor StatelessHTTPServerTransport: Transport, HTTPContextProviding { logger.debug("Cancelled waiter for request", metadata: ["requestID": "\(id)"]) } responseWaiters.removeAll() + activeRequestIDs.removeAll() + for (_, continuation) in listenStreams { continuation.finish() } + listenStreams.removeAll() + listenFilters.removeAll() + pendingListenFilters.removeAll() + listenIDs.removeAll() + for (_, continuation) in progressStreams { continuation.finish() } + progressStreams.removeAll() + progressTokens.removeAll() httpRequestContexts.removeAll() // Close incoming stream diff --git a/Sources/MCP/Base/Versioning.swift b/Sources/MCP/Base/Versioning.swift index 0bed5c49..5a4c9714 100644 --- a/Sources/MCP/Base/Versioning.swift +++ b/Sources/MCP/Base/Versioning.swift @@ -4,27 +4,84 @@ import Foundation /// following the format YYYY-MM-DD, to indicate /// the last date backwards incompatible changes were made. /// -/// - SeeAlso: https://modelcontextprotocol.io/specification/2025-11-25/ +/// - SeeAlso: https://modelcontextprotocol.io/specification/2026-07-28/basic/versioning public enum Version { - /// All protocol versions supported by this implementation, ordered from newest to oldest. - public static let supported: Set = [ + /// Modern protocol revisions convey version, identity, and capabilities as + /// per-request metadata (`2026-07-28` and later). They do not use `initialize`. + public static let modern: Set = [ + "2026-07-28", + ] + + /// Legacy protocol revisions establish a session with an `initialize` handshake + /// (`2025-11-25` and earlier). + public static let legacy: Set = [ "2025-11-25", "2025-06-18", "2025-03-26", "2024-11-05", ] + /// All protocol versions supported by this implementation. + public static let supported: Set = modern.union(legacy) + /// The latest protocol version supported by this implementation. - public static let latest = supported.max()! + public static let latest = modern.max()! + + /// The latest legacy (initialize-based) protocol version. + public static let latestLegacy = legacy.max()! + + /// Whether `version` belongs to the modern per-request metadata era. + /// + /// This classifies the protocol era independently from whether this + /// implementation supports that exact revision. + public static func isModern(_ version: String) -> Bool { + let components = version.split(separator: "-", omittingEmptySubsequences: false) + guard components.count == 3, + components[0].count == 4, + components[1].count == 2, + components[2].count == 2, + components.allSatisfy({ $0.allSatisfy({ $0 >= "0" && $0 <= "9" }) }) + else { return false } + return version >= (modern.min() ?? "") + } - /// Negotiates the protocol version based on the client's request and server's capabilities. + /// Whether `version` is a legacy (initialize handshake) revision. + public static func isLegacy(_ version: String) -> Bool { + legacy.contains(version) + } + + /// Negotiates a legacy protocol version for the `initialize` handshake. + /// + /// If the client's requested version is supported (including modern), that + /// version is returned when it is legacy; otherwise the latest *legacy* + /// version is returned. Modern clients should use per-request `_meta` and + /// `server/discover` instead of this path. + /// /// - Parameter clientRequestedVersion: The protocol version requested by the client. - /// - Returns: The negotiated protocol version. If the client's requested version is supported, - /// that version is returned. Otherwise, the server's latest supported version is returned. + /// - Returns: The negotiated protocol version for a legacy session. static func negotiate(clientRequestedVersion: String) -> String { - if supported.contains(clientRequestedVersion) { + if legacy.contains(clientRequestedVersion) { return clientRequestedVersion } - return latest + // A modern-only request on the initialize path is not a negotiated + // modern session — fall back to the latest legacy version so dual-era + // clients that probe initialize still get a usable legacy answer. + return latestLegacy + } + + /// Validates a per-request modern protocol version. + /// + /// Legacy revisions in `_meta` are rejected: modern-shaped requests must use + /// a modern revision (or omit `_meta` and use the legacy initialize path). + /// + /// - Throws: ``MCPError/unsupportedProtocolVersion(requested:supported:)`` when + /// the version is not a modern revision implemented by this SDK. + public static func requireSupported(_ version: String) throws { + guard modern.contains(version) else { + throw MCPError.unsupportedProtocolVersion( + requested: version, + supported: Array(modern).sorted(by: >) + ) + } } } diff --git a/Sources/MCP/Client/Client.swift b/Sources/MCP/Client/Client.swift index bf07d092..fbe1658d 100644 --- a/Sources/MCP/Client/Client.swift +++ b/Sources/MCP/Client/Client.swift @@ -4,6 +4,7 @@ import struct Foundation.Data import struct Foundation.Date import class Foundation.JSONDecoder import class Foundation.JSONEncoder +import class Foundation.JSONSerialization /// Model Context Protocol client public actor Client { @@ -171,6 +172,15 @@ public actor Client { private var serverVersion: String? /// The server instructions private var instructions: String? + /// When set, outbound requests/notifications get modern per-request `_meta`. + private var modernProtocolVersion: String? + /// Parked modern `subscriptions/listen` request (inbound notification channel). + private var modernListenContext: RequestContext? + /// URIs included in the modern listen `resourceSubscriptions` filter. + private var trackedResourceSubscriptions: Set = [] + /// Serializes listen open/refresh so concurrent subscribe calls do not + /// leave multiple parked listen streams. + private var modernListenRefreshInFlight = false /// A dictionary of type-erased notification handlers, keyed by method name private var notificationHandlers: [String: [NotificationHandlerBox]] = [:] @@ -278,10 +288,208 @@ public actor Client { } } - // Automatically initialize after connecting + // Legacy transports negotiate via initialize. Modern (2026-07-28+) HTTP + // clients skip initialize and use server/discover + per-request `_meta`. + if let http = self.connection as? HTTPClientTransport, + let version = await http.protocolVersion, + Version.isModern(version) + { + return try await _connectModern(protocolVersion: version) + } + return try await _initialize() } + /// Modern connect path: `server/discover` instead of `initialize`. + private func _connectModern(protocolVersion: String) async throws -> Initialize.Result { + // Enable per-request `_meta` injection before the discover call. + self.modernProtocolVersion = protocolVersion + + let discover: Discover.Result + do { + discover = try await send(Discover.request(.init())).value + } catch let error as MCPError { + guard case .unsupportedProtocolVersion(_, let supported) = error, + let retryVersion = supported.first(where: Version.modern.contains) + else { + self.modernProtocolVersion = nil + throw error + } + + // A server may reject the initial version once and advertise the + // version to retry in error.data.supported. Retry at most once. + self.modernProtocolVersion = retryVersion + if let http = self.connection as? HTTPClientTransport { + await http.updateNegotiatedProtocolVersion(retryVersion) + } + do { + discover = try await send(Discover.request(.init())).value + } catch { + self.modernProtocolVersion = nil + throw error + } + } catch { + self.modernProtocolVersion = nil + throw error + } + guard discover.supportedVersions.contains(where: { Version.modern.contains($0) }) + else { + self.modernProtocolVersion = nil + throw MCPError.unsupportedProtocolVersion( + requested: protocolVersion, + supported: Array(Version.modern).sorted(by: >) + ) + } + // Only claim revisions this SDK implements. + let negotiated = + discover.supportedVersions.first(where: { Version.modern.contains($0) && $0 == protocolVersion }) + ?? discover.supportedVersions.first(where: { Version.modern.contains($0) }) + ?? Version.latest + guard Version.modern.contains(negotiated) else { + throw MCPError.unsupportedProtocolVersion( + requested: protocolVersion, + supported: Array(Version.modern).sorted(by: >) + ) + } + self.modernProtocolVersion = negotiated + self.serverCapabilities = discover.capabilities + self.instructions = discover.instructions + + // Open subscriptions/listen so server notifications arrive on the POST SSE + // (modern clients must not use the legacy GET stream). + await openModernListen(protocolVersion: negotiated) + + var serverInfo = Server.Info(name: "unknown", version: "0") + if let meta = discover._meta, + case .object(let info) = meta[ProtocolMetaKey.serverInfo] + { + serverInfo = Server.Info( + name: info["name"]?.stringValue ?? serverInfo.name, + version: info["version"]?.stringValue ?? serverInfo.version + ) + } + self.serverVersion = serverInfo.version + + return Initialize.Result( + protocolVersion: negotiated, + capabilities: discover.capabilities, + serverInfo: serverInfo, + instructions: discover.instructions, + _meta: discover._meta + ) + } + + /// Ensures modern outbound JSON-RPC messages carry required per-request `_meta`, + /// and that success results include `resultType: "complete"`. + private func ensureModernRequestMeta(in data: Data) throws -> Data { + guard let version = modernProtocolVersion else { return data } + guard var json = try JSONSerialization.jsonObject(with: data) as? [String: Any] else { + return data + } + + // Client→server responses to server-initiated requests need resultType. + if json["method"] == nil { + if var result = json["result"] as? [String: Any] { + if result["resultType"] == nil { + result["resultType"] = ResultType.complete.rawValue + } + json["result"] = result + return try JSONSerialization.data(withJSONObject: json) + } + return data + } + + var params = json["params"] as? [String: Any] ?? [:] + var meta = params["_meta"] as? [String: Any] ?? [:] + if meta[ProtocolMetaKey.protocolVersion] == nil { + meta[ProtocolMetaKey.protocolVersion] = version + } + if meta[ProtocolMetaKey.clientCapabilities] == nil { + let capsData = try encoder.encode(capabilities) + meta[ProtocolMetaKey.clientCapabilities] = + try JSONSerialization.jsonObject(with: capsData) ?? [String: Any]() + } + if meta[ProtocolMetaKey.clientInfo] == nil { + let infoData = try encoder.encode(clientInfo) + meta[ProtocolMetaKey.clientInfo] = + try JSONSerialization.jsonObject(with: infoData) ?? [String: Any]() + } + params["_meta"] = meta + json["params"] = params + return try JSONSerialization.data(withJSONObject: json) + } + + + + private func openModernListen(protocolVersion: String) async { + let listenMeta = Metadata( + additionalFields: [ + ProtocolMetaKey.protocolVersion: .string(protocolVersion), + ProtocolMetaKey.clientInfo: .object([ + "name": .string(clientInfo.name), + "version": .string(clientInfo.version), + ]), + ] + ) + // Best-effort listen; stateful streaming is required. Stateless servers + // reject listen — connect still succeeds without a notification channel. + do { + modernListenContext = try send( + SubscriptionsListen.request( + .init( + _meta: listenMeta, + notifications: .init( + toolsListChanged: true, + resourcesListChanged: true, + promptsListChanged: true, + resourceSubscriptions: Array(trackedResourceSubscriptions).sorted() + ) + ) + ) + ) + + if let listenContext = modernListenContext { + let listenRequestID = listenContext.requestID + Task { + do { + _ = try await listenContext.value + } catch { + await self.logger?.warning( + "subscriptions/listen ended", + metadata: ["error": "\(error)"] + ) + } + await self.clearModernListenContext(ifMatching: listenRequestID) + } + } + } catch { + await logger?.warning( + "subscriptions/listen unavailable; continuing without notification stream", + metadata: ["error": "\(error)"] + ) + modernListenContext = nil + } + } + + /// Re-open listen with the current resource subscription filter. + private func refreshModernListen() async { + guard let version = modernProtocolVersion else { return } + if modernListenRefreshInFlight { return } + modernListenRefreshInFlight = true + defer { modernListenRefreshInFlight = false } + if let existing = modernListenContext { + modernListenContext = nil + try? await cancelRequest(existing.requestID, reason: "Refreshing listen filter") + } + await openModernListen(protocolVersion: version) + } + + private func clearModernListenContext(ifMatching requestID: ID) { + if modernListenContext?.requestID == requestID { + modernListenContext = nil + } + } + /// Disconnect the client and cancel all pending requests public func disconnect() async { await logger?.debug("Initiating client disconnect...") @@ -289,6 +497,16 @@ public actor Client { // Part 1: Inside actor - Grab state and clear internal references let taskToCancel = self.task let connectionToDisconnect = self.connection + + let listenToCancel = modernListenContext + self.modernListenContext = nil + self.trackedResourceSubscriptions = [] + // Cancel listen while modernProtocolVersion is still set so + // notifications/cancelled keeps modern `_meta` / header agreement. + if let listenToCancel { + try? await cancelRequest(listenToCancel.requestID, reason: "Client disconnected") + } + self.modernProtocolVersion = nil let pendingRequestsToCancel = self.pendingRequests self.task = nil @@ -354,7 +572,8 @@ public actor Client { throw MCPError.internalError("Client connection not initialized") } - let notificationData = try encoder.encode(notification) + var notificationData = try encoder.encode(notification) + notificationData = try ensureModernRequestMeta(in: notificationData) try await connection.send(notificationData) } @@ -363,7 +582,8 @@ public actor Client { guard let connection = connection else { throw MCPError.internalError("Client connection not initialized") } - let responseData = try encoder.encode(response) + var responseData = try encoder.encode(response) + responseData = try ensureModernRequestMeta(in: responseData) try await connection.send(responseData) } @@ -392,7 +612,8 @@ public actor Client { throw MCPError.internalError("Client connection not initialized") } - let requestData = try encoder.encode(request) + var requestData = try encoder.encode(request) + requestData = try ensureModernRequestMeta(in: requestData) let requestTask = Task { try await withCheckedThrowingContinuation { continuation in @@ -443,6 +664,11 @@ public actor Client { pendingRequest.resume(throwing: CancellationError()) } + // Close the client's in-flight POST/SSE for this id (listen refresh + // must not leave the old stream registered for fan-out). + await (connection as? any RequestStreamCancelling)? + .cancelInFlightRequestStream(for: requestID) + // Send cancellation notification to server let notification = CancelledNotification.message( .init(requestId: requestID, reason: reason) @@ -580,6 +806,11 @@ public actor Client { /// - 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 @Sendable (Batch) async throws -> Void) async throws { + if modernProtocolVersion != nil { + throw MCPError.invalidRequest( + "JSON-RPC batching is not supported on modern protocol versions" + ) + } guard let connection = connection else { throw MCPError.internalError("Client connection not initialized") } @@ -630,7 +861,7 @@ public actor Client { private func _initialize() async throws -> Initialize.Result { let request = Initialize.request( .init( - protocolVersion: Version.latest, + protocolVersion: Version.latestLegacy, capabilities: capabilities, clientInfo: clientInfo )) @@ -706,6 +937,34 @@ public actor Client { } public func subscribeToResource(uri: String) async throws { + // On the modern profile, delivery is gated by the listen filter — update + // it first so notifications arrive even if resources/subscribe is absent. + if modernProtocolVersion != nil { + trackedResourceSubscriptions.insert(uri) + await refreshModernListen() + let request = ResourceSubscribe.request(.init(uri: uri)) + do { + _ = try await send(request).value + } catch let error as MCPError { + // Best-effort legacy subscribe; listen filter already covers delivery. + if error.code == MCPError.methodNotFound(nil).code { + await logger?.debug( + "resources/subscribe not available; listen filter updated", + metadata: ["uri": "\(uri)"] + ) + return + } + trackedResourceSubscriptions.remove(uri) + await refreshModernListen() + throw error + } catch { + trackedResourceSubscriptions.remove(uri) + await refreshModernListen() + throw error + } + return + } + try validateServerCapability(\.resources?.subscribe, "Resource subscription") let request = ResourceSubscribe.request(.init(uri: uri)) _ = try await send(request).value @@ -738,7 +997,13 @@ public actor Client { request = ListTools.request(.init()) } let result = try await send(request).value - return (tools: result.tools, nextCursor: result.nextCursor) + let tools: [Tool] + if let http = connection as? HTTPClientTransport { + tools = await http.updateToolDefinitions(result.tools, reset: cursor == nil) + } else { + tools = result.tools + } + return (tools: tools, nextCursor: result.nextCursor) } /// Call a tool on the server. @@ -758,11 +1023,31 @@ public actor Client { meta: Metadata? = nil ) async throws -> (content: [Tool.Content], isError: Bool?) { try validateServerCapability(\.tools, "Tools") + try await ensureHTTPToolDefinition(name) let request = CallTool.request(.init(name: name, arguments: arguments, meta: meta)) let result = try await send(request).value return (content: result.content, isError: result.isError) } + private func ensureHTTPToolDefinition(_ name: String) async throws { + guard let http = connection as? HTTPClientTransport, + let version = await http.protocolVersion, + Version.isModern(version), + !(await http.hasToolDefinition(name)) + else { return } + + var cursor: String? + var visitedCursors = Set() + repeat { + let page = try await listTools(cursor: cursor) + if await http.hasToolDefinition(name) { return } + guard let nextCursor = page.nextCursor, + visitedCursors.insert(nextCursor).inserted + else { return } + cursor = nextCursor + } while true + } + /// Call a tool on the server. /// /// - Parameters: @@ -777,11 +1062,45 @@ public actor Client { public func callTool( name: String, arguments: [String: Value]? = nil, - meta: Metadata? = nil + meta: Metadata? = nil, + inputResponses: [String: Value]? = nil, + requestState: String? = nil ) throws -> RequestContext { try validateServerCapability(\.tools, "Tools") - let request = CallTool.request(.init(name: name, arguments: arguments, meta: meta)) - return try send(request) + return try send( + CallTool.request( + .init( + name: name, + arguments: arguments, + meta: meta, + inputResponses: inputResponses, + requestState: requestState + ) + ) + ) + } + + /// Call a tool and retain the complete result envelope, including modern + /// result type and MRTR input request fields. + public func callToolRequest( + name: String, + arguments: [String: Value]? = nil, + meta: Metadata? = nil, + inputResponses: [String: Value]? = nil, + requestState: String? = nil + ) async throws -> CallTool.Result { + try validateServerCapability(\.tools, "Tools") + try await ensureHTTPToolDefinition(name) + let request = CallTool.request( + .init( + name: name, + arguments: arguments, + meta: meta, + inputResponses: inputResponses, + requestState: requestState + ) + ) + return try await send(request).value } // MARK: - Sampling diff --git a/Sources/MCP/Server/Discover.swift b/Sources/MCP/Server/Discover.swift new file mode 100644 index 00000000..5c081ad2 --- /dev/null +++ b/Sources/MCP/Server/Discover.swift @@ -0,0 +1,77 @@ +/// `server/discover` — modern protocol advertisement (MCP 2026-07-28). +/// +/// Clients MAY call this to learn supported versions and capabilities. Version +/// negotiation can also happen inline via per-request `_meta`. +/// +/// - SeeAlso: https://modelcontextprotocol.io/specification/2026-07-28/basic/versioning +public enum Discover: Method { + public static let name = "server/discover" + + /// Cache scope for discover responses. + public enum CacheScope: String, Hashable, Codable, Sendable { + case `public` + case `private` + } + + public struct Parameters: Hashable, Codable, Sendable, NotRequired { + public var _meta: Metadata + + /// Default `_meta` carries the modern protocol version. + /// Client capabilities are filled by ``Client`` per-request injection when omitted. + public static var defaultMeta: Metadata { + Metadata( + additionalFields: [ + ProtocolMetaKey.protocolVersion: .string(Version.latest), + ] + ) + } + + public init(_meta: Metadata = Self.defaultMeta) { + self._meta = _meta + } + + public init() { + self._meta = Self.defaultMeta + } + + private enum CodingKeys: String, CodingKey { + case _meta + } + + public init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + _meta = try container.decodeIfPresent(Metadata.self, forKey: ._meta) ?? Self.defaultMeta + } + } + + public struct Result: Hashable, Codable, Sendable { + public var resultType: ResultType + public var supportedVersions: [String] + public var capabilities: Server.Capabilities + public var cacheScope: CacheScope + public var ttlMs: Int + public var instructions: String? + public var _meta: Metadata? + + public init( + supportedVersions: [String], + capabilities: Server.Capabilities, + cacheScope: CacheScope = .private, + ttlMs: Int = 0, + instructions: String? = nil, + _meta: Metadata? = nil + ) { + self.resultType = .complete + self.supportedVersions = supportedVersions + self.capabilities = capabilities + self.cacheScope = cacheScope + self.ttlMs = ttlMs + self.instructions = instructions + self._meta = _meta + } + + private enum CodingKeys: String, CodingKey { + case resultType, supportedVersions, capabilities, cacheScope, ttlMs, instructions, _meta + } + } +} diff --git a/Sources/MCP/Server/Prompts.swift b/Sources/MCP/Server/Prompts.swift index acfdcad8..9197f687 100644 --- a/Sources/MCP/Server/Prompts.swift +++ b/Sources/MCP/Server/Prompts.swift @@ -329,14 +329,31 @@ public enum GetPrompt: Method { public struct Parameters: Hashable, Codable, Sendable { public let name: String public let arguments: [String: String]? + public let inputResponses: [String: Value]? + public let requestState: String? - public init(name: String, arguments: [String: String]? = nil) { + public init(name: String, arguments: [String: String]? = nil, inputResponses: [String: Value]? = nil, requestState: String? = nil) { self.name = name self.arguments = arguments + self.inputResponses = inputResponses + self.requestState = requestState + } + + private enum CodingKeys: String, CodingKey { case name, arguments, inputResponses, requestState } + + public init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + name = try container.decode(String.self, forKey: .name) + arguments = try container.decodeIfPresent([String: String].self, forKey: .arguments) + inputResponses = try container.decodeIfPresent([String: Value].self, forKey: .inputResponses) + requestState = try container.decodeIfPresent(String.self, forKey: .requestState) } } public struct Result: Hashable, Codable, Sendable { + public let resultType: ResultType? + public let inputRequests: [String: Value]? + public let requestState: String? public let description: String? public let messages: [Prompt.Message] /// Optional metadata about this result @@ -345,15 +362,21 @@ public enum GetPrompt: Method { public init( description: String? = nil, messages: [Prompt.Message], - _meta: Metadata? = nil + _meta: Metadata? = nil, + resultType: ResultType? = nil, + inputRequests: [String: Value]? = nil, + requestState: String? = nil ) { self.description = description self.messages = messages self._meta = _meta + self.resultType = resultType + self.inputRequests = inputRequests + self.requestState = requestState } private enum CodingKeys: String, CodingKey, CaseIterable { - case description, messages, _meta + case resultType, inputRequests, requestState, description, messages, _meta } public func encode(to encoder: Encoder) throws { @@ -361,6 +384,9 @@ public enum GetPrompt: Method { try container.encodeIfPresent(description, forKey: .description) try container.encode(messages, forKey: .messages) try container.encodeIfPresent(_meta, forKey: ._meta) + try container.encodeIfPresent(resultType, forKey: .resultType) + try container.encodeIfPresent(inputRequests, forKey: .inputRequests) + try container.encodeIfPresent(requestState, forKey: .requestState) } public init(from decoder: Decoder) throws { @@ -368,6 +394,9 @@ public enum GetPrompt: Method { description = try container.decodeIfPresent(String.self, forKey: .description) messages = try container.decode([Prompt.Message].self, forKey: .messages) _meta = try container.decodeIfPresent(Metadata.self, forKey: ._meta) + resultType = try container.decodeIfPresent(ResultType.self, forKey: .resultType) + inputRequests = try container.decodeIfPresent([String: Value].self, forKey: .inputRequests) + requestState = try container.decodeIfPresent(String.self, forKey: .requestState) } } } diff --git a/Sources/MCP/Server/Server.swift b/Sources/MCP/Server/Server.swift index fa75b5bf..63dda368 100644 --- a/Sources/MCP/Server/Server.swift +++ b/Sources/MCP/Server/Server.swift @@ -4,6 +4,7 @@ import struct Foundation.Data import struct Foundation.Date import class Foundation.JSONDecoder import class Foundation.JSONEncoder +import class Foundation.JSONSerialization /// Model Context Protocol server public actor Server { @@ -175,6 +176,9 @@ public actor Server { /// Pending request tasks (for cancellation support) private var pendingRequestTasks: [ID: Task, Error>] = [:] + /// In-flight `subscriptions/listen` request ids (subset of `pendingRequestTasks`). + private var activeListenSubscriptions: Set = [] + /// Pending requests sent to the client, awaiting responses private var pendingRequests: [ID: AnyPendingRequest] = [:] @@ -226,6 +230,11 @@ public actor Server { self.connection = transport registerDefaultHandlers(initializeHook: initializeHook) registerCancellationHandler() + if let schemaTransport = transport as? any HTTPToolSchemaProviderInstalling { + await schemaTransport.setToolSchemaProvider { [weak self] request in + await self?.toolSchemaResponse(for: request) + } + } try await transport.connect() await logger?.debug( @@ -290,6 +299,67 @@ public actor Server { } } + private func toolSchemaResponse(for httpRequest: HTTPRequest) async -> Data? { + guard let handler = methodHandlers[ListTools.name] else { return nil } + + let id = httpRequest.body.flatMap(JSONRPCMessageKind.jsonRPCID(inBody:)) ?? .random + var clientCapabilities: Client.Capabilities? + if let body = httpRequest.body, + let root = try? JSONSerialization.jsonObject(with: body) as? [String: Any], + let params = root["params"], + let paramsData = try? JSONSerialization.data(withJSONObject: params), + let paramsValue = try? JSONDecoder().decode(Value.self, from: paramsData), + let capabilitiesValue = ModernRequestMeta.clientCapabilities(from: paramsValue), + let capabilitiesData = try? JSONEncoder().encode(capabilitiesValue) + { + clientCapabilities = try? JSONDecoder().decode(Client.Capabilities.self, from: capabilitiesData) + } + let context = HandlerContext( + id: id, + httpContext: httpRequest, + clientCapabilities: clientCapabilities + ) + + var cursor: String? + var visitedCursors = Set() + var tools: [[String: Any]] = [] + var responseObject: [String: Any]? + while true { + do { + let parameters = cursor.map(ListTools.Parameters.init(cursor:)) ?? .init() + guard let request = try? AnyRequest(ListTools.request(parameters)) else { return nil } + let response = try await Server.$currentHandlerContext.withValue(context) { + try await handler(request) + } + let responseData = try encoder.encode(response) + guard let currentResponse = try JSONSerialization.jsonObject(with: responseData) as? [String: Any], + let result = currentResponse["result"] as? [String: Any], + let pageTools = result["tools"] as? [[String: Any]] + else { return nil } + tools.append(contentsOf: pageTools) + responseObject = currentResponse + + guard let nextCursor = result["nextCursor"] as? String else { break } + guard visitedCursors.insert(nextCursor).inserted else { return nil } + cursor = nextCursor + } catch { + await logger?.warning( + "Could not resolve tool schema for HTTP header validation", + metadata: ["error": "\(error)"] + ) + return nil + } + } + + guard var responseObject, + var result = responseObject["result"] as? [String: Any] + else { return nil } + result["tools"] = tools + result.removeValue(forKey: "nextCursor") + responseObject["result"] = result + return try? JSONSerialization.data(withJSONObject: responseObject) + } + /// Stop the server public func stop() async { task?.cancel() @@ -302,6 +372,16 @@ public actor Server { request.resume(throwing: MCPError.internalError("Server disconnected")) } + // Cancel in-flight method handlers (e.g. parked subscriptions/listen) + let pendingTasks = pendingRequestTasks + pendingRequestTasks = [:] + activeListenSubscriptions = [] + isInitialized = false + clientCapabilities = nil + for (_, handlerTask) in pendingTasks { + handlerTask.cancel() + } + if let connection = connection { await connection.disconnect() } @@ -320,9 +400,11 @@ public actor Server { /// handlers can observe the originating HTTP request (headers, auth, path, /// body) without changing the `withMethodHandler` signature. public struct HandlerContext: Sendable { - /// The JSON-RPC request id of the in-flight request. SDK-internal use - /// (e.g. transports closing an SSE stream mid-call per SEP-1699). - package let id: ID + /// The JSON-RPC request id of the in-flight request. + /// + /// Use with ``completeSubscriptionsListen(id:)`` to end a parked + /// `subscriptions/listen` stream from outside the listen handler. + public let id: ID /// The originating HTTP request, if the active transport conforms to /// ``HTTPContextProviding``. `nil` for transports that don't carry HTTP @@ -330,9 +412,17 @@ public actor Server { /// path. public let httpContext: HTTPRequest? - package init(id: ID, httpContext: HTTPRequest?) { + /// Per-request client capabilities from modern `_meta`, when present. + public let clientCapabilities: Client.Capabilities? + + package init( + id: ID, + httpContext: HTTPRequest?, + clientCapabilities: Client.Capabilities? = nil + ) { self.id = id self.httpContext = httpContext + self.clientCapabilities = clientCapabilities } } @@ -754,11 +844,128 @@ public actor Server { "id": "\(request.id)", ]) - if configuration.strict { + // Modern (2026-07-28+) requests carry protocol version in params._meta + // and do not require initialize. Legacy strict mode still gates other methods. + let requestProtocolVersion = ModernRequestMeta.protocolVersion(from: request.params) + let hasPerRequestCaps = + ModernRequestMeta.clientCapabilities(from: request.params) != nil + // `initialize` is always legacy — never classify it as modern. + let isModernRequest = + request.method != Initialize.name + && (request.method == Discover.name + || request.method == SubscriptionsListen.name + || (requestProtocolVersion.map(Version.isModern) ?? false) + || hasPerRequestCaps) + + if request.method == Initialize.name, + (requestProtocolVersion.map(Version.isModern) ?? false) || hasPerRequestCaps + { + let response = AnyMethod.response( + id: request.id, + error: .invalidRequest( + "initialize must not carry modern per-request metadata; use server/discover" + ) + ) + if sendResponse { + try await send(response) + return nil + } + return response + } + + if let requestProtocolVersion { + do { + if isModernRequest || Version.isModern(requestProtocolVersion) { + // Discover/listen and modern-meta requests only accept modern revisions. + try Version.requireSupported(requestProtocolVersion) + } else if !Version.supported.contains(requestProtocolVersion) { + throw MCPError.unsupportedProtocolVersion( + requested: requestProtocolVersion, + supported: Array(Version.supported).sorted(by: >) + ) + } + } catch { + let mcpError = + error as? MCPError ?? MCPError.internalError(error.localizedDescription) + let response = AnyMethod.response(id: request.id, error: mcpError) + if sendResponse { + try await send(response) + return nil + } + return response + } + } + + if isModernRequest, + [Ping.name, SetLoggingLevel.name, ResourceSubscribe.name, ResourceUnsubscribe.name].contains(request.method) + { + let response = AnyMethod.response( + id: request.id, + error: MCPError.methodNotFound("Method is not available in the modern protocol: \(request.method)") + ) + if sendResponse { + try await send(response) + return nil + } + return response + } + + // Modern requests MUST include per-request clientCapabilities (empty object OK). + if isModernRequest, + !ModernRequestMeta.hasRequiredClientCapabilities(params: request.params) + { + let response = AnyMethod.response( + id: request.id, + error: .invalidParams( + "Missing \(ProtocolMetaKey.clientCapabilities) in params._meta" + ) + ) + if sendResponse { + try await send(response) + return nil + } + return response + } + + // Modern-shaped requests require a protocol version (discover/listen always, + // or any request that already declared clientCapabilities). + if requestProtocolVersion == nil, + isModernRequest + || ModernRequestMeta.clientCapabilities(from: request.params) != nil + { + let response = AnyMethod.response( + id: request.id, + error: .invalidParams( + "Missing \(ProtocolMetaKey.protocolVersion) in params._meta" + ) + ) + if sendResponse { + try await send(response) + return nil + } + return response + } + + var requestClientCapabilities: Client.Capabilities? = nil + if isModernRequest { + if let capsValue = ModernRequestMeta.clientCapabilities(from: request.params), + case .object = capsValue + { + if let capsData = try? JSONEncoder().encode(capsValue), + let caps = try? JSONDecoder().decode(Client.Capabilities.self, from: capsData) + { + requestClientCapabilities = caps + } else { + requestClientCapabilities = Client.Capabilities() + } + } + } + + if configuration.strict && !isModernRequest { // The client SHOULD NOT send requests other than pings // before the server has responded to the initialize request. switch request.method { - case Initialize.name, Ping.name: + case Initialize.name, Ping.name, Discover.name: break default: try checkInitialized() @@ -783,7 +990,15 @@ public actor Server { // that don't carry HTTP context (stdio, in-memory) don't conform. let httpContext = await (connection as? any HTTPContextProviding)? .httpRequestContext(for: request.id) - let handlerContext = HandlerContext(id: request.id, httpContext: httpContext) + let handlerContext = HandlerContext( + id: request.id, + httpContext: httpContext, + clientCapabilities: requestClientCapabilities + ) + let serverInfoValue: [String: Value] = [ + "name": .string(serverInfo.name), + "version": .string(serverInfo.version), + ] // Create a task to handle the request with cancellation support. // Set currentHandlerContext as a task local so handlers see it. @@ -796,7 +1011,17 @@ public actor Server { // Handle request and get response let response = try await handler(request) - return response + guard isModernRequest else { return response } + switch response.result { + case .success(let value): + let enriched = value.ensuringModernCompleteResult( + serverInfo: serverInfoValue, + cacheHints: [ListTools.name, ListPrompts.name, ListResources.name, ListResourceTemplates.name, ReadResource.name].contains(request.method) + ) + return Response(id: response.id, result: enriched) + case .failure: + return response + } } catch is CancellationError { // Request was cancelled, don't send a response per MCP spec await logger?.debug( @@ -814,10 +1039,16 @@ public actor Server { // Store the handler task for potential cancellation pendingRequestTasks[request.id] = handlerTask + if request.method == SubscriptionsListen.name { + activeListenSubscriptions.insert(request.id) + } // Ensure cleanup happens regardless of success or failure defer { pendingRequestTasks.removeValue(forKey: request.id) + if request.method == SubscriptionsListen.name { + activeListenSubscriptions.remove(request.id) + } } do { @@ -830,7 +1061,10 @@ public actor Server { return response } catch is CancellationError { - // Request was cancelled, don't send a response per MCP spec + // Request was cancelled, don't send a response per MCP spec. + // Close any parked HTTP SSE so listen fan-out cannot keep delivering. + await (connection as? any HTTPContextProviding)? + .closeRequestStream(for: request.id) return nil } catch { // This should not happen as errors are caught in the task @@ -851,9 +1085,35 @@ public actor Server { "Processing notification", metadata: ["method": "\(message.method)"]) + let version = ModernRequestMeta.protocolVersion(from: message.params) + let meta = ModernRequestMeta.metaObject(from: message.params) + let declaresModernProfile = + version != nil + || ModernRequestMeta.clientCapabilities(from: message.params) != nil + || meta?[ProtocolMetaKey.clientInfo] != nil + let hasSupportedModernProfile = + version.map(Version.modern.contains) == true + && ModernRequestMeta.hasRequiredClientCapabilities(params: message.params) + + // Unlike requests, notifications have no response ID to carry an + // unsupported-version error. Ignore malformed or unsupported modern + // profiles before dispatching application handlers. + if declaresModernProfile && !hasSupportedModernProfile { + await logger?.warning( + "Ignoring notification with unsupported or incomplete modern metadata", + metadata: ["method": "\(message.method)", "version": "\(version ?? "missing")"] + ) + return + } + if configuration.strict { - // Check initialization state unless this is an initialized notification - if message.method != InitializedNotification.name { + // Legacy clients must initialize. Modern clients never call initialize; + // allow notifications only when that notification carries its own + // valid modern profile. Cancellation remains transport-scoped. + let modernExempt = + hasSupportedModernProfile + || message.method == CancelledNotification.name + if message.method != InitializedNotification.name && !modernExempt { try checkInitialized() } } @@ -898,16 +1158,30 @@ public actor Server { } } - /// Validate the client capabilities. - /// Throws an error if the server is configured to be strict and the capability is not supported. + /// Validate capabilities declared on the current request, or legacy + /// initialized capabilities when strict legacy mode is enabled. private func validateClientCapability( _ keyPath: KeyPath, _ name: String ) throws { + // Modern requests declare capabilities per request. Never inherit a + // capability from an earlier request or let lenient legacy mode bypass + // this protocol requirement. + if let requestCapabilities = Self.currentHandlerContext?.clientCapabilities { + guard requestCapabilities[keyPath: keyPath] != nil else { + let key = String(name.prefix(1)).lowercased() + String(name.dropFirst()) + throw MCPError.missingRequiredClientCapability([key: .object([:])]) + } + return + } + + // Preserve the pre-existing initialize-era strict-mode behavior. if configuration.strict { - guard let capabilities = clientCapabilities else { + let capabilities = + Self.currentHandlerContext?.clientCapabilities ?? clientCapabilities + guard let capabilities else { throw MCPError.methodNotFound("Client capabilities not initialized") } guard capabilities[keyPath: keyPath] != nil else { @@ -953,8 +1227,109 @@ public actor Server { // Ping withMethodHandler(Ping.self) { _ in return Empty() } + + // Modern protocol: server/discover (no initialize required) + withMethodHandler(Discover.self) { _ in + let versions = Array(Array(Version.supported).sorted().reversed()) + var meta = Metadata() + meta.fields[ProtocolMetaKey.serverInfo] = .object([ + "name": .string(self.serverInfo.name), + "version": .string(self.serverInfo.version), + ]) + return Discover.Result( + supportedVersions: versions, + capabilities: await self.capabilities, + cacheScope: .private, + ttlMs: 0, + instructions: self.instructions, + _meta: meta + ) + } + + // Modern protocol: subscriptions/listen — parks until cancelled. + // Client cancellation closes the stream / sends cancelled; no result. + // Notification filters are enforced by the stateful HTTP transport. + withMethodHandler(SubscriptionsListen.self) { params in + if let version = params._meta[ProtocolMetaKey.protocolVersion]?.stringValue { + try Version.requireSupported(version) + } + if let id = Self.currentHandlerContext?.id { + let requested = params.notifications + let serverCapabilities = await self.capabilities + let agreed = SubscriptionsListen.NotificationFilter( + toolsListChanged: requested.toolsListChanged == true + && serverCapabilities.tools?.listChanged == true ? true : nil, + resourcesListChanged: requested.resourcesListChanged == true + && serverCapabilities.resources?.listChanged == true ? true : nil, + promptsListChanged: requested.promptsListChanged == true + && serverCapabilities.prompts?.listChanged == true ? true : nil, + resourceSubscriptions: serverCapabilities.resources?.subscribe == true + ? requested.resourceSubscriptions : nil + ) + try await self.notify( + SubscriptionsAcknowledgedNotification.message( + .init(subscriptionId: id, notifications: agreed) + ) + ) + } + // Id is tracked at dispatch in handleRequest for completeSubscriptionsListen. + while !Task.isCancelled { + try await Task.sleep(for: .seconds(3600)) + } + throw CancellationError() + } } + /// Ends a modern `subscriptions/listen` stream gracefully with a complete result. + /// + /// Sends a `resultType: complete` response while the HTTP SSE stream is still + /// registered, then cancels the parked listen handler. Unknown, already-finished, + /// or non-listen request ids are ignored. + /// + /// Capture ``HandlerContext/id`` from ``currentHandlerContext`` while a listen + /// (or any) request is in flight, or call ``completeAllSubscriptionsListen()``. + public func completeSubscriptionsListen(id: ID) async throws { + guard activeListenSubscriptions.contains(id) else { return } + guard let task = removePendingRequest(id: id) else { + activeListenSubscriptions.remove(id) + return + } + activeListenSubscriptions.remove(id) + // Emit complete before cancelling so response routing closes the SSE; + // cancelling first races with closeRequestStream and drops the result. + // Always cancel the parked handler — even when send throws — so it cannot + // linger after being removed from pendingRequestTasks. + let result = SubscriptionsListen.Result(subscriptionId: id) + do { + try await send(Response(id: id, result: result)) + } catch { + task.cancel() + await (connection as? any HTTPContextProviding)?.closeRequestStream(for: id) + throw error + } + task.cancel() + _ = try? await task.value + } + + /// Ends every in-flight modern `subscriptions/listen` stream. + public func completeAllSubscriptionsListen() async throws { + let ids = Array(activeListenSubscriptions) + var firstError: Error? + for id in ids { + do { + try await completeSubscriptionsListen(id: id) + } catch { + await logger?.warning( + "completeSubscriptionsListen failed", + metadata: ["id": "\(id)", "error": "\(error)"] + ) + if firstError == nil { firstError = error } + } + } + if let firstError { throw firstError } + } + + private func setInitialState( clientInfo: Client.Info, clientCapabilities: Client.Capabilities, @@ -1008,6 +1383,9 @@ public actor Server { metadata: ["requestId": "\(requestId)"] ) } + // Always attempt to close a parked HTTP SSE for this id (idempotent). + await (self.connection as? any HTTPContextProviding)? + .closeRequestStream(for: requestId) } } diff --git a/Sources/MCP/Server/SubscriptionsListen.swift b/Sources/MCP/Server/SubscriptionsListen.swift new file mode 100644 index 00000000..f91ef93d --- /dev/null +++ b/Sources/MCP/Server/SubscriptionsListen.swift @@ -0,0 +1,106 @@ +/// `subscriptions/listen` — modern long-lived notification stream (MCP 2026-07-28). +/// +/// Replaces the legacy HTTP GET notification endpoint. The listen request stays +/// open until the client cancels (closes the SSE stream / `notifications/cancelled`) +/// or the server ends it gracefully with this result. +/// +/// - SeeAlso: https://modelcontextprotocol.io/specification/2026-07-28/basic/patterns/subscriptions +public enum SubscriptionsListen: Method { + public static let name = "subscriptions/listen" + + public struct NotificationFilter: Hashable, Codable, Sendable { + public var toolsListChanged: Bool? + public var resourcesListChanged: Bool? + public var promptsListChanged: Bool? + public var resourceSubscriptions: [String]? + + public init( + toolsListChanged: Bool? = nil, + resourcesListChanged: Bool? = nil, + promptsListChanged: Bool? = nil, + resourceSubscriptions: [String]? = nil + ) { + self.toolsListChanged = toolsListChanged + self.resourcesListChanged = resourcesListChanged + self.promptsListChanged = promptsListChanged + self.resourceSubscriptions = resourceSubscriptions + } + + package func intersection(with accepted: Self) -> Self { + Self( + toolsListChanged: toolsListChanged == true && accepted.toolsListChanged == true ? true : nil, + resourcesListChanged: resourcesListChanged == true && accepted.resourcesListChanged == true ? true : nil, + promptsListChanged: promptsListChanged == true && accepted.promptsListChanged == true ? true : nil, + resourceSubscriptions: { + guard let requested = resourceSubscriptions, + let accepted = accepted.resourceSubscriptions + else { return nil } + return requested.filter(accepted.contains) + }() + ) + } + } + + public struct Parameters: Hashable, Codable, Sendable { + public var _meta: Metadata + public var notifications: NotificationFilter + + public init(_meta: Metadata, notifications: NotificationFilter) { + self._meta = _meta + self.notifications = notifications + } + + private enum CodingKeys: String, CodingKey { + case _meta, notifications + } + } + + public struct Result: Hashable, Codable, Sendable { + public var resultType: ResultType + public var _meta: Metadata + + public init(subscriptionId: ID, serverInfo: Metadata? = nil) { + self.resultType = .complete + var fields: [String: Value] = [:] + switch subscriptionId { + case .string(let s): + fields[ProtocolMetaKey.subscriptionId] = .string(s) + case .number(let n): + fields[ProtocolMetaKey.subscriptionId] = .int(n) + } + if let serverInfo, + let info = serverInfo[ProtocolMetaKey.serverInfo] + { + fields[ProtocolMetaKey.serverInfo] = info + } + self._meta = Metadata(additionalFields: fields) + } + + private enum CodingKeys: String, CodingKey { + case resultType, _meta + } + } +} + +/// Sent when a `subscriptions/listen` request has been accepted. +public struct SubscriptionsAcknowledgedNotification: Notification { + public static let name = "notifications/subscriptions/acknowledged" + + public struct Parameters: Hashable, Codable, Sendable { + public var _meta: Metadata + public var notifications: SubscriptionsListen.NotificationFilter + + public init( + subscriptionId: ID, + notifications: SubscriptionsListen.NotificationFilter = .init() + ) { + let value: Value + switch subscriptionId { + case .string(let string): value = .string(string) + case .number(let number): value = .int(number) + } + _meta = Metadata(additionalFields: [ProtocolMetaKey.subscriptionId: value]) + self.notifications = notifications + } + } +} diff --git a/Sources/MCP/Server/Tools.swift b/Sources/MCP/Server/Tools.swift index 478ba601..7ac1e237 100644 --- a/Sources/MCP/Server/Tools.swift +++ b/Sources/MCP/Server/Tools.swift @@ -374,17 +374,22 @@ public enum CallTool: Method { /// Arguments to use for the tool call. public let arguments: [String: Value]? + public let inputResponses: [String: Value]? + public let requestState: String? - public init(name: String, arguments: [String: Value]? = nil, meta: Metadata? = nil) { + public init(name: String, arguments: [String: Value]? = nil, meta: Metadata? = nil, inputResponses: [String: Value]? = nil, requestState: String? = nil) { self._meta = meta self.name = name self.arguments = arguments + self.inputResponses = inputResponses + self.requestState = requestState } private enum CodingKeys: String, CodingKey { case _meta case name case arguments + case inputResponses, requestState } public init(from decoder: Decoder) throws { @@ -392,6 +397,8 @@ public enum CallTool: Method { _meta = try container.decodeIfPresent(Metadata.self, forKey: ._meta) name = try container.decode(String.self, forKey: .name) arguments = try container.decodeIfPresent([String: Value].self, forKey: .arguments) + inputResponses = try container.decodeIfPresent([String: Value].self, forKey: .inputResponses) + requestState = try container.decodeIfPresent(String.self, forKey: .requestState) } public func encode(to encoder: Encoder) throws { @@ -399,10 +406,15 @@ public enum CallTool: Method { try container.encodeIfPresent(_meta, forKey: ._meta) try container.encode(name, forKey: .name) try container.encodeIfPresent(arguments, forKey: .arguments) + try container.encodeIfPresent(inputResponses, forKey: .inputResponses) + try container.encodeIfPresent(requestState, forKey: .requestState) } } public struct Result: Hashable, Codable, Sendable { + public let resultType: ResultType? + public let inputRequests: [String: Value]? + public let requestState: String? public let content: [Tool.Content] public let structuredContent: Value? public let isError: Bool? @@ -413,8 +425,14 @@ public enum CallTool: Method { content: [Tool.Content] = [], structuredContent: Value? = nil, isError: Bool? = nil, - _meta: Metadata? = nil + _meta: Metadata? = nil, + resultType: ResultType? = nil, + inputRequests: [String: Value]? = nil, + requestState: String? = nil ) { + self.resultType = resultType + self.inputRequests = inputRequests + self.requestState = requestState self.content = content self.structuredContent = structuredContent self.isError = isError @@ -437,7 +455,7 @@ public enum CallTool: Method { } private enum CodingKeys: String, CodingKey, CaseIterable { - case content, structuredContent, isError, _meta + case resultType, inputRequests, requestState, content, structuredContent, isError, _meta } public func encode(to encoder: Encoder) throws { @@ -446,15 +464,27 @@ public enum CallTool: Method { try container.encodeIfPresent(structuredContent, forKey: .structuredContent) try container.encodeIfPresent(isError, forKey: .isError) try container.encodeIfPresent(_meta, forKey: ._meta) + try container.encodeIfPresent(resultType, forKey: .resultType) + try container.encodeIfPresent(inputRequests, forKey: .inputRequests) + try container.encodeIfPresent(requestState, forKey: .requestState) } public init(from decoder: Decoder) throws { let container = try decoder.container(keyedBy: CodingKeys.self) - content = try container.decode([Tool.Content].self, forKey: .content) + resultType = try container.decodeIfPresent(ResultType.self, forKey: .resultType) + // MRTR input_required responses may omit fields that belong only + // to a completed tool result, including `content`. + if resultType == .inputRequired { + content = try container.decodeIfPresent([Tool.Content].self, forKey: .content) ?? [] + } else { + content = try container.decode([Tool.Content].self, forKey: .content) + } structuredContent = try container.decodeIfPresent( Value.self, forKey: .structuredContent) isError = try container.decodeIfPresent(Bool.self, forKey: .isError) _meta = try container.decodeIfPresent(Metadata.self, forKey: ._meta) + inputRequests = try container.decodeIfPresent([String: Value].self, forKey: .inputRequests) + requestState = try container.decodeIfPresent(String.self, forKey: .requestState) } } } diff --git a/Sources/MCPConformance/Client/main.swift b/Sources/MCPConformance/Client/main.swift index 60b4baef..6f583159 100644 --- a/Sources/MCPConformance/Client/main.swift +++ b/Sources/MCPConformance/Client/main.swift @@ -50,6 +50,18 @@ private func loadConformanceContext() -> [String: String] { return parsed } +private func conformanceProtocolVersion() throws -> String { + guard let version = ProcessInfo.processInfo.environment["MCP_CONFORMANCE_PROTOCOL_VERSION"] else { + return Version.latestLegacy + } + guard Version.supported.contains(version) else { + throw ConformanceError.invalidArguments( + "Unsupported MCP_CONFORMANCE_PROTOCOL_VERSION: \(version)" + ) + } + return version +} + private func percentEncodeFormValue(_ value: String) -> String { let allowed = CharacterSet(charactersIn: "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-._~") return value.addingPercentEncoding(withAllowedCharacters: allowed) ?? value @@ -326,6 +338,7 @@ func runAuthorizationScenario(scenario: String, args: [String]) async throws { let transport = HTTPClientTransport( endpoint: serverURL, streaming: true, + protocolVersion: try conformanceProtocolVersion(), authorizer: OAuthAuthorizer(configuration: oauthConfig), logger: logger ) @@ -388,6 +401,7 @@ func runInitializeScenario(_ args: [String]) async throws { // Create HTTP transport let transport = HTTPClientTransport( endpoint: serverURL, + protocolVersion: try conformanceProtocolVersion(), logger: logger ) @@ -432,6 +446,7 @@ func runToolsCallScenario(_ args: [String]) async throws { // Create HTTP transport let transport = HTTPClientTransport( endpoint: serverURL, + protocolVersion: try conformanceProtocolVersion(), logger: logger ) @@ -468,6 +483,109 @@ func runToolsCallScenario(_ args: [String]) async throws { logger.debug("Tools call scenario completed successfully") } +/// Exercises the client side of SEP-2322 using the SDK's typed request APIs. +/// The protocol leaves input fulfillment to the client application, so this +/// scenario translates the elicitation requests into responses and retries the +/// original tool call with the server's opaque request state. +func runMRTRClientScenario(_ args: [String]) async throws { + guard let serverURLString = args.last, let serverURL = URL(string: serverURLString) else { + throw ConformanceError.invalidArguments("Valid server URL is required") + } + + let transport = HTTPClientTransport( + endpoint: serverURL, + protocolVersion: try conformanceProtocolVersion() + ) + let client = Client( + name: "test-client", + version: "1.0.0", + capabilities: .init(elicitation: .init(form: .init())) + ) + try await client.connect(transport: transport) + _ = try await client.listTools() + + func fulfill(_ result: CallTool.Result) async throws -> [String: Value] { + guard result.resultType == .inputRequired, + let inputRequests = result.inputRequests, + !inputRequests.isEmpty + else { + throw ConformanceError.invalidArguments( + "MRTR response must include elicitation input requests" + ) + } + + var responses: [String: Value] = [:] + for (key, request) in inputRequests { + guard case .object(let object) = request, + object["method"]?.stringValue == CreateElicitation.name, + let params = object["params"] + else { + throw ConformanceError.invalidArguments( + "MRTR input request \(key) is malformed or unsupported" + ) + } + let data = try JSONEncoder().encode(params) + let elicitationParams = try JSONDecoder().decode(CreateElicitation.Parameters.self, from: data) + let response = try await client.send( + CreateElicitation.request(elicitationParams) + ).value + responses[key] = try JSONDecoder().decode( + Value.self, + from: JSONEncoder().encode(response) + ) + } + guard responses.count == inputRequests.count else { + throw ConformanceError.invalidArguments( + "MRTR input requests were not all fulfilled" + ) + } + return responses + } + + let first = try await client.callToolRequest(name: "test_mrtr_echo_state", arguments: [:]) + guard first.resultType == .inputRequired else { + throw ConformanceError.invalidArguments("MRTR state scenario did not request input") + } + let firstInputResponses = try await fulfill(first) + _ = try await client.callToolRequest( + name: "test_mrtr_unrelated", + arguments: [:] + ) + let firstRetry = try await client.callToolRequest( + name: "test_mrtr_echo_state", + arguments: [:], + inputResponses: firstInputResponses, + requestState: first.requestState + ) + guard firstRetry.resultType != .inputRequired, firstRetry.isError != true else { + throw ConformanceError.invalidArguments("MRTR state retry did not complete") + } + + let second = try await client.callToolRequest(name: "test_mrtr_no_state", arguments: [:]) + guard second.resultType == .inputRequired else { + throw ConformanceError.invalidArguments("MRTR no-state scenario did not request input") + } + let secondInputResponses = try await fulfill(second) + let secondRetry = try await client.callToolRequest( + name: "test_mrtr_no_state", + arguments: [:], + inputResponses: secondInputResponses + ) + guard secondRetry.resultType != .inputRequired, secondRetry.isError != true else { + throw ConformanceError.invalidArguments("MRTR no-state retry did not complete") + } + + // A missing resultType is a complete result for backward compatibility. + let defaultResult = try await client.callToolRequest( + name: "test_mrtr_no_result_type", + arguments: [:] + ) + guard defaultResult.resultType == nil || defaultResult.resultType == .complete else { + throw ConformanceError.invalidArguments("Missing resultType was not treated as complete") + } + await client.disconnect() +} + // MARK: - SSE Scenarios /// Handler for SSE-related scenarios (retry, reconnection, etc.) @@ -490,6 +608,7 @@ func runSSEScenario(_ args: [String]) async throws { let transport = HTTPClientTransport( endpoint: serverURL, streaming: true, + protocolVersion: try conformanceProtocolVersion(), logger: logger ) @@ -546,6 +665,7 @@ func runElicitationSEP1034ClientDefaults(_ args: [String]) async throws { let transport = HTTPClientTransport( endpoint: serverURL, streaming: true, + protocolVersion: try conformanceProtocolVersion(), logger: logger ) @@ -638,6 +758,7 @@ func runDefaultScenario(_ args: [String]) async throws { // Create HTTP transport let transport = HTTPClientTransport( endpoint: serverURL, + protocolVersion: try conformanceProtocolVersion(), logger: logger ) @@ -662,6 +783,7 @@ func runDefaultScenario(_ args: [String]) async throws { nonisolated(unsafe) let scenarioHandlers: [String: ScenarioHandler] = [ "initialize": runInitializeScenario, "tools_call": runToolsCallScenario, + "sep-2322-client-request-state": runMRTRClientScenario, "sse-retry": runSSEScenario, "elicitation-sep1034-client-defaults": runElicitationSEP1034ClientDefaults, ] diff --git a/Sources/MCPConformance/Server/HTTPApp.swift b/Sources/MCPConformance/Server/HTTPApp.swift index 6edba56f..428ba280 100644 --- a/Sources/MCPConformance/Server/HTTPApp.swift +++ b/Sources/MCPConformance/Server/HTTPApp.swift @@ -43,13 +43,16 @@ actor HTTPApp { } /// Factory function to create MCP Server instances for each session. - typealias ServerFactory = @Sendable (String, StatefulHTTPServerTransport) async throws -> Server + typealias ServerFactory = @Sendable (String, any Transport) async throws -> Server private let configuration: Configuration private let serverFactory: ServerFactory private let validationPipeline: (any HTTPRequestValidationPipeline)? private var channel: Channel? private var sessions: [String: SessionContext] = [:] + private var statelessServer: Server? + private var statelessTransport: StatelessHTTPServerTransport? + private let statelessInitialization = AsyncInitialization<(StatelessHTTPServerTransport, Server)>() nonisolated let logger: Logger @@ -152,7 +155,8 @@ actor HTTPApp { /// Routes an incoming HTTP request to the appropriate session transport. /// /// - Requests with a valid `Mcp-Session-Id` are forwarded to the matching transport. - /// - POST requests with an `initialize` body create a new session. + /// - POST requests with an `initialize` body or a modern-profile request + /// (`server/discover`, `subscriptions/listen`, modern `_meta`) create a session. /// - All other requests without a session return an error. func handleHTTPRequest(_ request: HTTPRequest) async -> HTTPResponse { let sessionID = request.header(HTTPHeaderName.sessionID) @@ -172,16 +176,28 @@ actor HTTPApp { return response } - // No session — check for initialize request - if request.method.uppercased() == "POST", + // No session — create one for initialize or modern-profile POSTs + // (server/discover, subscriptions/listen, modern `_meta` version). + // Stale/unknown Mcp-Session-Id must 404, not mint a replacement session. + if sessionID == nil, + request.method.uppercased() == "POST", let body = request.body, - let kind = JSONRPCMessageKind(data: body), - kind.isInitializeRequest + let kind = JSONRPCMessageKind(data: body) { - return await createSessionAndHandle(request) + let headerVersion = request.header(HTTPHeaderName.protocolVersion) + let isModern = JSONRPCMessageKind.isModernProfile( + kind: kind, + body: body, + headerProtocolVersion: headerVersion + ) + let modernHeader = headerVersion.map(Version.isModern) ?? false + if kind.isInitializeRequest || isModern || modernHeader { + if isModern || modernHeader { return await handleStateless(request) } + return await createSessionAndHandle(request) + } } - // No session and not initialize + // No session and not a session-creating request if sessionID != nil { return .error(statusCode: 404, .invalidRequest("Not Found: Session not found or expired")) } @@ -191,6 +207,35 @@ actor HTTPApp { ) } + private func handleStateless(_ request: HTTPRequest) async -> HTTPResponse { + if statelessServer == nil || statelessTransport == nil { + do { + let (transport, server) = try await statelessInitialization.value { + let transport = StatelessHTTPServerTransport( + validationPipeline: self.validationPipeline, + logger: self.logger + ) + do { + let server = try await self.serverFactory("stateless", transport) + try await server.start(transport: transport) + return (transport, server) + } catch { + await transport.disconnect() + throw error + } + } + statelessTransport = transport + statelessServer = server + } catch { + return .error(statusCode: 500, .internalError("Failed to start stateless server: \(error.localizedDescription)")) + } + } + guard let statelessTransport else { + return .error(statusCode: 500, .internalError("Stateless server transport is unavailable")) + } + return await statelessTransport.handleRequest(request) + } + // MARK: - Session Management private struct FixedSessionIDGenerator: SessionIDGenerator { @@ -247,6 +292,13 @@ actor HTTPApp { for sessionID in sessions.keys { await closeSession(sessionID) } + let initializedStateless = await statelessInitialization.existingValue() + if let server = statelessServer ?? initializedStateless?.1 { await server.stop() } + if let transport = statelessTransport ?? initializedStateless?.0 { + await transport.disconnect() + } + statelessServer = nil + statelessTransport = nil } private func sessionCleanupLoop() async { @@ -275,6 +327,9 @@ private final class HTTPHandler: ChannelInboundHandler, @unchecked Sendable { typealias OutboundOut = HTTPServerResponsePart private let app: HTTPApp + /// The NIO handler is scoped to one TCP channel, providing a stable caller + /// identity for unauthenticated requests on that connection. + private let clientIdentity = UUID().uuidString private struct RequestState { var head: HTTPRequestHead @@ -282,11 +337,28 @@ private final class HTTPHandler: ChannelInboundHandler, @unchecked Sendable { } private var requestState: RequestState? + /// Active SSE response pump; cancelled when the client disconnects. + /// Mutated only on the channel event loop (channelInactive / eventLoop.execute). + private var activeStreamTask: Task? + /// Identity for the active pump (Task is not Equatable). + private var activeStreamToken: UUID? + /// Set on the event loop when the channel becomes inactive so a late pump + /// registration can cancel immediately instead of parking forever. + private var channelClosed = false init(app: HTTPApp) { self.app = app } + func channelInactive(context: ChannelHandlerContext) { + channelClosed = true + let task = activeStreamTask + activeStreamTask = nil + activeStreamToken = nil + task?.cancel() + context.fireChannelInactive() + } + func channelRead(context: ChannelHandlerContext, data: NIOAny) { let part = unwrapInboundIn(data) @@ -353,12 +425,17 @@ private final class HTTPHandler: ChannelInboundHandler, @unchecked Sendable { } let path = String(state.head.uri.split(separator: "?").first ?? Substring(state.head.uri)) + let callerIdentity = HTTPRequest.stableClientIdentity( + headers: headers, + connectionIdentity: clientIdentity + ) return HTTPRequest( method: state.head.method.rawValue, headers: headers, body: body, - path: path + path: path, + clientIdentity: callerIdentity ) } @@ -388,22 +465,53 @@ private final class HTTPHandler: ChannelInboundHandler, @unchecked Sendable { ctx.flush() } - // Await the SSE stream directly — no Task needed since we're already in one - do { - for try await chunk in stream { + // Gate the pump until it is registered on the event loop so + // channelInactive can cancel it if the client disconnects immediately. + let gate = OneShotGate() + let pumpToken = UUID() + let pump = Task { + await gate.wait() + do { + for try await chunk in stream { + if Task.isCancelled { break } + eventLoop.execute { + var buffer = ctx.channel.allocator.buffer(capacity: chunk.count) + buffer.writeBytes(chunk) + ctx.writeAndFlush( + self.wrapOutboundOut(.body(.byteBuffer(buffer))), promise: nil) + } + } + } catch { + // Truncate the SSE stream so the client can distinguish failure. eventLoop.execute { - var buffer = ctx.channel.allocator.buffer(capacity: chunk.count) - buffer.writeBytes(chunk) - ctx.writeAndFlush( - self.wrapOutboundOut(.body(.byteBuffer(buffer))), promise: nil) + self.channelClosed = true + ctx.close(promise: nil) } } - } catch { - // Stream ended with error — close connection } - eventLoop.execute { - ctx.writeAndFlush(self.wrapOutboundOut(.end(nil)), promise: nil) + if self.channelClosed { + pump.cancel() + } else { + self.activeStreamTask = pump + self.activeStreamToken = pumpToken + } + gate.open() + } + await pump.value + eventLoop.execute { + if self.activeStreamToken == pumpToken { + self.activeStreamTask = nil + self.activeStreamToken = nil + } + } + + if !pump.isCancelled { + eventLoop.execute { + // Skip a graceful .end if the channel already closed (error path). + guard !self.channelClosed else { return } + ctx.writeAndFlush(self.wrapOutboundOut(.end(nil)), promise: nil) + } } default: @@ -430,3 +538,32 @@ private final class HTTPHandler: ChannelInboundHandler, @unchecked Sendable { } } } + +/// Single-use async gate so an SSE pump can wait until it is registered for cancel. +private final class OneShotGate: @unchecked Sendable { + private var continuation: CheckedContinuation? + private var opened = false + private let lock = NSLock() + + func wait() async { + await withCheckedContinuation { (cont: CheckedContinuation) in + lock.lock() + if opened { + lock.unlock() + cont.resume() + } else { + continuation = cont + lock.unlock() + } + } + } + + func open() { + lock.lock() + opened = true + let cont = continuation + continuation = nil + lock.unlock() + cont?.resume() + } +} diff --git a/Sources/MCPConformance/Server/main.swift b/Sources/MCPConformance/Server/main.swift index 6a7ccead..e3d59ee5 100644 --- a/Sources/MCPConformance/Server/main.swift +++ b/Sources/MCPConformance/Server/main.swift @@ -25,6 +25,8 @@ private let testAudioBase64 = "UklGRiYAAABXQVZFZm10IBAAAAABAAEAQB8AAAB9AAACABAAZ actor ServerState { var resourceSubscriptions: Set = [] var watchedResourceContent = "Watched resource content" + var promptListHasChanged = false + var toolListHasChanged = false func subscribe(to uri: String) { resourceSubscriptions.insert(uri) @@ -41,11 +43,87 @@ actor ServerState { func updateWatchedResource(_ newContent: String) { watchedResourceContent = newContent } + + func markPromptListChanged() { + promptListHasChanged = true + } + + func markToolListChanged() { + toolListHasChanged = true + } +} + +private func inputRequest(_ method: String, params: [String: Value]) -> Value { + .object(["method": .string(method), "params": .object(params)]) +} + +private func inputRequiredFixture(_ params: CallTool.Parameters) throws -> CallTool.Result? { + let name = params.name + guard name.hasPrefix("test_input_required_result_") else { return nil } + if name == "test_input_required_result_tampered_state", + params.inputResponses != nil, + params.requestState != "signed-state" { + throw MCPError.invalidParams("requestState integrity check failed") + } + if name == "test_input_required_result_elicitation", + let responses = params.inputResponses, + !(responses["user_name"].map { if case .object = $0 { true } else { false } } ?? false) { + return CallTool.Result( + resultType: .inputRequired, + inputRequests: ["user_name": inputRequest("elicitation/create", params: [ + "message": .string("What is your name?"), + "requestedSchema": .object([ + "type": .string("object"), + "properties": .object(["name": .object(["type": .string("string")])]), + "required": .array([.string("name")]), + ]), + ])] + ) + } + if params.inputResponses != nil { + if name == "test_input_required_result_multi_round", params.requestState == "round-1" { + return CallTool.Result( + resultType: .inputRequired, + inputRequests: ["step2": inputRequest("elicitation/create", params: [ + "message": .string("Step 2: What is your favorite color?"), + "requestedSchema": .object(["type": .string("object"), "properties": .object(["color": .object(["type": .string("string")])]), "required": .array([.string("color")])]), + ])], + requestState: "round-2" + ) + } + return CallTool.Result(content: [.text(text: "Input received", annotations: nil, _meta: nil)]) + } + + let elicitation = inputRequest("elicitation/create", params: [ + "message": .string("What is your name?"), + "requestedSchema": .object(["type": .string("object"), "properties": .object(["name": .object(["type": .string("string")])]), "required": .array([.string("name")])]), + ]) + let sampling = inputRequest("sampling/createMessage", params: [ + "messages": .array([.object(["role": .string("user"), "content": .object(["type": .string("text"), "text": .string("What is the capital of France?")])])]), + "maxTokens": .int(100), + ]) + let roots = inputRequest("roots/list", params: [:]) + var requests: [String: Value] + var state: String? = nil + switch name { + case "test_input_required_result_sampling": requests = ["capital_question": sampling] + case "test_input_required_result_list_roots": requests = ["client_roots": roots] + case "test_input_required_result_request_state": requests = ["confirm": elicitation]; state = "request-state-v1" + case "test_input_required_result_multiple_inputs": requests = ["user_name": elicitation, "greeting": sampling, "client_roots": roots]; state = "multiple-inputs-v1" + case "test_input_required_result_multi_round": requests = ["step1": elicitation]; state = "round-1" + case "test_input_required_result_tampered_state": requests = ["confirm": elicitation]; state = "signed-state" + case "test_input_required_result_capabilities": + let caps = params._meta?[ProtocolMetaKey.clientCapabilities] + requests = [:] + if case .object(let values) = caps, values["sampling"] != nil { requests["capital_question"] = sampling } + default: requests = ["user_name": elicitation] + } + return CallTool.Result(resultType: .inputRequired, inputRequests: requests, requestState: state) } // MARK: - Server Setup -func createConformanceServer(state: ServerState, transport: StatefulHTTPServerTransport) async -> Server { +func createConformanceServer(state: ServerState, transport: any Transport) async -> Server { let server = Server( name: "mcp-conformance-test-server", version: "1.0.0", @@ -60,8 +138,9 @@ func createConformanceServer(state: ServerState, transport: StatefulHTTPServerTr // Tools await server.withMethodHandler(ListTools.self) { _ in - .init(tools: [ + var tools = [ Tool(name: "test_simple_text", description: "Tests simple text content response", inputSchema: .object(["type": "object", "properties": [:]])), + Tool(name: "test_custom_header", description: "Tests SEP-2243 custom parameter headers", inputSchema: .object(["type": "object", "properties": ["tenant": ["type": "string", "x-mcp-header": "Tenant"]], "required": ["tenant"]])), Tool(name: "test_image_content", description: "Tests image content response", inputSchema: .object(["type": "object", "properties": [:]])), Tool(name: "test_audio_content", description: "Tests audio content response", inputSchema: .object(["type": "object", "properties": [:]])), Tool(name: "test_embedded_resource", description: "Tests embedded resource content response", inputSchema: .object(["type": "object", "properties": [:]])), @@ -74,6 +153,15 @@ func createConformanceServer(state: ServerState, transport: StatefulHTTPServerTr Tool(name: "test_tool_with_logging", description: "Tool sends log messages during execution", inputSchema: .object(["type": "object", "properties": [:]])), Tool(name: "test_reconnection", description: "Tests SSE reconnection and resumption with Last-Event-ID", inputSchema: .object(["type": "object", "properties": [:]])), Tool(name: "test_sampling", description: "Tests LLM sampling capabilities", inputSchema: .object(["type": "object", "properties": ["prompt": ["type": "string", "description": "Text to send to the LLM"]], "required": ["prompt"]])), + Tool(name: "test_missing_capability", description: "Diagnostic tool requiring sampling capability", inputSchema: .object(["type": "object", "properties": [:]])), + Tool(name: "test_input_required_result_elicitation", description: "Input-required elicitation fixture", inputSchema: .object(["type": "object", "properties": [:]])), + Tool(name: "test_input_required_result_sampling", description: "Input-required sampling fixture", inputSchema: .object(["type": "object", "properties": [:]])), + Tool(name: "test_input_required_result_list_roots", description: "Input-required roots fixture", inputSchema: .object(["type": "object", "properties": [:]])), + Tool(name: "test_input_required_result_request_state", description: "Input-required state fixture", inputSchema: .object(["type": "object", "properties": [:]])), + Tool(name: "test_input_required_result_multiple_inputs", description: "Multiple input-required fixture", inputSchema: .object(["type": "object", "properties": [:]])), + Tool(name: "test_input_required_result_multi_round", description: "Multi-round input-required fixture", inputSchema: .object(["type": "object", "properties": [:]])), + Tool(name: "test_input_required_result_tampered_state", description: "Tampered state fixture", inputSchema: .object(["type": "object", "properties": [:]])), + Tool(name: "test_input_required_result_capabilities", description: "Capability-filtered input-required fixture", inputSchema: .object(["type": "object", "properties": [:]])), Tool(name: "test_elicitation", description: "Tests user input elicitation", inputSchema: .object(["type": "object", "properties": ["message": ["type": "string", "description": "Text displayed to user"]], "required": ["message"]])), Tool(name: "test_elicitation_sep1034_defaults", description: "Tests elicitation with default values (SEP-1034)", inputSchema: .object(["type": "object", "properties": [:]])), Tool(name: "test_elicitation_sep1330_enums", description: "Tests elicitation with enum variants (SEP-1330)", inputSchema: .object(["type": "object", "properties": [:]])), @@ -95,14 +183,28 @@ func createConformanceServer(state: ServerState, transport: StatefulHTTPServerTr "address": .object(["$ref": .string("#/$defs/address")]) ]), "additionalProperties": .bool(false) - ])) - ]) + ])), + Tool(name: "test_trigger_prompt_change", description: "Conformance hook that changes the prompt list", inputSchema: .object(["type": .string("object")])), + Tool(name: "test_trigger_tool_change", description: "Conformance hook that changes the tool list", inputSchema: .object(["type": .string("object")])), + ] + if await state.toolListHasChanged { + tools.append(Tool(name: "test_dynamic_tool", description: "Added by the conformance list-change hook", inputSchema: .object(["type": .string("object")]))) + } + return .init(tools: tools) } await server.withMethodHandler(CallTool.self) { [weak server, transport] params in + if let fixture = try inputRequiredFixture(params) { return fixture } switch params.name { + case "test_missing_capability": + guard Server.currentHandlerContext?.clientCapabilities?.sampling != nil else { + throw MCPError.missingRequiredClientCapability(["sampling": .object([:])]) + } + return .init(content: [.text(text: "Sampling capability declared", annotations: nil, _meta: nil)], isError: false) case "test_simple_text": return .init(content: [.text(text: "This is a simple text response for testing.", annotations: nil, _meta: nil)], isError: false) + case "test_custom_header": + return .init(content: [.text(text: params.arguments?["tenant"]?.stringValue ?? "missing", annotations: nil, _meta: nil)], isError: false) case "test_image_content": return .init(content: [.image(data: testImageBase64, mimeType: "image/png", annotations: nil, _meta: nil)], isError: false) case "test_audio_content": @@ -120,7 +222,13 @@ func createConformanceServer(state: ServerState, transport: StatefulHTTPServerTr return .init(content: [.text(text: "Logging test completed", annotations: nil, _meta: nil)], isError: false) case "test_progress": let duration = params.arguments?["duration_ms"]?.intValue ?? 1000 - try? await Task.sleep(for: .milliseconds(duration)) + if let token = params._meta?.progressToken { + try await server?.notify(ProgressNotification.message(.init(progressToken: token, progress: 0, total: 100))) + try await Task.sleep(for: .milliseconds(max(1, duration / 2))) + try await server?.notify(ProgressNotification.message(.init(progressToken: token, progress: 50, total: 100))) + } else { + try? await Task.sleep(for: .milliseconds(duration)) + } return .init(content: [.text(text: "Progress test completed", annotations: nil, _meta: nil)], isError: false) case "add_numbers": guard let a = params.arguments?["a"]?.intValue, let b = params.arguments?["b"]?.intValue else { @@ -176,12 +284,20 @@ func createConformanceServer(state: ServerState, transport: StatefulHTTPServerTr try await server?.notify(log3) return .init(content: [.text(text: "Logging test completed", annotations: nil, _meta: nil)], isError: false) + case "test_trigger_tool_change": + await state.markToolListChanged() + try await server?.notify(ToolListChangedNotification.message()) + return .init(content: [.text(text: "Tool list changed", annotations: nil, _meta: nil)], isError: false) + case "test_trigger_prompt_change": + await state.markPromptListChanged() + try await server?.notify(PromptListChangedNotification.message()) + return .init(content: [.text(text: "Prompt list changed", annotations: nil, _meta: nil)], isError: false) case "test_reconnection": // SEP-1699: Close the SSE stream mid-call to trigger client reconnection. // The client should reconnect via GET with Last-Event-ID and receive the // response on the new stream. if let requestID = Server.currentHandlerContext?.id { - await transport.closeSSEStream(forRequestID: requestID.description) + await (transport as? StatefulHTTPServerTransport)?.closeSSEStream(forRequestID: requestID) } // Wait briefly for the client to reconnect before sending the response. try await Task.sleep(for: .milliseconds(100)) @@ -371,12 +487,19 @@ func createConformanceServer(state: ServerState, transport: StatefulHTTPServerTr } // Resources + await server.withMethodHandler(ListResourceTemplates.self) { _ in + .init(templates: [Resource.Template( + uriTemplate: "test://template/{id}", + name: "Template Resource", + description: "A resource template with URI parameters", + mimeType: "text/plain" + )]) + } await server.withMethodHandler(ListResources.self) { _ in .init(resources: [ Resource(name: "Static Text Resource", uri: "test://static-text", description: "A simple static text resource", mimeType: "text/plain"), Resource(name: "Static Binary Resource", uri: "test://static-binary", description: "A simple static binary resource", mimeType: "application/octet-stream"), Resource(name: "Watched Resource", uri: "test://watched", description: "A resource that can be subscribed to for updates", mimeType: "text/plain"), - Resource(name: "Template Resource", uri: "test://template/{id}", description: "A resource template with URI parameters", mimeType: "text/plain"), ]) } @@ -397,7 +520,7 @@ func createConformanceServer(state: ServerState, transport: StatefulHTTPServerTr let id = String(params.uri.dropFirst("test://template/".count)) return .init(contents: [.text("Template resource with id: \(id)", uri: params.uri)]) } - return .init(contents: [.text("Resource not found: \(params.uri)", uri: params.uri)]) + throw MCPError.resourceNotFound(uri: params.uri) } } @@ -413,15 +536,33 @@ func createConformanceServer(state: ServerState, transport: StatefulHTTPServerTr // Prompts await server.withMethodHandler(ListPrompts.self) { _ in - .init(prompts: [ + var prompts = [ + Prompt(name: "test_input_required_result_prompt", description: "Prompt requiring context"), Prompt(name: "test_simple_prompt", description: "A simple prompt without arguments"), Prompt(name: "test_prompt_with_arguments", description: "A prompt that accepts arguments", arguments: [Prompt.Argument(name: "arg1", description: "First test argument", required: true), Prompt.Argument(name: "arg2", description: "Second test argument", required: true)]), Prompt(name: "test_prompt_with_embedded_resource", description: "A prompt that includes embedded resources", arguments: [Prompt.Argument(name: "resourceUri", description: "URI of the resource to embed", required: true)]), Prompt(name: "test_prompt_with_image", description: "A prompt with image content"), - ]) + ] + if await state.promptListHasChanged { + prompts.append(Prompt(name: "test_dynamic_prompt", description: "Added by the conformance list-change hook")) + } + return .init(prompts: prompts) } await server.withMethodHandler(GetPrompt.self) { params in + if params.name == "test_input_required_result_prompt" { + if params.inputResponses == nil { + return .init( + messages: [], + resultType: .inputRequired, + inputRequests: ["user_context": inputRequest("elicitation/create", params: [ + "message": .string("What context should the prompt use?"), + "requestedSchema": .object(["type": .string("object"), "properties": .object(["context": .object(["type": .string("string")])]), "required": .array([.string("context")])]), + ])] + ) + } + return GetPrompt.Result(description: "Prompt with supplied context", messages: [Prompt.Message.user(.text(text: "Context received"))]) + } switch params.name { case "test_simple_prompt": return .init(description: "Simple prompt response", messages: [.user(.text(text: "This is a simple prompt for testing."))]) @@ -498,6 +639,7 @@ struct MCPHTTPServer { AcceptHeaderValidator(mode: .sseRequired), ContentTypeValidator(), ProtocolVersionValidator(), + MCPRoutingHeaderValidator(), SessionValidator(), ]), serverFactory: { sessionID, transport in diff --git a/Tests/MCPTests/ClientTests.swift b/Tests/MCPTests/ClientTests.swift index 8a2882a4..f71f1542 100644 --- a/Tests/MCPTests/ClientTests.swift +++ b/Tests/MCPTests/ClientTests.swift @@ -22,7 +22,7 @@ struct ClientTests { let response = Initialize.response( id: request.id, result: .init( - protocolVersion: Version.latest, + protocolVersion: Version.latestLegacy, capabilities: .init(), serverInfo: .init(name: "TestServer", version: "1.0"), instructions: nil @@ -34,7 +34,7 @@ struct ClientTests { let result = try await client.connect(transport: transport) #expect(await transport.isConnected == true) - #expect(result.protocolVersion == Version.latest) + #expect(result.protocolVersion == Version.latestLegacy) await client.disconnect() #expect(await transport.isConnected == false) initTask.cancel() @@ -59,7 +59,7 @@ struct ClientTests { let response = Initialize.response( id: request.id, result: .init( - protocolVersion: Version.latest, + protocolVersion: Version.latestLegacy, capabilities: .init(), serverInfo: .init(name: "TestServer", version: "1.0"), instructions: nil @@ -70,7 +70,7 @@ struct ClientTests { // Now complete the connect call which will automatically initialize let result = try await client.connect(transport: transport) - #expect(result.protocolVersion == Version.latest) + #expect(result.protocolVersion == Version.latestLegacy) #expect(result.serverInfo.name == "TestServer") #expect(result.serverInfo.version == "1.0") @@ -132,7 +132,7 @@ struct ClientTests { let response = Initialize.response( id: request.id, result: .init( - protocolVersion: Version.latest, + protocolVersion: Version.latestLegacy, capabilities: .init(), serverInfo: .init(name: "TestServer", version: "1.0"), instructions: nil @@ -182,7 +182,7 @@ struct ClientTests { let response = Initialize.response( id: request.id, result: .init( - protocolVersion: Version.latest, + protocolVersion: Version.latestLegacy, capabilities: .init(), serverInfo: .init(name: "TestServer", version: "1.0"), instructions: nil @@ -238,7 +238,7 @@ struct ClientTests { let response = Initialize.response( id: request.id, result: .init( - protocolVersion: Version.latest, + protocolVersion: Version.latestLegacy, capabilities: .init(), serverInfo: .init(name: "TestServer", version: "1.0"), instructions: nil @@ -329,7 +329,7 @@ struct ClientTests { let response = Initialize.response( id: request.id, result: .init( - protocolVersion: Version.latest, + protocolVersion: Version.latestLegacy, capabilities: .init(), serverInfo: .init(name: "TestServer", version: "1.0"), instructions: nil @@ -409,7 +409,7 @@ struct ClientTests { let response = Initialize.response( id: request.id, result: .init( - protocolVersion: Version.latest, + protocolVersion: Version.latestLegacy, capabilities: .init(), serverInfo: .init(name: "TestServer", version: "1.0"), instructions: nil @@ -489,7 +489,7 @@ struct ClientTests { let response = Initialize.response( id: request.id, result: .init( - protocolVersion: Version.latest, + protocolVersion: Version.latestLegacy, capabilities: .init(), serverInfo: .init(name: "TestServer", version: "1.0"), instructions: nil @@ -529,7 +529,7 @@ struct ClientTests { let response = Initialize.response( id: request.id, result: .init( - protocolVersion: Version.latest, + protocolVersion: Version.latestLegacy, capabilities: .init(), serverInfo: .init(name: "TestServer", version: "1.0"), instructions: nil @@ -589,7 +589,7 @@ struct ClientTests { let response = Initialize.response( id: request.id, result: .init( - protocolVersion: Version.latest, + protocolVersion: Version.latestLegacy, capabilities: .init(), serverInfo: .init(name: "TestServer", version: "1.0"), instructions: nil @@ -664,7 +664,7 @@ struct ClientTests { let response = Initialize.response( id: request.id, result: .init( - protocolVersion: Version.latest, + protocolVersion: Version.latestLegacy, capabilities: .init(), serverInfo: .init(name: "TestServer", version: "1.0"), instructions: nil @@ -725,7 +725,7 @@ struct ClientTests { let response = Initialize.response( id: request.id, result: .init( - protocolVersion: Version.latest, + protocolVersion: Version.latestLegacy, capabilities: .init(), serverInfo: .init(name: "TestServer", version: "1.0"), instructions: nil diff --git a/Tests/MCPTests/HTTPClientTransportTests.swift b/Tests/MCPTests/HTTPClientTransportTests.swift index bd8c38f7..ead78ffc 100644 --- a/Tests/MCPTests/HTTPClientTransportTests.swift +++ b/Tests/MCPTests/HTTPClientTransportTests.swift @@ -39,6 +39,7 @@ import Testing var requestHandler: (@Sendable (URLRequest) async throws -> (HTTPURLResponse, Data))? private var callCounts: [URL: Int] = [:] + private var capturedRequests: [URLRequest] = [] func setHandler( _ handler: @Sendable @escaping (URLRequest) async throws -> (HTTPURLResponse, Data) @@ -49,6 +50,7 @@ import Testing func clearHandler() { requestHandler = nil callCounts = [:] + capturedRequests = [] } func executeHandler(for request: URLRequest) async throws -> (HTTPURLResponse, Data) { @@ -59,6 +61,7 @@ import Testing NSLocalizedDescriptionKey: "No request handler set" ]) } + capturedRequests.append(request) if let url = request.url { callCounts[url, default: 0] += 1 } @@ -68,6 +71,10 @@ import Testing func callCount(for url: URL) -> Int { callCounts[url, default: 0] } + + func requests() -> [URLRequest] { + capturedRequests + } } // MARK: - Helper Methods @@ -172,6 +179,115 @@ import Testing await transport.disconnect() } + @Test("Modern connect retries a version rejected with a supported-version list", .httpClientTransportSetup) + func modernConnectRetriesSupportedProtocolVersion() async throws { + let endpoint = URL(string: "https://localhost:8080/modern-retry")! + let configuration = URLSessionConfiguration.ephemeral + configuration.protocolClasses = [MockURLProtocol.self] + let response = HTTPURLResponse( + url: endpoint, + statusCode: 200, + httpVersion: "HTTP/1.1", + headerFields: ["Content-Type": "application/json"] + )! + await MockURLProtocol.setHandler { request in + let body = try #require(request.readBody()) + let message = try #require(JSONSerialization.jsonObject(with: body) as? [String: Any]) + guard let requestID = message["id"] else { + return (response, Data()) + } + let method = try #require(message["method"] as? String) + + if method == Discover.name, + await MockURLProtocol.requestHandlerStorage.callCount(for: endpoint) == 1 + { + let error = try JSONSerialization.data(withJSONObject: [ + "jsonrpc": "2.0", + "id": requestID, + "error": [ + "code": -32022, + "message": "Unsupported protocol version", + "data": [ + "requested": "2026-07-28", + "supported": ["2026-07-28"], + ], + ], + ] as [String: Any]) + return ( + HTTPURLResponse( + url: endpoint, + statusCode: 400, + httpVersion: "HTTP/1.1", + headerFields: ["Content-Type": "application/json"] + )!, + error + ) + } + + let result: [String: Any] = method == Discover.name + ? [ + "resultType": "complete", + "supportedVersions": ["2026-07-28"], + "capabilities": ["tools": [:]], + "cacheScope": "private", + "ttlMs": 0, + ] + : ["resultType": "complete"] + let data = try JSONSerialization.data(withJSONObject: [ + "jsonrpc": "2.0", + "id": requestID, + "result": result, + ] as [String: Any]) + return (response, data) + } + + let transport = HTTPClientTransport( + endpoint: endpoint, + configuration: configuration, + streaming: false, + protocolVersion: "2026-07-28" + ) + let client = Client(name: "test-client", version: "1.0.0") + _ = try await client.connect(transport: transport) + + let requests = await MockURLProtocol.requestHandlerStorage.requests() + let discovers = requests.filter { request in + guard let body = request.readBody(), + let message = try? JSONSerialization.jsonObject(with: body) as? [String: Any] + else { return false } + return message["method"] as? String == Discover.name + } + #expect(discovers.count == 2) + #expect(discovers.allSatisfy { + $0.value(forHTTPHeaderField: "MCP-Protocol-Version") == "2026-07-28" + }) + for request in discovers { + let body = try #require(request.readBody()) + let message = try #require(JSONSerialization.jsonObject(with: body) as? [String: Any]) + let params = try #require(message["params"] as? [String: Any]) + let meta = try #require(params["_meta"] as? [String: Any]) + #expect(meta[ProtocolMetaKey.protocolVersion] as? String == "2026-07-28") + } + await client.disconnect() + } + + #if os(Linux) + @Test("Linux rejects modern HTTP mode while per-request SSE is unsupported", .httpClientTransportSetup) + func linuxModernModeFailsBeforeConnectSucceeds() async throws { + let transport = HTTPClientTransport( + endpoint: testEndpoint, + streaming: false, + protocolVersion: "2026-07-28" + ) + do { + try await transport.connect() + Issue.record("Expected unsupported modern POST SSE error on Linux") + } catch let error as MCPError { + #expect(error.localizedDescription.contains("Linux")) + } + } + #endif + @Test("Send and Receive JSON Response", .httpClientTransportSetup) func testSendAndReceiveJSON() async throws { let configuration = URLSessionConfiguration.ephemeral @@ -214,6 +330,128 @@ import Testing #expect(receivedData == responseData) } + @Test("Client identity remains stable across request and cancellation POSTs", .httpClientTransportSetup) + func testClientIdentityRemainsStableAcrossRequests() async throws { + let configuration = URLSessionConfiguration.ephemeral + configuration.protocolClasses = [MockURLProtocol.self] + + let transport = HTTPClientTransport( + endpoint: testEndpoint, + configuration: configuration, + streaming: false, + logger: nil + ) + try await transport.connect() + + await MockURLProtocol.setHandler { [testEndpoint] request in + let response = HTTPURLResponse( + url: testEndpoint, + statusCode: 202, + httpVersion: "HTTP/1.1", + headerFields: ["Content-Type": "application/json"] + )! + let body = request.readBody() ?? Data() + let isRequest = body.range(of: Data(#""id""#.utf8)) != nil + let responseBody = isRequest + ? #"{"jsonrpc":"2.0","result":{},"id":1}"#.data(using: .utf8)! + : Data() + return (response, responseBody) + } + + try await transport.send(#"{"jsonrpc":"2.0","method":"test/request","id":1}"#.data(using: .utf8)!) + try await transport.send(#"{"jsonrpc":"2.0","method":"notifications/cancelled","params":{"requestId":1}}"#.data(using: .utf8)!) + + let requests = await MockURLProtocol.requestHandlerStorage.requests() + #expect(requests.count == 2) + let identities = requests.compactMap { + $0.value(forHTTPHeaderField: "MCP-Client-Identity") + } + #expect(identities.count == 2) + #expect(identities.first == identities.last) + await transport.disconnect() + } + + @Test("Modern POST mirrors method and routing name into standard headers", .httpClientTransportSetup) + func modernPOSTMirrorsRoutingHeaders() async throws { + let configuration = URLSessionConfiguration.ephemeral + configuration.protocolClasses = [MockURLProtocol.self] + let transport = HTTPClientTransport( + endpoint: testEndpoint, + configuration: configuration, + streaming: false, + protocolVersion: "2026-07-28" + ) + try await transport.connect() + let body = try JSONSerialization.data(withJSONObject: [ + "jsonrpc": "2.0", "id": 8, "method": "tools/call", + "params": ["name": "echo", "arguments": [:] as [String: Any], "_meta": [ + ProtocolMetaKey.protocolVersion: "2026-07-28", + ]], + ] as [String: Any]) + await MockURLProtocol.setHandler { [testEndpoint] request in + #expect(request.value(forHTTPHeaderField: "Mcp-Method") == "tools/call") + #expect(request.value(forHTTPHeaderField: "Mcp-Name") == "echo") + return (HTTPURLResponse( + url: testEndpoint, statusCode: 200, httpVersion: "HTTP/1.1", + headerFields: ["Content-Type": "application/json"] + )!, #"{"jsonrpc":"2.0","id":8,"result":{}}"#.data(using: .utf8)!) + } + try await transport.send(body) + await transport.disconnect() + } + + @Test("Modern tool calls mirror x-mcp-header arguments") + func modernToolCallMirrorsCustomParameterHeader() async throws { + let configuration = URLSessionConfiguration.ephemeral + configuration.protocolClasses = [MockURLProtocol.self] + let transport = HTTPClientTransport(endpoint: testEndpoint, configuration: configuration, streaming: false, protocolVersion: "2026-07-28") + try await transport.connect() + _ = await transport.updateToolDefinitions([Tool( + name: "lookup", description: nil, + inputSchema: .object(["type": .string("object"), "properties": .object([ + "settings": .object(["type": .string("object"), "properties": .object([ + "tenant": .object(["type": .string("string"), "x-mcp-header": .string("Tenant")]), + ])]), + ])]) + )], reset: true) + _ = await transport.updateToolDefinitions([Tool( + name: "later-page", description: nil, + inputSchema: .object(["type": .string("object"), "properties": .object([ + "region": .object(["type": .string("string"), "x-mcp-header": .string("Region")]), + ])]) + )], reset: false) + let body = try JSONSerialization.data(withJSONObject: [ + "jsonrpc": "2.0", "id": 18, "method": "tools/call", + "params": ["name": "lookup", "arguments": ["settings": ["tenant": "acme"]], "_meta": [ProtocolMetaKey.protocolVersion: "2026-07-28"]], + ] as [String: Any]) + await MockURLProtocol.setHandler { [testEndpoint] request in + #expect(request.value(forHTTPHeaderField: "Mcp-Param-Tenant") == "acme") + return (HTTPURLResponse(url: testEndpoint, statusCode: 200, httpVersion: "HTTP/1.1", headerFields: ["Content-Type": "application/json"])!, #"{"jsonrpc":"2.0","id":18,"result":{}}"#.data(using: .utf8)!) + } + try await transport.send(body) + await transport.disconnect() + } + + @Test("Modern tool discovery excludes invalid custom-header annotations") + func invalidCustomHeaderToolIsFiltered() async throws { + let transport = HTTPClientTransport(endpoint: testEndpoint, protocolVersion: "2026-07-28") + let invalid = Tool(name: "invalid", description: nil, inputSchema: .object([ + "type": .string("object"), "properties": .object([ + "score": .object(["type": .string("number"), "x-mcp-header": .string("Score")]), + ]), + ])) + let valid = Tool(name: "valid", description: nil, inputSchema: .object([ + "type": .string("object"), "properties": .object([ + "tenant": .object(["type": .string("string"), "x-mcp-header": .string("Tenant")]), + ]), + ])) + let duplicate = Tool(name: "valid", description: nil, inputSchema: .object([ + "type": .string("object"), "properties": .object([:]), + ])) + let filtered = await transport.updateToolDefinitions([invalid, valid, duplicate], reset: true) + #expect(filtered.map(\.name) == ["valid"]) + } + @Test("Send and Receive Session ID", .httpClientTransportSetup) func testSendAndReceiveSessionID() async throws { let configuration = URLSessionConfiguration.ephemeral @@ -335,6 +573,26 @@ import Testing } } + @Test("HTTP 404 preserves a matching JSON-RPC method-not-found error", .httpClientTransportSetup) + func testHTTP404DecodesMatchingJSONRPCError() async throws { + let configuration = URLSessionConfiguration.ephemeral + configuration.protocolClasses = [MockURLProtocol.self] + let requestBody = #"{"jsonrpc":"2.0","method":"unknown/method","id":44}"#.data(using: .utf8)! + let errorBody = #"{"jsonrpc":"2.0","id":44,"error":{"code":-32601,"message":"Method not found"}}"#.data(using: .utf8)! + await MockURLProtocol.setHandler { [testEndpoint] _ in + (HTTPURLResponse(url: testEndpoint, statusCode: 404, httpVersion: "HTTP/1.1", headerFields: ["Content-Type": "application/json"])!, errorBody) + } + let transport = HTTPClientTransport(endpoint: testEndpoint, configuration: configuration, streaming: false) + try await transport.connect() + do { + try await transport.send(requestBody) + Issue.record("Expected JSON-RPC method-not-found error") + } catch let error as MCPError { + #expect(error.code == -32601) + } + await transport.disconnect() + } + @Test("HTTP 500 Server Error", .httpClientTransportSetup) func testHTTPServerError() async throws { let configuration = URLSessionConfiguration.ephemeral @@ -1178,7 +1436,7 @@ import Testing let requestID = json["id"] as! String let result = Initialize.Result( - protocolVersion: Version.latest, + protocolVersion: Version.latestLegacy, capabilities: .init(tools: .init()), serverInfo: .init(name: "Mock Server", version: "0.0.1"), instructions: nil @@ -1231,7 +1489,7 @@ import Testing // Step 1: Initialize client let initResult = try await client.connect(transport: transport) - #expect(initResult.protocolVersion == Version.latest) + #expect(initResult.protocolVersion == Version.latestLegacy) #expect(initResult.capabilities.tools != nil) // Step 2: Call a tool diff --git a/Tests/MCPTests/HTTPServerTransportTests.swift b/Tests/MCPTests/HTTPServerTransportTests.swift index 8e1c89b4..995edbc7 100644 --- a/Tests/MCPTests/HTTPServerTransportTests.swift +++ b/Tests/MCPTests/HTTPServerTransportTests.swift @@ -10,6 +10,11 @@ private struct FixedSessionIDGenerator: SessionIDGenerator { func generateSessionID() -> String { sessionID } } +private actor InitializationCounter { + private(set) var count = 0 + func increment() { count += 1 } +} + private func makeInitializeBody(id: String = "1") -> Data { let json: [String: Any] = [ "jsonrpc": "2.0", @@ -48,6 +53,14 @@ private func makeResponseBody(id: String = "2") -> Data { return try! JSONSerialization.data(withJSONObject: json) } +private func makeCancellationBody(id: String) throws -> Data { + try JSONSerialization.data(withJSONObject: [ + "jsonrpc": "2.0", + "method": CancelledNotification.name, + "params": ["requestId": id], + ] as [String: Any]) +} + private func makeStatefulPOSTRequest( body: Data, sessionID: String? = nil, @@ -218,11 +231,296 @@ private func initializeSession( return sid } +private struct LegacyHTTPContextProvider: HTTPContextProviding { + func httpRequestContext(for id: ID) async -> HTTPRequest? { nil } +} + // MARK: - StatefulHTTPServerTransport Tests @Suite("StatefulHTTPServerTransport Tests") struct StatefulHTTPServerTransportTests { + @Test("Concurrent initialization callers share one successful startup") + func concurrentInitializationRunsOnce() async throws { + let initialization = AsyncInitialization() + let counter = InitializationCounter() + let results = await withTaskGroup(of: Int?.self, returning: [Int].self) { group in + for _ in 0..<12 { + group.addTask { + try? await initialization.value { + await counter.increment() + try? await Task.sleep(for: .milliseconds(20)) + return 42 + } + } + } + var values: [Int] = [] + for await result in group { + if let result { values.append(result) } + } + return values + } + #expect(results.count == 12) + #expect(results.allSatisfy { $0 == 42 }) + #expect(await counter.count == 1) + } + + @Test("Stateless cancellation identity respects explicit identity and credentials") + func stableClientIdentitySelection() { + #expect(HTTPRequest.stableClientIdentity( + headers: ["mCp-ClIeNt-IdEnTiTy": "stable-capability", "Authorization": "Bearer token"], + connectionIdentity: "channel" + ) == "stable-capability") + #expect(HTTPRequest.stableClientIdentity( + headers: ["Authorization": "Bearer token"], connectionIdentity: "channel" + ) == nil) + #expect(HTTPRequest.stableClientIdentity( + headers: ["Cookie": "session=value"], connectionIdentity: "channel" + ) == nil) + #expect(HTTPRequest.stableClientIdentity( + headers: [:], connectionIdentity: "channel" + ) == "channel") + } + + @Test("HTTP context providers can omit optional stream closing") + func httpContextProviderStreamClosingDefaultsToNoOp() async { + let provider = LegacyHTTPContextProvider() + await provider.closeRequestStream(for: .string("request")) + } + + @Test("Numeric and string JSON-RPC IDs keep separate HTTP request contexts") + func numericAndStringRequestIDsHaveSeparateContexts() async throws { + let transport = makeStatefulTransport() + try await transport.connect() + let stringBody = try JSONSerialization.data(withJSONObject: [ + "jsonrpc": "2.0", "id": "1", "method": "ping", "params": [:] as [String: Any], + ] as [String: Any]) + let numberBody = try JSONSerialization.data(withJSONObject: [ + "jsonrpc": "2.0", "id": 1, "method": "tools/list", "params": [:] as [String: Any], + ] as [String: Any]) + let stringResponse = await transport.handleRequest(makeStatefulPOSTRequest(body: stringBody)) + let numberResponse = await transport.handleRequest(makeStatefulPOSTRequest(body: numberBody)) + guard case .stream = stringResponse, case .stream = numberResponse else { + Issue.record("Expected both requests to have separate response streams") + await transport.disconnect() + return + } + + let stringContext = try #require(await transport.httpRequestContext(for: .string("1"))) + let numberContext = try #require(await transport.httpRequestContext(for: .number(1))) + #expect(JSONRPCMessageKind.jsonRPCID(inBody: try #require(stringContext.body)) == .string("1")) + #expect(JSONRPCMessageKind.jsonRPCID(inBody: try #require(numberContext.body)) == .number(1)) + await transport.disconnect() + } + + @Test("Stateful transport rejects duplicate active JSON-RPC IDs without replacing the stream") + func duplicateActiveStatefulRequestIDIsRejected() async throws { + let transport = makeStatefulTransport() + try await transport.connect() + let body = try JSONSerialization.data(withJSONObject: [ + "jsonrpc": "2.0", "id": "duplicate", "method": "tools/list", "params": [:] as [String: Any], + ] as [String: Any]) + let first = await transport.handleRequest(makeStatefulPOSTRequest(body: body)) + guard case .stream(let firstStream, _) = first else { + Issue.record("Expected first request to retain an SSE response stream") + await transport.disconnect() + return + } + + let duplicate = await transport.handleRequest(makeStatefulPOSTRequest(body: body)) + #expect(duplicate.statusCode == 409) + let duplicateJSON = try #require(duplicate.bodyData.flatMap { try? JSONSerialization.jsonObject(with: $0) as? [String: Any] }) + #expect(duplicateJSON["id"] as? String == "duplicate") + + try await transport.send(try JSONSerialization.data(withJSONObject: [ + "jsonrpc": "2.0", "id": "duplicate", "result": ["tools": []], + ] as [String: Any])) + let chunks = await drainSSEStream(.stream(firstStream, headers: [:]), maxChunks: 3, timeout: .milliseconds(100)) + #expect(chunks.contains { String(decoding: $0, as: UTF8.self).contains("\"id\":\"duplicate\"") }) + await transport.disconnect() + } + + @Test("Modern subscriptions/listen receives an acknowledgement and tagged notifications") + func modernSubscriptionsListenReceivesAcknowledgementAndTaggedNotifications() async throws { + let transport = makeStatefulTransport() + try await transport.connect() + let listenID = "stateful-listen-1" + let listenBody = try JSONSerialization.data(withJSONObject: [ + "jsonrpc": "2.0", + "id": listenID, + "method": "subscriptions/listen", + "params": [ + "_meta": [ + ProtocolMetaKey.protocolVersion: "2026-07-28", + ProtocolMetaKey.clientCapabilities: [String: Any](), + ], + "notifications": ["toolsListChanged": true], + ], + ] as [String: Any]) + let response = await transport.handleRequest(makeStatefulPOSTRequest(body: listenBody)) + guard case .stream(let stream, let headers) = response else { + Issue.record("Expected subscriptions/listen to return an SSE stream") + await transport.disconnect() + return + } + #expect(headers[HTTPHeaderName.sessionID] == nil) + + let acknowledgment = Data(""" + {"jsonrpc":"2.0","method":"notifications/subscriptions/acknowledged","params":{"notifications":{"toolsListChanged":true},"_meta":{"\(ProtocolMetaKey.subscriptionId)":"\(listenID)"}}} + """.utf8) + let notification = Data(""" + {"jsonrpc":"2.0","method":"notifications/tools/list_changed","params":{}} + """.utf8) + try await transport.send(notification) + try await transport.send(acknowledgment) + try await transport.send(notification) + + var iterator = stream.makeAsyncIterator() + var firstMessage: [String: Any]? + for _ in 0..<4 { + guard let frame = try await iterator.next() else { break } + if let message = Self.jsonFromSSE(frame) { + firstMessage = message + break + } + } + let acknowledgmentJSON = try #require(firstMessage) + if acknowledgmentJSON["method"] as? String == "notifications/subscriptions/acknowledged" { + let acknowledgmentParams = try #require(acknowledgmentJSON["params"] as? [String: Any]) + #expect(acknowledgmentParams["notifications"] as? [String: Any] != nil) + + let notificationFrame = try await iterator.next() + let notificationJSON = try #require(notificationFrame.flatMap(Self.jsonFromSSE)) + let params = try #require(notificationJSON["params"] as? [String: Any]) + let meta = try #require(params["_meta"] as? [String: Any]) + #expect(notificationJSON["method"] as? String == "notifications/tools/list_changed") + #expect(meta[ProtocolMetaKey.subscriptionId] as? String == listenID) + } else { + Issue.record("Expected the subscription acknowledgment before change notifications") + let notificationParams = try #require(acknowledgmentJSON["params"] as? [String: Any]) + let notificationMeta = notificationParams["_meta"] as? [String: Any] + #expect(notificationMeta?[ProtocolMetaKey.subscriptionId] as? String == listenID) + } + + await transport.closeRequestStream(for: .string(listenID)) + await transport.disconnect() + } + + @Test("Modern stateful request receives progress notification for its token") + func modernRequestReceivesMatchingProgressNotification() async throws { + let transport = makeStatefulTransport() + try await transport.connect() + let requestBody = try JSONSerialization.data(withJSONObject: [ + "jsonrpc": "2.0", "id": "progress-request", "method": "tools/call", + "params": ["_meta": [ + "progressToken": "progress-token", + ProtocolMetaKey.protocolVersion: "2026-07-28", + ]], + ] as [String: Any]) + let response = await transport.handleRequest(makeStatefulPOSTRequest(body: requestBody)) + guard case .stream(let stream, _) = response else { + Issue.record("Expected request SSE stream") + await transport.disconnect() + return + } + let progress = Data(""" + {"jsonrpc":"2.0","method":"notifications/progress","params":{"progressToken":"progress-token","progress":0.5}} + """.utf8) + try await transport.send(progress) + var iterator = stream.makeAsyncIterator() + var delivered: [String: Any]? + for _ in 0..<2 { + guard let frame = try await iterator.next() else { break } + if Self.jsonFromSSE(frame)?["method"] as? String == "notifications/progress" { + delivered = Self.jsonFromSSE(frame) + break + } + } + #expect(delivered?["method"] as? String == "notifications/progress") + await transport.closeRequestStream(for: .string("progress-request")) + await transport.disconnect() + } + + @Test("Modern stateful progress routing preserves integer and string token types") + func modernProgressTokensKeepJSONValueType() async throws { + let transport = makeStatefulTransport() + try await transport.connect() + + func request(id: String, token: Any) async throws -> HTTPResponse { + let body = try JSONSerialization.data(withJSONObject: [ + "jsonrpc": "2.0", "id": id, "method": "tools/call", + "params": ["_meta": [ + "progressToken": token, + ProtocolMetaKey.protocolVersion: "2026-07-28", + ]], + ] as [String: Any]) + return await transport.handleRequest(makeStatefulPOSTRequest(body: body)) + } + let integerResponse = try await request(id: "integer-token", token: 1) + let stringResponse = try await request(id: "string-token", token: "1") + func sendProgress(_ token: Any, _ progress: Double) async throws { + let data = try JSONSerialization.data(withJSONObject: [ + "jsonrpc": "2.0", "method": "notifications/progress", + "params": ["progressToken": token, "progress": progress], + ] as [String: Any]) + try await transport.send(data) + } + try await sendProgress(1, 0.25) + try await sendProgress("1", 0.75) + let integerFrames = await drainSSEStream(integerResponse, maxChunks: 2, timeout: .milliseconds(80)) + let stringFrames = await drainSSEStream(stringResponse, maxChunks: 3, timeout: .milliseconds(80)) + let integerEvents = integerFrames.compactMap(Self.jsonFromSSE) + let stringEvents = stringFrames.compactMap(Self.jsonFromSSE) + #expect(integerEvents.count == 1) + #expect((integerEvents.first?["params"] as? [String: Any])?["progressToken"] as? Int == 1) + #expect(stringEvents.count == 1) + #expect((stringEvents.first?["params"] as? [String: Any])?["progressToken"] as? String == "1") + await transport.disconnect() + } + + @Test("Modern stateful header validation rejects a mismatched method header") + func modernStatefulHeaderMismatchIsRejected() async throws { + let transport = StatefulHTTPServerTransport() + try await transport.connect() + let body = try JSONSerialization.data(withJSONObject: [ + "jsonrpc": "2.0", "method": "notifications/tools/list_changed", + "params": ["_meta": [ProtocolMetaKey.protocolVersion: "2026-07-28"]], + ] as [String: Any]) + let response = await transport.handleRequest(HTTPRequest( + method: "POST", + headers: [ + "Host": "localhost:3000", HTTPHeaderName.contentType: ContentType.json, + HTTPHeaderName.accept: "application/json, text/event-stream", + HTTPHeaderName.protocolVersion: "2026-07-28", "Mcp-Method": "notifications/prompts/list_changed", + ], body: body + )) + #expect(response.statusCode == 400) + let json = try #require(response.bodyData.flatMap { try? JSONSerialization.jsonObject(with: $0) as? [String: Any] }) + #expect((json["error"] as? [String: Any])?["code"] as? Int == -32020) + await transport.disconnect() + } + + @Test("Malformed stateful subscriptions/listen error preserves request ID") + func malformedStatefulListenPreservesRequestID() async throws { + let transport = makeStatefulTransport() + try await transport.connect() + let body = try JSONSerialization.data(withJSONObject: [ + "jsonrpc": "2.0", "id": 72, "method": "subscriptions/listen", "params": [:] as [String: Any], + ] as [String: Any]) + let response = await transport.handleRequest(makeStatefulPOSTRequest(body: body)) + let json = try #require(response.bodyData.flatMap { try? JSONSerialization.jsonObject(with: $0) as? [String: Any] }) + #expect(json["id"] as? Int == 72) + await transport.disconnect() + } + + fileprivate static func jsonFromSSE(_ frame: Data) -> [String: Any]? { + let text = String(decoding: frame, as: UTF8.self) + guard let line = text.split(separator: "\n").first(where: { $0.hasPrefix("data: ") }) else { + return nil + } + return try? JSONSerialization.jsonObject(with: Data(line.dropFirst(6).utf8)) as? [String: Any] + } + // MARK: - Lifecycle @Test("Connect succeeds") @@ -852,6 +1150,779 @@ struct StatefulHTTPServerTransportTests { @Suite("StatelessHTTPServerTransport Tests") struct StatelessHTTPServerTransportTests { + @Test("Schema provider rejects direct annotated calls before tools/list") + func directAnnotatedCallUsesSchemaProvider() async throws { + let transport = makeStatelessTransport() + let server = Server(name: "schema-provider-test", version: "1.0") + let callCounter = InitializationCounter() + await server.withMethodHandler(ListTools.self) { _ in + ListTools.Result(tools: [Tool( + name: "lookup", description: nil, + inputSchema: .object([ + "type": .string("object"), + "properties": .object(["tenant": .object([ + "type": .string("string"), "x-mcp-header": .string("Tenant"), + ])]), + "required": .array([.string("tenant")]), + ]) + )]) + } + await server.withMethodHandler(CallTool.self) { _ in + await callCounter.increment() + return .init(content: [.text(text: "called", annotations: nil, _meta: nil)], isError: false) + } + try await server.start(transport: transport) + let body = try JSONSerialization.data(withJSONObject: [ + "jsonrpc": "2.0", "id": 82, "method": "tools/call", + "params": [ + "name": "lookup", "arguments": ["tenant": "acme"], + "_meta": [ + ProtocolMetaKey.protocolVersion: "2026-07-28", + ProtocolMetaKey.clientCapabilities: [String: Any](), + ], + ], + ] as [String: Any]) + let response = await transport.handleRequest(HTTPRequest( + method: "POST", + headers: [HTTPHeaderName.contentType: ContentType.json], + body: body, + clientIdentity: "direct-call-client" + )) + #expect(response.statusCode == 400) + let json = try #require(response.bodyData.flatMap { try? JSONSerialization.jsonObject(with: $0) as? [String: Any] }) + #expect(json["id"] as? Int == 82) + #expect((json["error"] as? [String: Any])?["code"] as? Int == -32020) + #expect(await callCounter.count == 0) + await server.stop() + } + + @Test("Schema provider follows pagination for direct annotated calls") + func directAnnotatedCallOnLaterToolPageUsesSchemaProvider() async throws { + let transport = makeStatelessTransport() + let server = Server(name: "schema-provider-pagination-test", version: "1.0") + let callCounter = InitializationCounter() + await server.withMethodHandler(ListTools.self) { params in + if params.cursor == nil { + return ListTools.Result(tools: [Tool( + name: "first-page-tool", description: nil, + inputSchema: .object(["type": .string("object")]) + )], nextCursor: "page-2") + } + return ListTools.Result(tools: [Tool( + name: "lookup", description: nil, + inputSchema: .object([ + "type": .string("object"), + "properties": .object(["tenant": .object([ + "type": .string("string"), "x-mcp-header": .string("Tenant"), + ])]), + "required": .array([.string("tenant")]), + ]) + )]) + } + await server.withMethodHandler(CallTool.self) { _ in + await callCounter.increment() + return .init(content: [.text(text: "called", annotations: nil, _meta: nil)], isError: false) + } + try await server.start(transport: transport) + let body = try JSONSerialization.data(withJSONObject: [ + "jsonrpc": "2.0", "id": 83, "method": "tools/call", + "params": [ + "name": "lookup", "arguments": ["tenant": "acme"], + "_meta": [ + ProtocolMetaKey.protocolVersion: "2026-07-28", + ProtocolMetaKey.clientCapabilities: [String: Any](), + ], + ], + ] as [String: Any]) + let response = await transport.handleRequest(HTTPRequest( + method: "POST", + headers: [ + HTTPHeaderName.contentType: ContentType.json, + "Mcp-Param-Tenant": "acme", + ], + body: body, + clientIdentity: "later-page-client" + )) + #expect(response.statusCode == 200) + let json = try #require(response.bodyData.flatMap { try? JSONSerialization.jsonObject(with: $0) as? [String: Any] }) + #expect(json["id"] as? Int == 83) + #expect(await callCounter.count == 1) + await server.stop() + } + + @Test("Calls to tools absent from tools/list reach the registered handler") + func directUnlistedToolCallReachesHandler() async throws { + let transport = makeStatelessTransport() + let server = Server(name: "unlisted-tool-test", version: "1.0") + let listCounter = InitializationCounter() + let callCounter = InitializationCounter() + await server.withMethodHandler(ListTools.self) { _ in + await listCounter.increment() + return ListTools.Result(tools: []) + } + await server.withMethodHandler(CallTool.self) { _ in + await callCounter.increment() + return .init(content: [.text(text: "called", annotations: nil, _meta: nil)], isError: false) + } + try await server.start(transport: transport) + for id in [84, 87] { + let body = try JSONSerialization.data(withJSONObject: [ + "jsonrpc": "2.0", "id": id, "method": "tools/call", + "params": [ + "name": "diagnostic-tool", "arguments": [:] as [String: String], + "_meta": [ + ProtocolMetaKey.protocolVersion: "2026-07-28", + ProtocolMetaKey.clientCapabilities: [String: Any](), + ], + ], + ] as [String: Any]) + let response = await transport.handleRequest(HTTPRequest( + method: "POST", + headers: [HTTPHeaderName.contentType: ContentType.json], + body: body, + clientIdentity: "unlisted-tool-client" + )) + #expect(response.statusCode == 200) + } + #expect(await listCounter.count == 1) + #expect(await callCounter.count == 2) + await server.stop() + } + + @Test("Tool-list notifications invalidate cached HTTP schemas") + func toolsListChangedInvalidatesCachedSchemas() async throws { + let transport = makeStatelessTransport() + let server = Server(name: "schema-invalidation-test", version: "1.0") + let listCounter = InitializationCounter() + let callCounter = InitializationCounter() + await server.withMethodHandler(ListTools.self) { _ in + await listCounter.increment() + if await listCounter.count == 1 { + return ListTools.Result(tools: [Tool( + name: "lookup", description: nil, + inputSchema: .object(["type": .string("object")]) + )]) + } + return ListTools.Result(tools: [Tool( + name: "lookup", description: nil, + inputSchema: .object([ + "type": .string("object"), + "properties": .object(["tenant": .object([ + "type": .string("string"), "x-mcp-header": .string("Tenant"), + ])]), + "required": .array([.string("tenant")]), + ]) + )]) + } + await server.withMethodHandler(CallTool.self) { _ in + await callCounter.increment() + return .init(content: [.text(text: "called", annotations: nil, _meta: nil)], isError: false) + } + try await server.start(transport: transport) + + let metadata: [String: Any] = [ + ProtocolMetaKey.protocolVersion: "2026-07-28", + ProtocolMetaKey.clientCapabilities: [String: Any](), + ] + let listBody = try JSONSerialization.data(withJSONObject: [ + "jsonrpc": "2.0", "id": 85, "method": "tools/list", + "params": ["_meta": metadata], + ] as [String: Any]) + let listResponse = await transport.handleRequest(HTTPRequest( + method: "POST", + headers: [HTTPHeaderName.contentType: ContentType.json], + body: listBody, + clientIdentity: "schema-change-client" + )) + #expect(listResponse.statusCode == 200) + + try await server.notify(ToolListChangedNotification.message()) + + let callBody = try JSONSerialization.data(withJSONObject: [ + "jsonrpc": "2.0", "id": 86, "method": "tools/call", + "params": ["name": "lookup", "arguments": ["tenant": "acme"], "_meta": metadata], + ] as [String: Any]) + let callResponse = await transport.handleRequest(HTTPRequest( + method: "POST", + headers: [HTTPHeaderName.contentType: ContentType.json], + body: callBody, + clientIdentity: "schema-change-client" + )) + #expect(callResponse.statusCode == 400) + let json = try #require(callResponse.bodyData.flatMap { try? JSONSerialization.jsonObject(with: $0) as? [String: Any] }) + #expect((json["error"] as? [String: Any])?["code"] as? Int == -32020) + #expect(await listCounter.count == 2) + #expect(await callCounter.count == 0) + await server.stop() + } + + @Test("Modern stateless header validation rejects missing method header and preserves request ID") + func modernStatelessMissingMethodHeaderIsRejected() async throws { + let transport = StatelessHTTPServerTransport() + try await transport.connect() + let body = try JSONSerialization.data(withJSONObject: [ + "jsonrpc": "2.0", "id": 81, "method": "subscriptions/listen", + "params": [ + "_meta": [ProtocolMetaKey.protocolVersion: "2026-07-28"], + "notifications": ["toolsListChanged": true], + ], + ] as [String: Any]) + let response = await transport.handleRequest(HTTPRequest( + method: "POST", + headers: [ + "Host": "localhost:3000", HTTPHeaderName.contentType: ContentType.json, + HTTPHeaderName.accept: "application/json, text/event-stream", + HTTPHeaderName.protocolVersion: "2026-07-28", + ], body: body + )) + #expect(response.statusCode == 400) + let json = try #require(response.bodyData.flatMap { try? JSONSerialization.jsonObject(with: $0) as? [String: Any] }) + #expect(json["id"] as? Int == 81) + #expect((json["error"] as? [String: Any])?["code"] as? Int == -32020) + await transport.disconnect() + } + + @Test("Modern stateless progress routing preserves integer and string token types") + func statelessProgressTokensKeepJSONValueType() async throws { + let transport = makeStatelessTransport() + try await transport.connect() + func makeRequest(id: String, token: Any) throws -> Data { + try JSONSerialization.data(withJSONObject: [ + "jsonrpc": "2.0", "id": id, "method": "tools/call", + "params": ["_meta": [ + "progressToken": token, + ProtocolMetaKey.protocolVersion: "2026-07-28", + ]], + ] as [String: Any]) + } + let headers = [ + HTTPHeaderName.contentType: ContentType.json, + HTTPHeaderName.accept: "application/json, text/event-stream", + ] + let integerResponse = await transport.handleRequest(HTTPRequest(method: "POST", headers: headers, body: try makeRequest(id: "integer", token: 1))) + let stringResponse = await transport.handleRequest(HTTPRequest(method: "POST", headers: headers, body: try makeRequest(id: "string", token: "1"))) + let progressEvents: [(Any, Double)] = [(1, 0.25), ("1", 0.75)] + for (token, value) in progressEvents { + try await transport.send(JSONSerialization.data(withJSONObject: [ + "jsonrpc": "2.0", "method": "notifications/progress", + "params": ["progressToken": token, "progress": value], + ] as [String: Any])) + } + let integerEvents = (await drainSSEStream(integerResponse, maxChunks: 1, timeout: .milliseconds(80))).compactMap(StatefulHTTPServerTransportTests.jsonFromSSE) + let stringEvents = (await drainSSEStream(stringResponse, maxChunks: 2, timeout: .milliseconds(80))).compactMap(StatefulHTTPServerTransportTests.jsonFromSSE) + #expect(integerEvents.count == 1) + #expect((integerEvents.first?["params"] as? [String: Any])?["progressToken"] as? Int == 1) + #expect(stringEvents.count == 1) + #expect((stringEvents.first?["params"] as? [String: Any])?["progressToken"] as? String == "1") + await transport.disconnect() + } + + @Test("Malformed stateless subscriptions/listen error preserves request ID") + func malformedStatelessListenPreservesRequestID() async throws { + let transport = makeStatelessTransport() + try await transport.connect() + let body = try JSONSerialization.data(withJSONObject: [ + "jsonrpc": "2.0", "id": 82, "method": "subscriptions/listen", "params": [:] as [String: Any], + ] as [String: Any]) + let response = await transport.handleRequest(HTTPRequest(method: "POST", headers: [:], body: body)) + let json = try #require(response.bodyData.flatMap { try? JSONSerialization.jsonObject(with: $0) as? [String: Any] }) + #expect(json["id"] as? Int == 82) + await transport.disconnect() + } + + @Test("Modern subscriptions/listen opens an SSE stream with an acknowledgement") + func testModernSubscriptionsListenOpensSSEStreamWithAcknowledgement() async throws { + let transport = makeStatelessTransport() + try await transport.connect() + + let requestBody = try JSONSerialization.data(withJSONObject: [ + "jsonrpc": "2.0", + "id": "listen-1", + "method": "subscriptions/listen", + "params": [ + "_meta": [ + ProtocolMetaKey.protocolVersion: "2026-07-28", + ProtocolMetaKey.clientCapabilities: [String: Any](), + ], + "notifications": ["toolsListChanged": true], + ], + ] as [String: Any]) + let request = HTTPRequest( + method: "POST", + headers: [ + HTTPHeaderName.contentType: ContentType.json, + HTTPHeaderName.accept: "application/json, text/event-stream", + ], + body: requestBody + ) + + let response = await transport.handleRequest(request) + guard case .stream(let stream, let headers) = response else { + Issue.record("Expected subscriptions/listen to return an SSE stream") + await transport.disconnect() + return + } + #expect(response.statusCode == 200) + #expect(headers[HTTPHeaderName.contentType] == ContentType.sse) + #expect(headers[HTTPHeaderName.sessionID] == nil) + + try await transport.send(try JSONEncoder().encode( + SubscriptionsAcknowledgedNotification.message( + .init( + subscriptionId: .string("listen-1"), + notifications: .init(toolsListChanged: true) + ) + ) + )) + + var iterator = stream.makeAsyncIterator() + let firstFrame = try await iterator.next() + let frameText = firstFrame.map { String(decoding: $0, as: UTF8.self) } ?? "" + let jsonText = frameText + .split(separator: "\n") + .first(where: { $0.hasPrefix("data: ") }) + .map { String($0.dropFirst(6)) } + let acknowledgement = try jsonText.map { + try JSONSerialization.jsonObject(with: Data($0.utf8)) as? [String: Any] + } ?? nil + #expect(acknowledgement?["method"] as? String == "notifications/subscriptions/acknowledged") + let params = acknowledgement?["params"] as? [String: Any] + let meta = params?["_meta"] as? [String: Any] + #expect(meta?[ProtocolMetaKey.subscriptionId] as? String == "listen-1") + + await transport.closeRequestStream(for: .string("listen-1")) + await transport.disconnect() + } + + @Test("Modern listen stream delivers only opted-in notifications with subscription metadata") + func testModernListenStreamFiltersAndTagsNotifications() async throws { + let transport = makeStatelessTransport() + try await transport.connect() + + let requestBody = try JSONSerialization.data(withJSONObject: [ + "jsonrpc": "2.0", + "id": "listen-2", + "method": "subscriptions/listen", + "params": [ + "_meta": [ + ProtocolMetaKey.protocolVersion: "2026-07-28", + ProtocolMetaKey.clientCapabilities: [String: Any](), + ], + "notifications": ["toolsListChanged": true], + ], + ] as [String: Any]) + let response = await transport.handleRequest( + HTTPRequest( + method: "POST", + headers: [ + HTTPHeaderName.contentType: ContentType.json, + HTTPHeaderName.accept: "application/json, text/event-stream", + ], + body: requestBody + ) + ) + guard case .stream(let stream, _) = response else { + Issue.record("Expected subscriptions/listen to return an SSE stream") + await transport.disconnect() + return + } + + let excluded = Data(#"{"jsonrpc":"2.0","method":"notifications/prompts/list_changed"}"#.utf8) + let included = Data(#"{"jsonrpc":"2.0","method":"notifications/tools/list_changed"}"#.utf8) + try await transport.send(included) + try await transport.send(try JSONEncoder().encode( + SubscriptionsAcknowledgedNotification.message( + .init( + subscriptionId: .string("listen-2"), + notifications: .init(toolsListChanged: true) + ) + ) + )) + try await transport.send(excluded) + try await transport.send(included) + + var iterator = stream.makeAsyncIterator() + let acknowledgementFrame = try await iterator.next() + let notificationFrame = try await iterator.next() + let acknowledgementText = acknowledgementFrame.map { String(decoding: $0, as: UTF8.self) } ?? "" + let frameText = notificationFrame.map { String(decoding: $0, as: UTF8.self) } ?? "" + let jsonText = frameText + .split(separator: "\n") + .first(where: { $0.hasPrefix("data: ") }) + .map { String($0.dropFirst(6)) } + let notification = try jsonText.map { + try JSONSerialization.jsonObject(with: Data($0.utf8)) as? [String: Any] + } ?? nil + let params = notification?["params"] as? [String: Any] + let meta = params?["_meta"] as? [String: Any] + #expect(acknowledgementText.contains("notifications\\/subscriptions\\/acknowledged")) + #expect(notification?["method"] as? String == "notifications/tools/list_changed") + #expect(meta?[ProtocolMetaKey.subscriptionId] as? String == "listen-2") + + await transport.closeRequestStream(for: .string("listen-2")) + await transport.disconnect() + } + + @Test("Duplicate active listen IDs do not replace another client's stream or context") + func duplicateActiveListenIDIsRejectedWithoutOverwritingState() async throws { + let transport = makeStatelessTransport() + try await transport.connect() + let sharedID = "shared-listen-id" + + func request(client: String) throws -> HTTPRequest { + let body = try JSONSerialization.data(withJSONObject: [ + "jsonrpc": "2.0", + "id": sharedID, + "method": "subscriptions/listen", + "params": [ + "_meta": [ + ProtocolMetaKey.protocolVersion: "2026-07-28", + ProtocolMetaKey.clientCapabilities: [String: Any](), + ], + "notifications": ["toolsListChanged": true], + ], + ] as [String: Any]) + return HTTPRequest( + method: "POST", + headers: [ + HTTPHeaderName.contentType: ContentType.json, + HTTPHeaderName.accept: "application/json, text/event-stream", + "X-Test-Client": client, + ], + body: body + ) + } + + let firstResponse = await transport.handleRequest(try request(client: "first")) + guard case .stream = firstResponse else { + Issue.record("Expected the first listen to open an SSE stream") + await transport.disconnect() + return + } + let secondResponse = await transport.handleRequest(try request(client: "second")) + #expect(secondResponse.statusCode == 409) + + let context = try #require(await transport.httpRequestContext(for: .string(sharedID))) + #expect(context.header("X-Test-Client") == "first") + + await transport.disconnect() + } + + @Test("Stateless cancellation only reaches a request with the same authorization principal") + func statelessCancellationIsScopedToAuthorizationPrincipal() async throws { + let transport = makeStatelessTransport() + try await transport.connect() + let requestStream = await transport.receive() + var iterator = requestStream.makeAsyncIterator() + let requestID = "shared-request-id" + + let activeRequest = HTTPRequest( + method: "POST", + headers: [ + HTTPHeaderName.contentType: ContentType.json, + HTTPHeaderName.accept: "application/json", + HTTPHeaderName.authorization: "Bearer first-client", + ], + body: makeRequestBody(id: requestID) + ) + let responseTask = Task { await transport.handleRequest(activeRequest) } + _ = try #require(await iterator.next()) + + let otherClientCancellation = HTTPRequest( + method: "POST", + headers: [ + HTTPHeaderName.contentType: ContentType.json, + HTTPHeaderName.authorization: "Bearer other-client", + ], + body: try makeCancellationBody(id: requestID) + ) + _ = await transport.handleRequest(otherClientCancellation) + + let sentinelNotification = makeNotificationBody(method: "notifications/initialized") + _ = await transport.handleRequest(makeStatelessPOSTRequest(body: sentinelNotification)) + let forwarded = try #require(await iterator.next()) + let forwardedJSON = try #require( + JSONSerialization.jsonObject(with: forwarded) as? [String: Any] + ) + #expect(forwardedJSON["method"] as? String == "notifications/initialized") + + let ownerCancellation = HTTPRequest( + method: "POST", + headers: [ + HTTPHeaderName.contentType: ContentType.json, + HTTPHeaderName.authorization: "Bearer first-client", + ], + body: try makeCancellationBody(id: requestID) + ) + _ = await transport.handleRequest(ownerCancellation) + let forwardedCancellation = try #require(await iterator.next()) + let cancellationJSON = try #require( + JSONSerialization.jsonObject(with: forwardedCancellation) as? [String: Any] + ) + #expect(cancellationJSON["method"] as? String == CancelledNotification.name) + + await transport.disconnect() + _ = await responseTask.value + } + + @Test("Stateless cancellation supports an adapter-supplied identity without credentials") + func statelessCancellationSupportsAdapterIdentity() async throws { + let transport = makeStatelessTransport() + try await transport.connect() + let requestStream = await transport.receive() + var iterator = requestStream.makeAsyncIterator() + let requestID = "unauthenticated-request" + + let activeRequest = HTTPRequest( + method: "POST", + headers: [ + HTTPHeaderName.contentType: ContentType.json, + HTTPHeaderName.accept: "application/json", + ], + body: makeRequestBody(id: requestID), + clientIdentity: "application-user-42" + ) + let responseTask = Task { await transport.handleRequest(activeRequest) } + _ = try #require(await iterator.next()) + + let ownerCancellation = HTTPRequest( + method: "POST", + headers: [HTTPHeaderName.contentType: ContentType.json], + body: try makeCancellationBody(id: requestID), + clientIdentity: "application-user-42" + ) + _ = await transport.handleRequest(ownerCancellation) + let forwardedCancellation = try #require(await iterator.next()) + let cancellationJSON = try #require( + JSONSerialization.jsonObject(with: forwardedCancellation) as? [String: Any] + ) + #expect(cancellationJSON["method"] as? String == CancelledNotification.name) + + await transport.disconnect() + _ = await responseTask.value + } + + @Test("Numeric and string listen IDs keep separate HTTP request contexts") + func numericAndStringListenIDsHaveSeparateContexts() async throws { + let transport = makeStatelessTransport() + try await transport.connect() + + func request(_ id: Any) throws -> HTTPRequest { + let body = try JSONSerialization.data(withJSONObject: [ + "jsonrpc": "2.0", + "id": id, + "method": "subscriptions/listen", + "params": [ + "_meta": [ + ProtocolMetaKey.protocolVersion: "2026-07-28", + ProtocolMetaKey.clientCapabilities: [String: Any](), + ], + "notifications": ["toolsListChanged": true], + ], + ] as [String: Any]) + return HTTPRequest( + method: "POST", + headers: [ + HTTPHeaderName.contentType: ContentType.json, + HTTPHeaderName.accept: "application/json, text/event-stream", + ], + body: body + ) + } + + let stringResponse = await transport.handleRequest(try request("1")) + let numberResponse = await transport.handleRequest(try request(1)) + guard case .stream = stringResponse, case .stream = numberResponse else { + Issue.record("Expected both listen requests to have separate streams") + await transport.disconnect() + return + } + let stringContext = try #require(await transport.httpRequestContext(for: .string("1"))) + let numberContext = try #require(await transport.httpRequestContext(for: .number(1))) + #expect(JSONRPCMessageKind.jsonRPCID(inBody: try #require(stringContext.body)) == .string("1")) + #expect(JSONRPCMessageKind.jsonRPCID(inBody: try #require(numberContext.body)) == .number(1)) + await transport.disconnect() + } + + @Test("Modern acknowledgments are routed only to their originating listen stream") + func modernAcknowledgmentIsIsolatedToItsListenStream() async throws { + let transport = makeStatelessTransport() + try await transport.connect() + + func listenRequest(_ id: String) throws -> HTTPRequest { + let body = try JSONSerialization.data(withJSONObject: [ + "jsonrpc": "2.0", + "id": id, + "method": "subscriptions/listen", + "params": [ + "_meta": [ + ProtocolMetaKey.protocolVersion: "2026-07-28", + ProtocolMetaKey.clientCapabilities: [String: Any](), + ], + "notifications": ["toolsListChanged": true], + ], + ] as [String: Any]) + return HTTPRequest( + method: "POST", + headers: [ + HTTPHeaderName.contentType: ContentType.json, + HTTPHeaderName.accept: "application/json, text/event-stream", + ], + body: body + ) + } + + let firstResponse = await transport.handleRequest(try listenRequest("listen-a")) + let secondResponse = await transport.handleRequest(try listenRequest("listen-b")) + guard case .stream(let firstStream, _) = firstResponse, + case .stream(let secondStream, _) = secondResponse + else { + Issue.record("Expected both subscriptions to return SSE streams") + await transport.disconnect() + return + } + + func acknowledgment(_ id: String) -> Data { + Data(""" + {"jsonrpc":"2.0","method":"notifications/subscriptions/acknowledged","params":{"notifications":{"toolsListChanged":true},"_meta":{"\(ProtocolMetaKey.subscriptionId)":"\(id)"}}} + """.utf8) + } + try await transport.send(acknowledgment("listen-a")) + try await transport.send(acknowledgment("listen-b")) + var firstIterator = firstStream.makeAsyncIterator() + var secondIterator = secondStream.makeAsyncIterator() + let firstFrameValue = try await firstIterator.next() + let firstFrame = try #require(firstFrameValue) + let secondFrameValue = try await secondIterator.next() + let secondFrame = try #require(secondFrameValue) + await transport.closeRequestStream(for: .string("listen-a")) + await transport.closeRequestStream(for: .string("listen-b")) + #expect(await transport.httpRequestContext(for: .string("listen-a")) == nil) + #expect(await transport.httpRequestContext(for: .string("listen-b")) == nil) + + func message(from frame: Data) throws -> [String: Any] { + let text = String(decoding: frame, as: UTF8.self) + let line = try #require(text.split(separator: "\n").first(where: { $0.hasPrefix("data: ") })) + return try #require( + JSONSerialization.jsonObject(with: Data(line.dropFirst(6).utf8)) as? [String: Any] + ) + } + let firstMessage = try message(from: firstFrame) + let firstMeta = (firstMessage["params"] as? [String: Any])?["_meta"] as? [String: Any] + #expect(firstMeta?[ProtocolMetaKey.subscriptionId] as? String == "listen-a") + let secondMessage = try message(from: secondFrame) + let secondMeta = (secondMessage["params"] as? [String: Any])?["_meta"] as? [String: Any] + #expect(secondMeta?[ProtocolMetaKey.subscriptionId] as? String == "listen-b") + await transport.disconnect() + } + + @Test("Modern listen only delivers notification types accepted in the acknowledgment") + func modernListenHonorsAcknowledgedNotificationSubset() async throws { + let transport = makeStatelessTransport() + try await transport.connect() + let requestBody = try JSONSerialization.data(withJSONObject: [ + "jsonrpc": "2.0", + "id": "listen-subset", + "method": "subscriptions/listen", + "params": [ + "_meta": [ + ProtocolMetaKey.protocolVersion: "2026-07-28", + ProtocolMetaKey.clientCapabilities: [String: Any](), + ], + "notifications": ["toolsListChanged": true], + ], + ] as [String: Any]) + let response = await transport.handleRequest(HTTPRequest( + method: "POST", + headers: [ + HTTPHeaderName.contentType: ContentType.json, + HTTPHeaderName.accept: "application/json, text/event-stream", + ], + body: requestBody + )) + guard case .stream(let stream, _) = response else { + Issue.record("Expected subscriptions/listen to return an SSE stream") + await transport.disconnect() + return + } + let acknowledgment = Data(""" + {"jsonrpc":"2.0","method":"notifications/subscriptions/acknowledged","params":{"notifications":{},"_meta":{"\(ProtocolMetaKey.subscriptionId)":"listen-subset"}}} + """.utf8) + let toolsChanged = Data(#"{"jsonrpc":"2.0","method":"notifications/tools/list_changed"}"#.utf8) + try await transport.send(acknowledgment) + try await transport.send(toolsChanged) + await transport.closeRequestStream(for: .string("listen-subset")) + + var methods: [String] = [] + for try await frame in stream { + let text = String(decoding: frame, as: UTF8.self) + guard let line = text.split(separator: "\n").first(where: { $0.hasPrefix("data: ") }), + let json = try? JSONSerialization.jsonObject( + with: Data(line.dropFirst(6).utf8) + ) as? [String: Any], + let method = json["method"] as? String + else { continue } + methods.append(method) + } + #expect(methods == [SubscriptionsAcknowledgedNotification.name]) + await transport.disconnect() + } + + @Test("Modern validation errors preserve request ID and use JSON-RPC invalid params") + func testModernValidationErrorPreservesRequestID() async throws { + let transport = StatelessHTTPServerTransport( + validationPipeline: StandardValidationPipeline(validators: [ProtocolVersionValidator()]) + ) + try await transport.connect() + let requestBody = try JSONSerialization.data(withJSONObject: [ + "jsonrpc": "2.0", + "id": 101, + "method": "server/discover", + "params": [:] as [String: Any], + ] as [String: Any]) + + let response = await transport.handleRequest( + HTTPRequest( + method: "POST", + headers: [HTTPHeaderName.protocolVersion: "2026-07-28"], + body: requestBody + ) + ) + let body = try #require(response.bodyData) + let envelope = try #require(JSONSerialization.jsonObject(with: body) as? [String: Any]) + let error = try #require(envelope["error"] as? [String: Any]) + + #expect(response.statusCode == 400) + #expect(envelope["id"] as? Int == 101) + #expect(error["code"] as? Int == -32602) + + await transport.disconnect() + } + + @Test("Modern method-not-found maps to HTTP 404") + func testModernMethodNotFoundUsesHTTP404() async throws { + let transport = makeStatelessTransport() + let server = Server(name: "TestServer", version: "1.0") + try await server.start(transport: transport) + let requestBody = try JSONSerialization.data(withJSONObject: [ + "jsonrpc": "2.0", "id": 102, "method": "unknown/method", + "params": ["_meta": [ + ProtocolMetaKey.protocolVersion: "2026-07-28", + ProtocolMetaKey.clientCapabilities: [String: Any](), + ]], + ] as [String: Any]) + let response = await transport.handleRequest(HTTPRequest( + method: "POST", + headers: [HTTPHeaderName.contentType: ContentType.json, HTTPHeaderName.accept: "application/json, text/event-stream"], + body: requestBody + )) + #expect(response.statusCode == 404) + let envelope = try #require(response.bodyData.flatMap { try? JSONSerialization.jsonObject(with: $0) as? [String: Any] }) + let error = try #require(envelope["error"] as? [String: Any]) + #expect(error["code"] as? Int == -32601) + await server.stop() + } + // MARK: - Lifecycle @Test("Connect succeeds") diff --git a/Tests/MCPTests/ModernProtocolTests.swift b/Tests/MCPTests/ModernProtocolTests.swift new file mode 100644 index 00000000..4c77b51f --- /dev/null +++ b/Tests/MCPTests/ModernProtocolTests.swift @@ -0,0 +1,687 @@ +import Foundation +import Testing + +@testable import MCP + +@Suite("Modern Protocol 2026-07-28") +struct ModernProtocolTests { + @Test("SEP-2243 custom parameter headers must match schema values") + func customParameterHeadersValidateAgainstBody() throws { + let response = try JSONSerialization.data(withJSONObject: [ + "jsonrpc": "2.0", "id": 1, "result": ["tools": [[ + "name": "lookup", "inputSchema": [ + "properties": ["tenant": ["type": "string", "x-mcp-header": "Tenant"]], + "required": ["tenant"], + ], + ]]], + ] as [String: Any]) + let schemas = try #require(MCPToolHeaderSupport.schemaMap(from: response)) + let body = try JSONSerialization.data(withJSONObject: [ + "jsonrpc": "2.0", "id": 2, "method": "tools/call", + "params": ["name": "lookup", "arguments": ["tenant": "acme"]], + ] as [String: Any]) + let valid = HTTPRequest(method: "POST", headers: ["mCp-PaRaM-tEnAnT": "acme"], body: body) + let mismatch = HTTPRequest(method: "POST", headers: ["Mcp-Param-Tenant": "other"], body: body) + let missing = HTTPRequest(method: "POST", headers: [:], body: body) + #expect(MCPToolHeaderSupport.validateCall(body: body, request: valid, schemas: schemas) == nil) + #expect(MCPToolHeaderSupport.validateCall(body: body, request: mismatch, schemas: schemas) != nil) + #expect(MCPToolHeaderSupport.validateCall(body: body, request: missing, schemas: schemas) != nil) + } + + @Test("Nested custom parameters and Base64 sentinel literals roundtrip safely") + func nestedCustomParameterAndSentinelLiteralRoundtrip() throws { + let literal = "=?base64?literal?=" + let encoded = MCPToolHeaderSupport.encodeHeaderValue(literal) + #expect(encoded != literal) + #expect(MCPToolHeaderSupport.decodeHeaderValue(encoded) == literal) + #expect(MCPToolHeaderSupport.isValidSuffix("Tenant-2")) + #expect(!MCPToolHeaderSupport.isValidSuffix("Tenant\rInjected")) + + let schema = ["properties": ["settings": [ + "type": "object", "required": ["tenant"], + "properties": ["tenant": ["type": "string", "x-mcp-header": "Tenant"]], + ]]] as [String: Any] + let body = try JSONSerialization.data(withJSONObject: [ + "jsonrpc": "2.0", "id": 7, "method": "tools/call", + "params": ["name": "lookup", "arguments": ["settings": ["tenant": literal]]], + ] as [String: Any]) + let request = HTTPRequest(method: "POST", headers: ["Mcp-Param-Tenant": encoded], body: body) + #expect(MCPToolHeaderSupport.validateCall(body: body, request: request, schemas: ["lookup": schema]) == nil) + #expect(MCPToolHeaderSupport.primitiveString(NSNumber(value: 9_007_199_254_740_991)) == "9007199254740991") + #expect(MCPToolHeaderSupport.primitiveString(NSNumber(value: 9_007_199_254_740_992)) == nil) + } + + @Test("Tool schema pages merge per caller without crossing identities") + func toolSchemaRegistryIsPaginatedAndCallerScoped() throws { + func listRequest(id: Int, identity: String, cursor: String? = nil) throws -> HTTPRequest { + var params: [String: Any] = [:] + if let cursor { params["cursor"] = cursor } + let body = try JSONSerialization.data(withJSONObject: [ + "jsonrpc": "2.0", "id": id, "method": "tools/list", "params": params, + ] as [String: Any]) + return HTTPRequest(method: "POST", body: body, clientIdentity: identity) + } + func listResponse(id: Int, tool: String, nextCursor: String? = nil) throws -> Data { + var result: [String: Any] = [ + "tools": [["name": tool, "inputSchema": ["type": "object", "properties": [:]]]], + ] + result["nextCursor"] = nextCursor + return try JSONSerialization.data(withJSONObject: [ + "jsonrpc": "2.0", "id": id, + "result": result, + ] as [String: Any]) + } + var registry = MCPToolSchemaRegistry() + let firstPage = try listRequest(id: 1, identity: "client-a") + registry.record(responseData: try listResponse(id: 1, tool: "first", nextCursor: "next"), for: firstPage) + #expect(!registry.isComplete(for: firstPage)) + let secondPage = try listRequest(id: 2, identity: "client-a", cursor: "next") + registry.record(responseData: try listResponse(id: 2, tool: "second"), for: secondPage) + #expect(registry.isComplete(for: firstPage)) + let otherCaller = try listRequest(id: 3, identity: "client-b") + registry.record(responseData: try listResponse(id: 3, tool: "private"), for: otherCaller) + #expect(Set(registry.schemas(for: firstPage).keys) == ["first", "second"]) + #expect(Set(registry.schemas(for: otherCaller).keys) == ["private"]) + #expect(registry.isComplete(for: otherCaller)) + } + + @Test("Tool schema registry evicts old caller entries at its capacity") + func toolSchemaRegistryIsBounded() throws { + var registry = MCPToolSchemaRegistry() + let response = try JSONSerialization.data(withJSONObject: [ + "jsonrpc": "2.0", "id": 1, + "result": ["tools": [["name": "lookup", "inputSchema": ["type": "object"]]]], + ] as [String: Any]) + var firstRequest: HTTPRequest? + var lastRequest: HTTPRequest? + for index in 0..<257 { + let body = try JSONSerialization.data(withJSONObject: [ + "jsonrpc": "2.0", "id": index, "method": "tools/list", + ] as [String: Any]) + let request = HTTPRequest(method: "POST", body: body, clientIdentity: "client-\(index)") + if index == 0 { firstRequest = request } + if index == 256 { lastRequest = request } + registry.record(responseData: response, for: request) + } + #expect(registry.schemas(for: try #require(firstRequest)).isEmpty) + #expect(!registry.isComplete(for: try #require(firstRequest))) + #expect(registry.schemas(for: try #require(lastRequest))["lookup"] != nil) + #expect(registry.isComplete(for: try #require(lastRequest))) + } + + @Test("Supported versions include modern and legacy") + func supportedVersionsIncludeModernAndLegacy() { + #expect(Version.supported.contains("2026-07-28")) + #expect(Version.supported.contains("2025-11-25")) + #expect(Version.latest == "2026-07-28") + #expect(Version.isModern("2026-07-28")) + #expect(!Version.isModern("2025-11-25")) + #expect(Version.isLegacy("2025-11-25")) + } + + @Test("requireSupported rejects unknown modern probes with -32022") + func requireSupportedRejectsUnknown() throws { + do { + try Version.requireSupported("2099-01-01") + Issue.record("Expected unsupportedProtocolVersion") + } catch let error as MCPError { + #expect(error.code == -32022) + if case .unsupportedProtocolVersion(let requested, let supported) = error { + #expect(requested == "2099-01-01") + #expect(supported.contains("2026-07-28")) + } else { + Issue.record("Wrong error case: \(error)") + } + } + } + + @Test("Legacy negotiate still returns a legacy version") + func legacyNegotiate() { + #expect(Version.negotiate(clientRequestedVersion: "2025-11-25") == "2025-11-25") + #expect(Version.negotiate(clientRequestedVersion: "2026-07-28") == Version.latestLegacy) + #expect(Version.negotiate(clientRequestedVersion: "nope") == Version.latestLegacy) + } + + @Test("ModernRequestMeta reads protocol version from params") + func modernRequestMeta() { + let params: Value = .object([ + "_meta": .object([ + ProtocolMetaKey.protocolVersion: .string("2026-07-28"), + ProtocolMetaKey.clientCapabilities: .object([:]), + ]) + ]) + #expect(ModernRequestMeta.protocolVersion(from: params) == "2026-07-28") + #expect(ModernRequestMeta.isModernRequest(params: params)) + } + + @Test("subscriptions/listen acknowledges its request ID") + func subscriptionsListenAcknowledges() async throws { + let transport = MockTransport() + let server = Server( + name: "TestServer", + version: "1.0", + capabilities: .init(tools: .init(listChanged: true)) + ) + try await server.start(transport: transport) + let request: [String: Any] = [ + "jsonrpc": "2.0", "id": "listen-ack", "method": "subscriptions/listen", + "params": [ + "_meta": [ + ProtocolMetaKey.protocolVersion: "2026-07-28", + ProtocolMetaKey.clientCapabilities: [String: Any](), + ], + "notifications": ["toolsListChanged": true], + ], + ] + await transport.queue(data: try JSONSerialization.data(withJSONObject: request)) + try await Task.sleep(for: .milliseconds(100)) + let sent = await transport.sentMessages + #expect(sent.contains { $0.contains(SubscriptionsAcknowledgedNotification.name) }) + #expect(sent.contains { $0.contains("listen-ack") && $0.contains(ProtocolMetaKey.subscriptionId) }) + let acknowledgment = try #require(sent + .compactMap { try? JSONSerialization.jsonObject(with: Data($0.utf8)) as? [String: Any] } + .first { $0["method"] as? String == SubscriptionsAcknowledgedNotification.name }) + let params = try #require(acknowledgment["params"] as? [String: Any]) + let notifications = try #require(params["notifications"] as? [String: Any]) + #expect(notifications["toolsListChanged"] as? Bool == true) + await server.stop() + } + + @Test("Modern server requests return missing-required-capability for undeclared features") + func modernRequestRejectsUndeclaredSamplingCapability() async throws { + let transport = MockTransport() + let server = Server( + name: "TestServer", + version: "1.0", + configuration: .strict + ) + try await server.start(transport: transport) + let context = Server.HandlerContext( + id: .string("modern-request"), + httpContext: nil, + clientCapabilities: .init() + ) + + do { + try await Server.$currentHandlerContext.withValue(context) { + _ = try await server.requestSampling(messages: [], maxTokens: 8) + } + Issue.record("Expected missingRequiredClientCapability") + } catch let error as MCPError { + #expect(error.code == -32021) + if case .missingRequiredClientCapability(let required) = error { + #expect(required["sampling"] != nil) + } else { + Issue.record("Expected missingRequiredClientCapability, got \(error)") + } + } + await server.stop() + } + + @Test("ensuringModernCompleteResult adds resultType") + func ensuringModernCompleteResult() { + let value: Value = .object(["ok": .bool(true)]) + let enriched = value.ensuringModernCompleteResult( + serverInfo: ["name": .string("test"), "version": .string("1")] + ) + guard case .object(let object) = enriched else { + Issue.record("Expected object") + return + } + #expect(object["resultType"]?.stringValue == "complete") + guard case .object(let meta) = object["_meta"], + case .object(let info) = meta[ProtocolMetaKey.serverInfo] + else { + Issue.record("Expected serverInfo meta") + return + } + #expect(info["name"]?.stringValue == "test") + } + + @Test("modern list and read results include caching hints") + func modernCachingHints() { + let value: Value = .object(["items": .array([])]) + guard case .object(let result) = value.ensuringModernCompleteResult(cacheHints: true) else { + Issue.record("Expected object result") + return + } + #expect(result["cacheScope"]?.stringValue == "private") + #expect(result["ttlMs"]?.intValue == 0) + } + + @Test("missing client capability error includes required capabilities") + func missingCapabilityErrorData() throws { + let error = MCPError.missingRequiredClientCapability(["sampling": .object([:])]) + let encoded = try JSONEncoder().encode(error) + let json = try #require(JSONSerialization.jsonObject(with: encoded) as? [String: Any]) + let data = try #require(json["data"] as? [String: Any]) + let capabilities = try #require(data["requiredCapabilities"] as? [String: Any]) + #expect(json["code"] as? Int == -32021) + #expect(capabilities["sampling"] as? [String: Any] != nil) + } + + @Test("tool results encode input-required request fields") + func toolInputRequiredResultEncoding() throws { + let result = CallTool.Result( + resultType: .inputRequired, + inputRequests: ["name": .object(["method": .string("elicitation/create"), "params": .object([:])])], + requestState: "opaque" + ) + let encoded = try JSONEncoder().encode(result) + let json = try #require(JSONSerialization.jsonObject(with: encoded) as? [String: Any]) + #expect(json["resultType"] as? String == "input_required") + #expect(json["requestState"] as? String == "opaque") + #expect(json["inputRequests"] as? [String: Any] != nil) + } + + @Test("tool input-required result can omit complete-result content") + func toolInputRequiredResultWithoutContent() throws { + let data = Data( + #"{"resultType":"input_required","inputRequests":{"confirm":{"method":"elicitation/create","params":{}}}}"#.utf8 + ) + let result = try JSONDecoder().decode(CallTool.Result.self, from: data) + #expect(result.resultType == .inputRequired) + #expect(result.content.isEmpty) + } + + @Test("complete tool result still requires content") + func completeToolResultRequiresContent() throws { + let data = Data(#"{"resultType":"complete"}"#.utf8) + #expect(throws: DecodingError.self) { + _ = try JSONDecoder().decode(CallTool.Result.self, from: data) + } + } + + @Test("ensuringModernCompleteResult leaves non-object values unchanged") + func ensuringModernCompleteResultNonObject() { + let value: Value = .string("ok") + let enriched = value.ensuringModernCompleteResult( + serverInfo: ["name": .string("test"), "version": .string("1")] + ) + #expect(enriched == value) + } + + @Test("InputRequiredResult requires resultType when decoding") + func inputRequiredResultRequiresResultType() throws { + let data = Data(#"{"requestState":"s1"}"#.utf8) + do { + _ = try JSONDecoder().decode(InputRequiredResult.self, from: data) + Issue.record("Expected decoding to fail without resultType") + } catch is DecodingError { + // expected + } + } + + @Test("Discover result encodes required fields") + func discoverResultEncoding() throws { + let result = Discover.Result( + supportedVersions: ["2026-07-28", "2025-11-25"], + capabilities: .init(tools: .init()), + cacheScope: .private, + ttlMs: 0 + ) + let data = try JSONEncoder().encode(result) + let json = try JSONSerialization.jsonObject(with: data) as? [String: Any] + #expect(json?["resultType"] as? String == "complete") + #expect(json?["cacheScope"] as? String == "private") + #expect(json?["ttlMs"] as? Int == 0) + let versions = json?["supportedVersions"] as? [String] + #expect(versions?.contains("2026-07-28") == true) + } + + @Test("server/discover works without initialize") + func discoverWithoutInitialize() async throws { + let transport = MockTransport() + let server = Server( + name: "TestServer", + version: "1.0.0", + capabilities: .init(tools: .init()), + configuration: .strict + ) + try await server.start(transport: transport) + + let request = Discover.request( + .init( + _meta: Metadata( + additionalFields: [ + ProtocolMetaKey.protocolVersion: .string("2026-07-28"), + ProtocolMetaKey.clientCapabilities: .object([:]), + ] + ) + ) + ) + try await transport.queue(request: request) + try await Task.sleep(for: .milliseconds(200)) + + let messages = await transport.sentMessages + #expect(!messages.isEmpty) + #expect(messages[0].contains("supportedVersions")) + #expect(messages[0].contains("2026-07-28")) + #expect(messages[0].contains("\"resultType\":\"complete\"") || messages[0].contains("resultType")) + + await server.stop() + } + + @Test("Strict modern notification exemption is validated per notification") + func strictModeDoesNotCarryModernExemptionToOtherNotifications() async throws { + actor NotificationCounter { + private(set) var count = 0 + func increment() { count += 1 } + } + + let transport = MockTransport() + let counter = NotificationCounter() + let server = Server( + name: "TestServer", + version: "1.0.0", + configuration: .strict + ) + await server.onNotification(RootsListChangedNotification.self) { _ in + await counter.increment() + } + try await server.start(transport: transport) + + await transport.queue(data: try JSONSerialization.data(withJSONObject: [ + "jsonrpc": "2.0", + "id": "modern-discover", + "method": Discover.name, + "params": [ + "_meta": [ + ProtocolMetaKey.protocolVersion: "2026-07-28", + ProtocolMetaKey.clientCapabilities: [String: Any](), + ], + ], + ] as [String: Any])) + try await Task.sleep(for: .milliseconds(100)) + + await transport.queue(data: try JSONSerialization.data(withJSONObject: [ + "jsonrpc": "2.0", + "method": RootsListChangedNotification.name, + ] as [String: Any])) + try await Task.sleep(for: .milliseconds(100)) + + #expect(await counter.count == 0) + + await transport.queue(data: try JSONSerialization.data(withJSONObject: [ + "jsonrpc": "2.0", + "id": "legacy-initialize", + "method": "initialize", + "params": [ + "protocolVersion": "2025-11-25", + "capabilities": [String: Any](), + "clientInfo": ["name": "legacy", "version": "1"], + ], + ] as [String: Any])) + try await Task.sleep(for: .milliseconds(100)) + + await transport.queue(data: try JSONSerialization.data(withJSONObject: [ + "jsonrpc": "2.0", + "method": RootsListChangedNotification.name, + "params": [ + "_meta": [ + ProtocolMetaKey.protocolVersion: "2099-01-01", + ProtocolMetaKey.clientCapabilities: [String: Any](), + ], + ], + ] as [String: Any])) + try await Task.sleep(for: .milliseconds(100)) + + #expect(await counter.count == 0) + + await transport.queue(data: try JSONSerialization.data(withJSONObject: [ + "jsonrpc": "2.0", + "method": RootsListChangedNotification.name, + "params": [ + "_meta": [ + ProtocolMetaKey.protocolVersion: "2026-07-28", + ProtocolMetaKey.clientCapabilities: [String: Any](), + ], + ], + ] as [String: Any])) + try await Task.sleep(for: .milliseconds(100)) + + #expect(await counter.count == 1) + await server.stop() + } + + @Test("Unsupported protocol version on modern request returns -32022") + func unsupportedVersionOnRequest() async throws { + let transport = MockTransport() + let server = Server( + name: "TestServer", + version: "1.0.0", + configuration: .default + ) + try await server.start(transport: transport) + + let request: [String: Any] = [ + "jsonrpc": "2.0", + "id": 2, + "method": "ping", + "params": [ + "_meta": [ + ProtocolMetaKey.protocolVersion: "2099-01-01", + ProtocolMetaKey.clientCapabilities: [:] as [String: Any], + ] + ], + ] + await transport.queue(data: try JSONSerialization.data(withJSONObject: request)) + try await Task.sleep(for: .milliseconds(200)) + + let messages = await transport.sentMessages + #expect(!messages.isEmpty) + #expect(messages[0].contains("-32022") || messages[0].contains("32022")) + #expect(messages[0].contains("2099-01-01")) + + await server.stop() + } + + @Test("HTTP 400 unsupported version body includes error.data") + func httpErrorBodyIncludesUnsupportedVersionData() throws { + let response = HTTPResponse.error( + statusCode: 400, + .unsupportedProtocolVersion( + requested: "2099-01-01", + supported: ["2026-07-28", "2025-11-25"] + ) + ) + #expect(response.statusCode == 400) + let body = try #require(response.bodyData) + let json = try #require( + JSONSerialization.jsonObject(with: body) as? [String: Any] + ) + let error = try #require(json["error"] as? [String: Any]) + #expect(error["code"] as? Int == -32022) + let data = try #require(error["data"] as? [String: Any]) + #expect(data["requested"] as? String == "2099-01-01") + let supported = try #require(data["supported"] as? [String]) + #expect(supported.contains("2026-07-28")) + } + + @Test("ProtocolVersionValidator rejects header/body disagreement with HTTP 400") + func protocolVersionHeaderBodyDisagreement() { + let validator = ProtocolVersionValidator() + let request = HTTPRequest( + method: "POST", + headers: [HTTPHeaderName.protocolVersion: "2025-11-25"], + path: "/" + ) + let context = HTTPValidationContext( + httpMethod: "POST", + bodyProtocolVersion: "2026-07-28", + supportedProtocolVersions: Version.supported + ) + let response = validator.validate(request, context: context) + #expect(response?.statusCode == 400) + let body = response?.bodyData + let text = body.flatMap { String(data: $0, encoding: .utf8) } ?? "" + #expect(text.contains("disagrees") || text.contains("Invalid Request") || text.contains("-32600")) + #expect(text.contains("2026-07-28")) + } + + @Test("ProtocolVersionValidator rejects modern header without body meta") + func protocolVersionModernHeaderRequiresBodyMeta() throws { + let validator = ProtocolVersionValidator() + let body = try JSONSerialization.data( + withJSONObject: [ + "jsonrpc": "2.0", + "id": 1, + "method": "ping", + "params": [:] as [String: Any], + ] as [String: Any] + ) + let request = HTTPRequest( + method: "POST", + headers: [HTTPHeaderName.protocolVersion: "2026-07-28"], + body: body, + path: "/" + ) + let context = HTTPValidationContext( + httpMethod: "POST", + bodyProtocolVersion: nil, + supportedProtocolVersions: Version.supported + ) + let response = validator.validate(request, context: context) + #expect(response?.statusCode == 400) + let text = response?.bodyData.flatMap { String(data: $0, encoding: .utf8) } ?? "" + #expect(text.contains("params._meta") || text.contains("protocolVersion")) + } + + @Test("completeSubscriptionsListen cancels parked listen and is idempotent") + func completeSubscriptionsListenCancelsParked() async throws { + let transport = MockTransport() + let server = Server( + name: "TestServer", + version: "1.0.0", + configuration: .strict + ) + try await server.start(transport: transport) + + let listenID: ID = 42 + let request = SubscriptionsListen.request( + id: listenID, + .init( + _meta: Metadata( + additionalFields: [ + ProtocolMetaKey.protocolVersion: .string("2026-07-28"), + ProtocolMetaKey.clientCapabilities: .object([:]), + ] + ), + notifications: .init() + ) + ) + try await transport.queue(request: request) + try await Task.sleep(for: .milliseconds(100)) + + try await server.completeSubscriptionsListen(id: listenID) + try await Task.sleep(for: .milliseconds(100)) + + let messages = await transport.sentMessages + #expect(messages.contains { $0.contains("\"resultType\":\"complete\"") || $0.contains("resultType") }) + + // Unknown / already completed id must not emit another response + let before = await transport.sentMessages.count + try await server.completeSubscriptionsListen(id: listenID) + let after = await transport.sentMessages.count + #expect(after == before) + + await server.stop() + } +} + + + @Test("closeRequestStream finishes parked listen SSE") + func closeRequestStreamFinishesListenSSE() async throws { + let transport = StatefulHTTPServerTransport( + validationPipeline: StandardValidationPipeline(validators: []) + ) + try await transport.connect() + + let listenBody = try JSONSerialization.data( + withJSONObject: [ + "jsonrpc": "2.0", + "id": "listen-1", + "method": "subscriptions/listen", + "params": [ + "_meta": [ + ProtocolMetaKey.protocolVersion: "2026-07-28", + ProtocolMetaKey.clientCapabilities: [:] as [String: Any], + ], + "notifications": [ + "toolsListChanged": true, + "resourcesListChanged": true, + "promptsListChanged": true, + "resourceSubscriptions": [] as [String], + ], + ], + ] as [String: Any] + ) + let request = HTTPRequest( + method: "POST", + headers: [ + HTTPHeaderName.contentType: ContentType.json, + HTTPHeaderName.accept: "\(ContentType.json), \(ContentType.sse)", + HTTPHeaderName.protocolVersion: "2026-07-28", + ], + body: listenBody, + path: "/" + ) + let response = await transport.handleRequest(request) + guard case .stream(let stream, _) = response else { + Issue.record("Expected SSE stream for listen, got \(response)") + return + } + + let finished = Task { + do { + for try await _ in stream {} + return true + } catch { + return true + } + } + await transport.closeRequestStream(for: .string("listen-1")) + let didFinish = await finished.value + #expect(didFinish) + + await transport.disconnect() + } + + @Test("Listen notification filter matches opted-in types only") + func listenNotificationFilter() { + let filter = SubscriptionsListen.NotificationFilter( + toolsListChanged: true, + resourcesListChanged: false, + promptsListChanged: true, + resourceSubscriptions: ["file://a"] + ) + let tools = Data(#"{"method":"notifications/tools/list_changed"}"#.utf8) + let prompts = Data(#"{"method":"notifications/prompts/list_changed"}"#.utf8) + let resources = Data(#"{"method":"notifications/resources/list_changed"}"#.utf8) + let progress = Data( + #"{"method":"notifications/progress","params":{"progressToken":1,"progress":0.5}}"# + .utf8) + let updatedMatch = Data( + #"{"method":"notifications/resources/updated","params":{"uri":"file://a"}}"#.utf8) + let updatedMiss = Data( + #"{"method":"notifications/resources/updated","params":{"uri":"file://b"}}"#.utf8) + + #expect( + StatefulHTTPServerTransport.notification( + "notifications/tools/list_changed", matches: filter, body: tools)) + #expect( + StatefulHTTPServerTransport.notification( + "notifications/prompts/list_changed", matches: filter, body: prompts)) + #expect( + !StatefulHTTPServerTransport.notification( + "notifications/resources/list_changed", matches: filter, body: resources)) + #expect( + !StatefulHTTPServerTransport.notification( + "notifications/progress", matches: filter, body: progress)) + #expect( + StatefulHTTPServerTransport.notification( + "notifications/resources/updated", matches: filter, body: updatedMatch)) + #expect( + !StatefulHTTPServerTransport.notification( + "notifications/resources/updated", matches: filter, body: updatedMiss)) + } diff --git a/Tests/MCPTests/OAuthAuthorizationCodeFlowTests.swift b/Tests/MCPTests/OAuthAuthorizationCodeFlowTests.swift index 85c610e4..be4bb6c3 100644 --- a/Tests/MCPTests/OAuthAuthorizationCodeFlowTests.swift +++ b/Tests/MCPTests/OAuthAuthorizationCodeFlowTests.swift @@ -112,6 +112,53 @@ struct OAuthAuthorizationCodeFlowTests { #expect(code == "auth-code-123") } + @Test("extractCode rejects an issuer mismatch even when the server did not advertise iss") + func testExtractCodeRejectsUnexpectedIssuer() { + let redirectURL = URL( + string: "https://app.example.com/callback?code=auth-code-123&state=my-state&iss=https%3A%2F%2Fevil.example.com" + )! + #expect(throws: OAuthAuthorizationError.self) { + try flow.extractCode( + from: redirectURL, + expectedRedirectURI: redirectURI, + expectedState: "my-state", + expectedIssuer: "https://auth.example.com", + issuerRequired: false + ) + } + } + + @Test("extractCode requires iss when authorization server metadata advertises support") + func testExtractCodeRequiresAdvertisedIssuer() { + let redirectURL = URL( + string: "https://app.example.com/callback?code=auth-code-123&state=my-state" + )! + #expect(throws: OAuthAuthorizationError.self) { + try flow.extractCode( + from: redirectURL, + expectedRedirectURI: redirectURI, + expectedState: "my-state", + expectedIssuer: "https://auth.example.com", + issuerRequired: true + ) + } + } + + @Test("extractCode accepts an exact matching issuer") + func testExtractCodeAcceptsMatchingIssuer() throws { + let redirectURL = URL( + string: "https://app.example.com/callback?code=auth-code-123&state=my-state&iss=https%3A%2F%2Fauth.example.com" + )! + let code = try flow.extractCode( + from: redirectURL, + expectedRedirectURI: redirectURI, + expectedState: "my-state", + expectedIssuer: "https://auth.example.com", + issuerRequired: true + ) + #expect(code == "auth-code-123") + } + @Test("extractCode throws state mismatch") func testExtractCodeThrowsStateMismatch() { let redirectURL = URL(string: diff --git a/Tests/MCPTests/OAuthAuthorizerTests.swift b/Tests/MCPTests/OAuthAuthorizerTests.swift index 0384307b..c52759e5 100644 --- a/Tests/MCPTests/OAuthAuthorizerTests.swift +++ b/Tests/MCPTests/OAuthAuthorizerTests.swift @@ -59,7 +59,8 @@ final class MockDiscoveryClient: OAuthDiscoveryFetching, @unchecked Sendable { registrationEndpoint: nil, codeChallengeMethodsSupported: ["S256"], tokenEndpointAuthMethodsSupported: nil, - clientIDMetadataDocumentSupported: nil + clientIDMetadataDocumentSupported: nil, + authorizationResponseIssParameterSupported: nil ) ) } @@ -141,6 +142,8 @@ final class MockAuthCodeFlow: OAuthAuthorizationCodeFlowing, @unchecked Sendable authorizationURL: URL, redirectURI: URL, state: String, + expectedIssuer: String?, + issuerRequired: Bool, delegate: (any OAuthAuthorizationDelegate)?, session: URLSession ) async throws -> String { @@ -149,6 +152,45 @@ final class MockAuthCodeFlow: OAuthAuthorizationCodeFlowing, @unchecked Sendable } } +private actor OAuthTestBarrier { + private var reached = false + private var released = false + private var reachedWaiters: [CheckedContinuation] = [] + private var releaseWaiters: [CheckedContinuation] = [] + + func pause() async { + reached = true + reachedWaiters.forEach { $0.resume() } + reachedWaiters.removeAll() + guard !released else { return } + await withCheckedContinuation { releaseWaiters.append($0) } + } + + func waitUntilReached() async { + guard !reached else { return } + await withCheckedContinuation { reachedWaiters.append($0) } + } + + func release() { + released = true + releaseWaiters.forEach { $0.resume() } + releaseWaiters.removeAll() + } +} + +private actor OAuthTestCounter { + private var value = 0 + + func increment() -> Int { + value += 1 + return value + } + + func current() -> Int { + value + } +} + // MARK: - OAuthAuthorizer Invocation Tests @Suite("OAuthAuthorizer dependency invocations") @@ -426,4 +468,85 @@ struct OAuthAuthorizerTests { #expect(registrar.registerCallCount == 1) #expect(tokenStorage.load()?.clientID == assignedClientID) } + + @Test("Canceled challenge waiting for authorization gate has no auth side effects") + func testCanceledQueuedChallengeDoesNotRunAfterGateRelease() async throws { + let tokenStorage = InMemoryTokenStorage() + let providerBarrier = OAuthTestBarrier() + let providerCallCount = OAuthTestCounter() + let config = OAuthConfiguration( + authentication: .clientSecretBasic(clientID: "client", clientSecret: "secret"), + accessTokenProvider: { _, _ in + let call = await providerCallCount.increment() + if call == 1 { + await providerBarrier.pause() + } + return "provider-token-\(call)" + } + ) + let authorizer = OAuthAuthorizer( + configuration: config, + tokenStorage: tokenStorage, + urlValidator: MockURLValidator(), + discoveryClient: MockDiscoveryClient(), + tokenEndpointClient: MockTokenClient(), + clientRegistrar: MockClientRegistrar(), + authCodeFlow: MockAuthCodeFlow() + ) + + let activeChallenge = Task { + try await authorizer.handleChallenge( + statusCode: 401, + headers: headers401, + endpoint: endpoint, + operationKey: "first", + session: .shared + ) + } + await providerBarrier.waitUntilReached() + + let canceledChallenge = Task { + try await authorizer.handleChallenge( + statusCode: 401, + headers: headers401, + endpoint: endpoint, + operationKey: "second", + session: .shared + ) + } + await Task.yield() + canceledChallenge.cancel() + await providerBarrier.release() + + #expect(try await activeChallenge.value == true) + await #expect(throws: CancellationError.self) { + try await canceledChallenge.value + } + + let followUpHandled = await withTaskGroup(of: Bool.self) { group in + group.addTask { + (try? await authorizer.handleChallenge( + statusCode: 401, + headers: headers401, + endpoint: endpoint, + operationKey: "follow-up", + session: .shared + )) == true + } + group.addTask { + do { + try await Task.sleep(for: .seconds(1)) + return false + } catch { + return false + } + } + let result = await group.next() ?? false + group.cancelAll() + return result + } + #expect(followUpHandled) + #expect(await providerCallCount.current() == 2) + #expect(tokenStorage.load()?.value == "provider-token-2") + } } diff --git a/Tests/MCPTests/OAuthClientRegistrarTests.swift b/Tests/MCPTests/OAuthClientRegistrarTests.swift index 7bdec5dd..ab15b1e8 100644 --- a/Tests/MCPTests/OAuthClientRegistrarTests.swift +++ b/Tests/MCPTests/OAuthClientRegistrarTests.swift @@ -23,7 +23,8 @@ import Testing registrationEndpoint: registrationEndpoint, codeChallengeMethodsSupported: ["S256"], tokenEndpointAuthMethodsSupported: nil, - clientIDMetadataDocumentSupported: nil + clientIDMetadataDocumentSupported: nil, + authorizationResponseIssParameterSupported: nil ) } @@ -74,7 +75,8 @@ import Testing let asMetadata = OAuthAuthorizationServerMetadata( issuer: nil, authorizationEndpoint: nil, tokenEndpoint: nil, registrationEndpoint: nil, codeChallengeMethodsSupported: nil, - tokenEndpointAuthMethodsSupported: nil, clientIDMetadataDocumentSupported: false + tokenEndpointAuthMethodsSupported: nil, clientIDMetadataDocumentSupported: false, + authorizationResponseIssParameterSupported: nil ) let (session, _) = makeIsolatedSession() @@ -112,6 +114,49 @@ import Testing #expect(resultValue.updatedAuthentication == expected) } + @Test("Dynamic registration identifies loopback redirects as native applications") + func testRegisterIncludesNativeApplicationType() async throws { + let body = try successRegistrationBody() + let (session, key) = makeIsolatedSession() + await IsolatedMockURLProtocol.setHandler(key: key) { request in + let requestBody: Data + if let data = request.httpBody { + requestBody = data + } else if let stream = request.httpBodyStream { + stream.open() + defer { stream.close() } + let buffer = UnsafeMutablePointer.allocate(capacity: 4096) + defer { buffer.deallocate() } + var data = Data() + while stream.hasBytesAvailable { + data.append(buffer, count: stream.read(buffer, maxLength: 4096)) + } + requestBody = data + } else { + requestBody = Data() + } + let payload = try #require( + try? JSONSerialization.jsonObject(with: requestBody) as? [String: Any] + ) + #expect(payload["application_type"] as? String == "native") + let response = HTTPURLResponse( + url: self.registrationEndpoint, statusCode: 201, + httpVersion: nil, headerFields: nil)! + return (response, body) + } + + let config = OAuthConfiguration( + grantType: .authorizationCode, + authentication: .none(clientID: ""), + authorizationRedirectURI: URL(string: "http://127.0.0.1:54321/callback")! + ) + _ = try await registrar.register( + configuration: config, + asMetadata: makeASMetadata(registrationEndpoint: registrationEndpoint), + session: session + ) + } + @Test("Throws on 4xx registration response") func testRegisterThrowsOn4xx() async throws { let errorBody = try JSONSerialization.data( diff --git a/Tests/MCPTests/OAuthDiscoveryClientTests.swift b/Tests/MCPTests/OAuthDiscoveryClientTests.swift index b962bb6f..35496a15 100644 --- a/Tests/MCPTests/OAuthDiscoveryClientTests.swift +++ b/Tests/MCPTests/OAuthDiscoveryClientTests.swift @@ -183,13 +183,14 @@ import Testing registrationEndpoint: nil, codeChallengeMethodsSupported: ["S256"], tokenEndpointAuthMethodsSupported: nil, - clientIDMetadataDocumentSupported: nil) + clientIDMetadataDocumentSupported: nil, + authorizationResponseIssParameterSupported: nil) #expect(server == expectedServer) #expect(metadata == expectedMetadata) } - @Test("Uses metadata issuer as server identity when it differs from candidate URL") - func testFetchAuthorizationServerMetadataUsesMetadataIssuer() async throws { + @Test("Rejects authorization metadata whose issuer differs from its discovery URL") + func testFetchAuthorizationServerMetadataRejectsIssuerMismatch() async throws { let metadataIssuer = "https://other.example.com" let body = try makeASMetadataBody(issuer: metadataIssuer) let (session, key) = makeIsolatedSession() @@ -200,11 +201,34 @@ import Testing return (response, body) } - let (server, _) = try await makeClient().fetchAuthorizationServerMetadata( - candidates: [URL(string: "https://auth.example.com")!], - session: session - ) - #expect(server == URL(string: metadataIssuer)!) + await #expect(throws: OAuthAuthorizationError.self) { + try await makeClient().fetchAuthorizationServerMetadata( + candidates: [URL(string: "https://auth.example.com")!], + session: session + ) + } + } + + @Test("Rejects authorization metadata with no issuer") + func testFetchAuthorizationServerMetadataRejectsMissingIssuer() async throws { + let body = try JSONSerialization.data(withJSONObject: [ + "token_endpoint": "https://auth.example.com/token", + "code_challenge_methods_supported": ["S256"], + ]) + let (session, key) = makeIsolatedSession() + await IsolatedMockURLProtocol.setHandler(key: key) { _ in + let response = HTTPURLResponse( + url: URL(string: "https://auth.example.com")!, + statusCode: 200, httpVersion: nil, headerFields: nil)! + return (response, body) + } + + await #expect(throws: OAuthAuthorizationError.self) { + try await makeClient().fetchAuthorizationServerMetadata( + candidates: [URL(string: "https://auth.example.com")!], + session: session + ) + } } @Test("Skips private IP candidates without making HTTP calls") diff --git a/Tests/MCPTests/RequestTests.swift b/Tests/MCPTests/RequestTests.swift index 86747e29..c61e150a 100644 --- a/Tests/MCPTests/RequestTests.swift +++ b/Tests/MCPTests/RequestTests.swift @@ -202,7 +202,7 @@ struct RequestTests { """ let decoded = try decoder.decode( Request.self, from: emptyParams.data(using: .utf8)!) - #expect(decoded.params.protocolVersion == Version.latest) + #expect(decoded.params.protocolVersion == Version.latestLegacy) #expect(decoded.params.clientInfo.name == "unknown") } diff --git a/Tests/MCPTests/ResponseTests.swift b/Tests/MCPTests/ResponseTests.swift index cb918521..a1ef1a43 100644 --- a/Tests/MCPTests/ResponseTests.swift +++ b/Tests/MCPTests/ResponseTests.swift @@ -2,6 +2,7 @@ import Testing import class Foundation.JSONDecoder import class Foundation.JSONEncoder +import class Foundation.JSONSerialization @testable import MCP @@ -84,6 +85,16 @@ struct ResponseTests { } } + @Test("Resource not found error uses modern invalid params shape") + func testResourceNotFoundErrorEncoding() throws { + let uri = "test://missing-resource" + let data = try JSONEncoder().encode(MCPError.resourceNotFound(uri: uri)) + let object = try #require(JSONSerialization.jsonObject(with: data) as? [String: Any]) + #expect(object["code"] as? Int == -32602) + #expect((object["data"] as? [String: Any])?["uri"] as? String == uri) + #expect(try JSONDecoder().decode(MCPError.self, from: data) == .resourceNotFound(uri: uri)) + } + @Test("Empty result response encoding") func testEmptyResultResponseEncoding() throws { let response = EmptyMethod.response(id: "test-id") diff --git a/Tests/MCPTests/RoundtripTests.swift b/Tests/MCPTests/RoundtripTests.swift index b91825df..2f3a6ad0 100644 --- a/Tests/MCPTests/RoundtripTests.swift +++ b/Tests/MCPTests/RoundtripTests.swift @@ -107,7 +107,7 @@ struct RoundtripTests { #expect(result.serverInfo.version == "1.0.0") #expect(result.capabilities.prompts != nil) #expect(result.capabilities.tools != nil) - #expect(result.protocolVersion == Version.latest) + #expect(result.protocolVersion == Version.latestLegacy) } try await withThrowingTaskGroup(of: Void.self) { group in group.addTask { diff --git a/Tests/MCPTests/ServerTests.swift b/Tests/MCPTests/ServerTests.swift index 4713b278..81e9e8e2 100644 --- a/Tests/MCPTests/ServerTests.swift +++ b/Tests/MCPTests/ServerTests.swift @@ -25,7 +25,7 @@ struct ServerTests { try await transport.queue( request: Initialize.request( .init( - protocolVersion: Version.latest, + protocolVersion: Version.latestLegacy, capabilities: .init(), clientInfo: .init(name: "TestClient", version: "1.0") ) @@ -132,7 +132,7 @@ struct ServerTests { try await transport.queue( request: Initialize.request( .init( - protocolVersion: Version.latest, + protocolVersion: Version.latestLegacy, capabilities: .init(), clientInfo: .init(name: "TestClient", version: "1.0") ) @@ -172,7 +172,7 @@ struct ServerTests { try await transport.queue( request: Initialize.request( .init( - protocolVersion: Version.latest, + protocolVersion: Version.latestLegacy, capabilities: .init(), clientInfo: .init(name: "BlockedClient", version: "1.0") ) @@ -223,7 +223,7 @@ struct ServerTests { // Initialize the server first let initRequest = Initialize.request( .init( - protocolVersion: Version.latest, + protocolVersion: Version.latestLegacy, capabilities: .init(), clientInfo: .init(name: "TestClient", version: "1.0") ) diff --git a/Tests/MCPTests/VersioningTests.swift b/Tests/MCPTests/VersioningTests.swift index 2071f904..5614c885 100644 --- a/Tests/MCPTests/VersioningTests.swift +++ b/Tests/MCPTests/VersioningTests.swift @@ -6,9 +6,14 @@ import Testing struct VersioningTests { @Test("Client requests latest supported version") func testClientRequestsLatestSupportedVersion() { + // Legacy negotiate is for initialize; the latest modern version falls + // back to the latest legacy session version. let clientVersion = Version.latest let negotiatedVersion = Version.negotiate(clientRequestedVersion: clientVersion) - #expect(negotiatedVersion == Version.latest) + #expect(negotiatedVersion == Version.latestLegacy) + + let legacyLatest = Version.latestLegacy + #expect(Version.negotiate(clientRequestedVersion: legacyLatest) == legacyLatest) } @Test("Client requests older supported version") @@ -22,35 +27,46 @@ struct VersioningTests { func testClientRequestsUnsupportedVersion() { let clientVersion = "2023-01-01" // An unsupported version let negotiatedVersion = Version.negotiate(clientRequestedVersion: clientVersion) - #expect(negotiatedVersion == Version.latest) + // Legacy initialize path falls back to the latest *legacy* version. + #expect(negotiatedVersion == Version.latestLegacy) } @Test("Client requests empty version string") func testClientRequestsEmptyVersionString() { let clientVersion = "" let negotiatedVersion = Version.negotiate(clientRequestedVersion: clientVersion) - #expect(negotiatedVersion == Version.latest) + #expect(negotiatedVersion == Version.latestLegacy) + } + + @Test("Future dated protocol revisions stay in the modern profile") + func testFutureVersionIsModernEvenWhenUnsupported() { + #expect(Version.isModern("2099-01-01")) + #expect(!Version.supported.contains("2099-01-01")) + #expect(!Version.isModern("2025-11-25")) + #expect(!Version.isModern("not-a-version")) } @Test("Client requests garbage version string") func testClientRequestsGarbageVersionString() { let clientVersion = "not-a-version" let negotiatedVersion = Version.negotiate(clientRequestedVersion: clientVersion) - #expect(negotiatedVersion == Version.latest) + #expect(negotiatedVersion == Version.latestLegacy) } @Test("Server's supported versions correctly defined") func testServerSupportedVersions() { + #expect(Version.supported.contains("2026-07-28")) #expect(Version.supported.contains("2025-11-25")) #expect(Version.supported.contains("2025-06-18")) #expect(Version.supported.contains("2025-03-26")) #expect(Version.supported.contains("2024-11-05")) - #expect(Version.supported.count == 4) + #expect(Version.supported.count == 5) } @Test("Server's latest version is correct") func testServerLatestVersion() { - #expect(Version.latest == "2025-11-25") + #expect(Version.latest == "2026-07-28") + #expect(Version.latestLegacy == "2025-11-25") } @Test("Client requests new 2025-11-25 version")