Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 3 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
30 changes: 27 additions & 3 deletions Sources/MCP/Base/Authorization/OAuthAuthorizationCodeFlow.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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 {
Expand All @@ -129,7 +133,9 @@ public struct OAuthAuthorizationCodeFlow: Sendable {
return try extractCode(
from: redirectURL,
expectedRedirectURI: redirectURI,
expectedState: state
expectedState: state,
expectedIssuer: expectedIssuer,
issuerRequired: issuerRequired
)
}

Expand Down Expand Up @@ -168,7 +174,9 @@ public struct OAuthAuthorizationCodeFlow: Sendable {
return try extractCode(
from: redirectURL,
expectedRedirectURI: redirectURI,
expectedState: state
expectedState: state,
expectedIssuer: expectedIssuer,
issuerRequired: issuerRequired
)
}

Expand All @@ -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),
Expand Down Expand Up @@ -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 ?? "<unknown>",
actual: responseIssuer
)
}
} else if issuerRequired {
throw OAuthAuthorizationError.authorizationResponseMissingIssuer
}

guard
let code = redirectComponents.queryItems?.first(where: {
$0.name == OAuthParameterName.code
Expand Down
103 changes: 103 additions & 0 deletions Sources/MCP/Base/Authorization/OAuthAuthorizer.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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 {
Expand All @@ -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(
Expand Down Expand Up @@ -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(
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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
}
Expand Down Expand Up @@ -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
)
Expand Down Expand Up @@ -871,3 +924,53 @@ public final class OAuthAuthorizer: HTTPClientAuthorizer, @unchecked Sendable {
return normalized.isEmpty ? "<unknown>" : 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<Void, Error>
}
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<Void, Error>) 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()
}
}
3 changes: 3 additions & 0 deletions Sources/MCP/Base/Authorization/OAuthClientRegistrar.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
1 change: 1 addition & 0 deletions Sources/MCP/Base/Authorization/OAuthConstants.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
33 changes: 15 additions & 18 deletions Sources/MCP/Base/Authorization/OAuthDiscoveryClient.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down Expand Up @@ -113,27 +110,27 @@ 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
}
}
}

if let firstValid {
return firstValid
if let firstIssuerMismatch {
throw OAuthAuthorizationError.authorizationServerIssuerMismatch(
expected: firstIssuerMismatch.expected,
actual: firstIssuerMismatch.actual
)
}
throw OAuthAuthorizationError.authorizationServerMetadataDiscoveryFailed
}
Expand Down
3 changes: 3 additions & 0 deletions Sources/MCP/Base/Authorization/OAuthErrors.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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).
Expand Down Expand Up @@ -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"
Expand Down
2 changes: 2 additions & 0 deletions Sources/MCP/Base/Authorization/OAuthModels.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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"
}
}

Expand Down
Loading