Compare commits
13 changed files with 45 additions and 489 deletions
30
README.md
30
README.md
|
|
@ -41,7 +41,6 @@ of the MCP specification.
|
||||||
- [Logging](#logging-1)
|
- [Logging](#logging-1)
|
||||||
- [Progress Tracking](#progress-tracking-1)
|
- [Progress Tracking](#progress-tracking-1)
|
||||||
- [Initialize Hook](#initialize-hook)
|
- [Initialize Hook](#initialize-hook)
|
||||||
- [HTTP Request Context in Handlers](#http-request-context-in-handlers)
|
|
||||||
- [Graceful Shutdown](#graceful-shutdown)
|
- [Graceful Shutdown](#graceful-shutdown)
|
||||||
- [Transports](#transports)
|
- [Transports](#transports)
|
||||||
- [Authentication](#authentication)
|
- [Authentication](#authentication)
|
||||||
|
|
@ -1185,35 +1184,6 @@ try await server.start(transport: transport) { clientInfo, clientCapabilities in
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
### HTTP Request Context in Handlers
|
|
||||||
|
|
||||||
When a server is connected over `StatefulHTTPServerTransport` or `StatelessHTTPServerTransport`,
|
|
||||||
method handlers can observe the originating HTTP request (headers, body, path, method) via
|
|
||||||
`Server.currentHandlerContext` — a task-local set automatically before each handler runs:
|
|
||||||
|
|
||||||
```swift
|
|
||||||
await server.withMethodHandler(CallTool.self) { params in
|
|
||||||
let httpRequest = Server.currentHandlerContext?.httpContext
|
|
||||||
let authHeader = httpRequest?.headers["Authorization"]
|
|
||||||
// …use the header to scope the response, audit, etc.
|
|
||||||
return .init(content: [.text("ok")], isError: false)
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
`httpContext` is `nil` for transports that don't carry HTTP context (e.g. `StdioTransport`,
|
|
||||||
`InMemoryTransport`) and for handlers reached off the dispatch path.
|
|
||||||
|
|
||||||
Task-locals are not inherited by `Task.detached`. If you spawn a detached task from a handler,
|
|
||||||
capture the context up front:
|
|
||||||
|
|
||||||
```swift
|
|
||||||
let ctx = Server.currentHandlerContext
|
|
||||||
Task.detached { await doWork(with: ctx?.httpContext) }
|
|
||||||
```
|
|
||||||
|
|
||||||
Custom HTTP transports can opt in by conforming to `HTTPContextProviding` and returning the
|
|
||||||
`HTTPRequest` for a given JSON-RPC id while it is in flight.
|
|
||||||
|
|
||||||
### Graceful Shutdown
|
### Graceful Shutdown
|
||||||
|
|
||||||
We recommend using
|
We recommend using
|
||||||
|
|
|
||||||
|
|
@ -217,6 +217,7 @@ public final class OAuthAuthorizer: HTTPClientAuthorizer, @unchecked Sendable {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
if accessToken.isExpired() {
|
if accessToken.isExpired() {
|
||||||
|
tokenStorage.clear()
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
return "\(OAuthTokenType.bearer) \(accessToken.value)"
|
return "\(OAuthTokenType.bearer) \(accessToken.value)"
|
||||||
|
|
@ -366,6 +367,7 @@ public final class OAuthAuthorizer: HTTPClientAuthorizer, @unchecked Sendable {
|
||||||
public func prepareAuthorization(for endpoint: URL, session: URLSession) async throws {
|
public func prepareAuthorization(for endpoint: URL, session: URLSession) async throws {
|
||||||
guard configuration.proactiveRefreshWindowSeconds > 0 else { return }
|
guard configuration.proactiveRefreshWindowSeconds > 0 else { return }
|
||||||
guard let token = tokenStorage.load() else { return }
|
guard let token = tokenStorage.load() else { return }
|
||||||
|
guard !token.isExpired() else { return }
|
||||||
guard token.isExpired(skewSeconds: configuration.proactiveRefreshWindowSeconds) else {
|
guard token.isExpired(skewSeconds: configuration.proactiveRefreshWindowSeconds) else {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
@ -439,10 +441,8 @@ public final class OAuthAuthorizer: HTTPClientAuthorizer, @unchecked Sendable {
|
||||||
candidates.append(fallback)
|
candidates.append(fallback)
|
||||||
}
|
}
|
||||||
|
|
||||||
let fallbackIssuer = try? discoveryClient.metadataDiscovery
|
|
||||||
.authorizationServerFallbackIssuer(from: endpoint)
|
|
||||||
let metadata = try await discoveryClient.fetchProtectedResourceMetadata(
|
let metadata = try await discoveryClient.fetchProtectedResourceMetadata(
|
||||||
candidates: candidates, fallbackIssuer: fallbackIssuer, session: session)
|
candidates: candidates, session: session)
|
||||||
try validateProtectedResource(metadata: metadata, endpoint: endpoint)
|
try validateProtectedResource(metadata: metadata, endpoint: endpoint)
|
||||||
|
|
||||||
self.protectedResourceMetadata = metadata
|
self.protectedResourceMetadata = metadata
|
||||||
|
|
@ -716,17 +716,10 @@ public final class OAuthAuthorizer: HTTPClientAuthorizer, @unchecked Sendable {
|
||||||
expiresAt: expiresAt,
|
expiresAt: expiresAt,
|
||||||
scopes: scopeSet,
|
scopes: scopeSet,
|
||||||
authorizationServer: selectedAuthorizationServer,
|
authorizationServer: selectedAuthorizationServer,
|
||||||
refreshToken: decoded.refreshToken,
|
refreshToken: decoded.refreshToken
|
||||||
clientID: nonEmptyClientID()
|
|
||||||
))
|
))
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Returns the configured `client_id` or `nil` if the authorizer has not yet been assigned one.
|
|
||||||
private func nonEmptyClientID() -> String? {
|
|
||||||
let id = configuration.authentication.clientID
|
|
||||||
return id.isEmpty ? nil : id
|
|
||||||
}
|
|
||||||
|
|
||||||
private func resolveTokenEndpoint(
|
private func resolveTokenEndpoint(
|
||||||
asMetadata: OAuthAuthorizationServerMetadata
|
asMetadata: OAuthAuthorizationServerMetadata
|
||||||
) throws -> URL {
|
) throws -> URL {
|
||||||
|
|
@ -830,8 +823,7 @@ public final class OAuthAuthorizer: HTTPClientAuthorizer, @unchecked Sendable {
|
||||||
expiresAt: nil,
|
expiresAt: nil,
|
||||||
scopes: requestedScopes ?? [],
|
scopes: requestedScopes ?? [],
|
||||||
authorizationServer: authorizationServer,
|
authorizationServer: authorizationServer,
|
||||||
refreshToken: nil,
|
refreshToken: nil
|
||||||
clientID: nonEmptyClientID()
|
|
||||||
))
|
))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -79,7 +79,7 @@ struct OAuthClientRegistrar: Sendable {
|
||||||
let responseTypes: [String]
|
let responseTypes: [String]
|
||||||
switch configuration.grantType {
|
switch configuration.grantType {
|
||||||
case .authorizationCode:
|
case .authorizationCode:
|
||||||
grantTypes = [OAuthGrantTypeValue.authorizationCode, OAuthGrantTypeValue.refreshToken]
|
grantTypes = [OAuthGrantTypeValue.authorizationCode]
|
||||||
responseTypes = [OAuthParameterName.code]
|
responseTypes = [OAuthParameterName.code]
|
||||||
case .clientCredentials:
|
case .clientCredentials:
|
||||||
grantTypes = [OAuthGrantTypeValue.clientCredentials]
|
grantTypes = [OAuthGrantTypeValue.clientCredentials]
|
||||||
|
|
|
||||||
|
|
@ -7,7 +7,7 @@ import Foundation
|
||||||
/// Internal protocol for fetching OAuth discovery metadata.
|
/// Internal protocol for fetching OAuth discovery metadata.
|
||||||
protocol OAuthDiscoveryFetching: Sendable {
|
protocol OAuthDiscoveryFetching: Sendable {
|
||||||
var metadataDiscovery: any OAuthMetadataDiscovering { get }
|
var metadataDiscovery: any OAuthMetadataDiscovering { get }
|
||||||
func fetchProtectedResourceMetadata(candidates: [URL], fallbackIssuer: URL?, session: URLSession) async throws -> OAuthProtectedResourceMetadata
|
func fetchProtectedResourceMetadata(candidates: [URL], session: URLSession) async throws -> OAuthProtectedResourceMetadata
|
||||||
func fetchAuthorizationServerMetadata(candidates: [URL], session: URLSession) async throws -> (server: URL, metadata: OAuthAuthorizationServerMetadata)
|
func fetchAuthorizationServerMetadata(candidates: [URL], session: URLSession) async throws -> (server: URL, metadata: OAuthAuthorizationServerMetadata)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -29,13 +29,8 @@ struct OAuthDiscoveryClient: Sendable {
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Fetches Protected Resource Metadata from the first candidate that returns a valid response.
|
/// Fetches Protected Resource Metadata from the first candidate that returns a valid response.
|
||||||
///
|
|
||||||
/// If all candidates fail and `fallbackIssuer` is provided, returns synthetic metadata
|
|
||||||
/// using that issuer as the authorization server — for servers that do not expose a
|
|
||||||
/// PRM document at any well-known path.
|
|
||||||
func fetchProtectedResourceMetadata(
|
func fetchProtectedResourceMetadata(
|
||||||
candidates: [URL],
|
candidates: [URL],
|
||||||
fallbackIssuer: URL?,
|
|
||||||
session: URLSession
|
session: URLSession
|
||||||
) async throws -> OAuthProtectedResourceMetadata {
|
) async throws -> OAuthProtectedResourceMetadata {
|
||||||
let decoder = JSONDecoder()
|
let decoder = JSONDecoder()
|
||||||
|
|
@ -61,28 +56,15 @@ struct OAuthDiscoveryClient: Sendable {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if let fallbackIssuer {
|
|
||||||
return OAuthProtectedResourceMetadata(
|
|
||||||
resource: nil,
|
|
||||||
authorizationServers: [fallbackIssuer],
|
|
||||||
scopesSupported: nil
|
|
||||||
)
|
|
||||||
}
|
|
||||||
throw OAuthAuthorizationError.metadataDiscoveryFailed
|
throw OAuthAuthorizationError.metadataDiscoveryFailed
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Fetches Authorization Server Metadata from candidates, preferring a response whose
|
/// Fetches Authorization Server Metadata from the first candidate that returns a valid response.
|
||||||
/// `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.
|
|
||||||
func fetchAuthorizationServerMetadata(
|
func fetchAuthorizationServerMetadata(
|
||||||
candidates: [URL],
|
candidates: [URL],
|
||||||
session: URLSession
|
session: URLSession
|
||||||
) async throws -> (server: URL, metadata: OAuthAuthorizationServerMetadata) {
|
) async throws -> (server: URL, metadata: OAuthAuthorizationServerMetadata) {
|
||||||
let decoder = JSONDecoder()
|
let decoder = JSONDecoder()
|
||||||
var firstValid: (server: URL, metadata: OAuthAuthorizationServerMetadata)?
|
|
||||||
|
|
||||||
for candidateServer in candidates {
|
for candidateServer in candidates {
|
||||||
guard (try? urlValidator.validateAuthorizationServer(
|
guard (try? urlValidator.validateAuthorizationServer(
|
||||||
candidateServer, context: "Authorization server issuer")) != nil
|
candidateServer, context: "Authorization server issuer")) != nil
|
||||||
|
|
@ -113,28 +95,22 @@ struct OAuthDiscoveryClient: Sendable {
|
||||||
let asMetadata = try decoder.decode(
|
let asMetadata = try decoder.decode(
|
||||||
OAuthAuthorizationServerMetadata.self, from: data)
|
OAuthAuthorizationServerMetadata.self, from: data)
|
||||||
|
|
||||||
// Prefer metadata whose issuer matches the candidate URL (RFC 8414 §3).
|
// RFC 8414 §3: issuer field must match the candidate server URL.
|
||||||
let issuerMatches =
|
// Absent issuer is tolerated (some servers omit it).
|
||||||
asMetadata.issuer == nil
|
if let metadataIssuer = asMetadata.issuer {
|
||||||
|| asMetadata.issuer?.absoluteString.lowercased()
|
guard metadataIssuer.absoluteString.lowercased()
|
||||||
== candidateServer.absoluteString.lowercased()
|
== candidateServer.absoluteString.lowercased()
|
||||||
if issuerMatches {
|
else {
|
||||||
return (server: candidateServer, metadata: asMetadata)
|
continue
|
||||||
}
|
}
|
||||||
// Keep as fallback in case no issuer-matching candidate is found.
|
|
||||||
if firstValid == nil {
|
|
||||||
let server = asMetadata.issuer ?? candidateServer
|
|
||||||
firstValid = (server: server, metadata: asMetadata)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
return (server: candidateServer, metadata: asMetadata)
|
||||||
} catch {
|
} catch {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if let firstValid {
|
|
||||||
return firstValid
|
|
||||||
}
|
|
||||||
throw OAuthAuthorizationError.authorizationServerMetadataDiscoveryFailed
|
throw OAuthAuthorizationError.authorizationServerMetadataDiscoveryFailed
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -113,7 +113,7 @@ struct OAuthTokenErrorResponse: Decodable {
|
||||||
///
|
///
|
||||||
/// Stored by ``TokenStorage`` and produced by ``OAuthAuthorizer`` after a successful
|
/// Stored by ``TokenStorage`` and produced by ``OAuthAuthorizer`` after a successful
|
||||||
/// token request. Use ``isExpired(now:skewSeconds:)`` to check validity before use.
|
/// token request. Use ``isExpired(now:skewSeconds:)`` to check validity before use.
|
||||||
public struct OAuthAccessToken: Sendable, Codable {
|
public struct OAuthAccessToken: Sendable {
|
||||||
/// The raw bearer token string for use in the `Authorization` header.
|
/// The raw bearer token string for use in the `Authorization` header.
|
||||||
public let value: String
|
public let value: String
|
||||||
|
|
||||||
|
|
@ -135,14 +135,6 @@ public struct OAuthAccessToken: Sendable, Codable {
|
||||||
/// The refresh token, if the authorization server issued one alongside the access token.
|
/// The refresh token, if the authorization server issued one alongside the access token.
|
||||||
public let refreshToken: String?
|
public let refreshToken: String?
|
||||||
|
|
||||||
/// The OAuth `client_id` this token was issued to, if the authorizer had one at storage time.
|
|
||||||
///
|
|
||||||
/// Captures the `client_id` held by the authorizer's ``OAuthConfiguration/authentication`` when
|
|
||||||
/// the token was saved — including identifiers assigned by Dynamic Client Registration
|
|
||||||
/// (RFC 7591). `nil` when no `client_id` was configured (for example, the placeholder used
|
|
||||||
/// before registration completes).
|
|
||||||
public let clientID: String?
|
|
||||||
|
|
||||||
/// Creates a new access token record.
|
/// Creates a new access token record.
|
||||||
public init(
|
public init(
|
||||||
value: String,
|
value: String,
|
||||||
|
|
@ -150,8 +142,7 @@ public struct OAuthAccessToken: Sendable, Codable {
|
||||||
expiresAt: Date?,
|
expiresAt: Date?,
|
||||||
scopes: Set<String>,
|
scopes: Set<String>,
|
||||||
authorizationServer: URL?,
|
authorizationServer: URL?,
|
||||||
refreshToken: String?,
|
refreshToken: String?
|
||||||
clientID: String? = nil
|
|
||||||
) {
|
) {
|
||||||
self.value = value
|
self.value = value
|
||||||
self.tokenType = tokenType
|
self.tokenType = tokenType
|
||||||
|
|
@ -159,7 +150,6 @@ public struct OAuthAccessToken: Sendable, Codable {
|
||||||
self.scopes = scopes
|
self.scopes = scopes
|
||||||
self.authorizationServer = authorizationServer
|
self.authorizationServer = authorizationServer
|
||||||
self.refreshToken = refreshToken
|
self.refreshToken = refreshToken
|
||||||
self.clientID = clientID
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Returns `true` if the token has expired or will expire within the skew window.
|
/// Returns `true` if the token has expired or will expire within the skew window.
|
||||||
|
|
|
||||||
|
|
@ -207,24 +207,6 @@ struct SSEEvent: Sendable {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - HTTP Context Providing
|
|
||||||
|
|
||||||
/// A transport that can surface the originating HTTP request for an in-flight JSON-RPC
|
|
||||||
/// request, keyed by its JSON-RPC id.
|
|
||||||
///
|
|
||||||
/// The server consults this during dispatch so that registered method handlers can
|
|
||||||
/// observe the HTTP request that triggered them (headers, auth, path, body) via
|
|
||||||
/// ``Server/currentHandlerContext``, without changing the `withMethodHandler` signature.
|
|
||||||
///
|
|
||||||
/// Only JSON-RPC *requests* (with an id) are addressable — notifications have no id and
|
|
||||||
/// are not correlated.
|
|
||||||
public protocol HTTPContextProviding: Sendable {
|
|
||||||
/// Returns the HTTP request associated with the given JSON-RPC id, if the
|
|
||||||
/// 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?
|
|
||||||
}
|
|
||||||
|
|
||||||
// MARK: - JSON-RPC Message Classification
|
// MARK: - JSON-RPC Message Classification
|
||||||
|
|
||||||
/// Classifies a raw JSON-RPC message for routing purposes.
|
/// Classifies a raw JSON-RPC message for routing purposes.
|
||||||
|
|
|
||||||
|
|
@ -29,7 +29,7 @@ import Logging
|
||||||
/// and receive `HTTPResponse` values to convert to your framework's native types.
|
/// and receive `HTTPResponse` values to convert to your framework's native types.
|
||||||
/// For SSE responses, the `.stream` case provides an `AsyncThrowingStream<Data, Error>`
|
/// For SSE responses, the `.stream` case provides an `AsyncThrowingStream<Data, Error>`
|
||||||
/// to pipe to the client.
|
/// to pipe to the client.
|
||||||
public actor StatefulHTTPServerTransport: Transport, HTTPContextProviding {
|
public actor StatefulHTTPServerTransport: Transport {
|
||||||
public nonisolated let logger: Logger
|
public nonisolated let logger: Logger
|
||||||
|
|
||||||
// MARK: - Dependencies
|
// MARK: - Dependencies
|
||||||
|
|
@ -54,11 +54,6 @@ public actor StatefulHTTPServerTransport: Transport, HTTPContextProviding {
|
||||||
/// Maps request ID → SSE stream continuation for active POST request streams.
|
/// Maps request ID → SSE stream continuation for active POST request streams.
|
||||||
private var requestSSEContinuations: [String: AsyncThrowingStream<Data, Swift.Error>.Continuation] = [:]
|
private var requestSSEContinuations: [String: AsyncThrowingStream<Data, Swift.Error>.Continuation] = [:]
|
||||||
|
|
||||||
/// 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.
|
|
||||||
private var httpRequestContexts: [String: HTTPRequest] = [:]
|
|
||||||
|
|
||||||
// MARK: - Standalone GET SSE stream
|
// MARK: - Standalone GET SSE stream
|
||||||
|
|
||||||
/// The standalone SSE stream continuation for server-initiated messages.
|
/// The standalone SSE stream continuation for server-initiated messages.
|
||||||
|
|
@ -285,7 +280,6 @@ public actor StatefulHTTPServerTransport: Transport, HTTPContextProviding {
|
||||||
// Create SSE stream for this request
|
// Create SSE stream for this request
|
||||||
let (sseStream, sseContinuation) = AsyncThrowingStream<Data, Swift.Error>.makeStream()
|
let (sseStream, sseContinuation) = AsyncThrowingStream<Data, Swift.Error>.makeStream()
|
||||||
requestSSEContinuations[requestID] = sseContinuation
|
requestSSEContinuations[requestID] = sseContinuation
|
||||||
httpRequestContexts[requestID] = request
|
|
||||||
|
|
||||||
// Extract protocol version for priming event decision
|
// Extract protocol version for priming event decision
|
||||||
let protocolVersion = extractProtocolVersion(from: body, request: request)
|
let protocolVersion = extractProtocolVersion(from: body, request: request)
|
||||||
|
|
@ -412,7 +406,6 @@ public actor StatefulHTTPServerTransport: Transport, HTTPContextProviding {
|
||||||
continuation.finish()
|
continuation.finish()
|
||||||
requestSSEContinuations.removeValue(forKey: requestID)
|
requestSSEContinuations.removeValue(forKey: requestID)
|
||||||
}
|
}
|
||||||
httpRequestContexts.removeValue(forKey: requestID)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private func routeServerInitiatedMessage(_ data: Data) {
|
private func routeServerInitiatedMessage(_ data: Data) {
|
||||||
|
|
@ -542,13 +535,6 @@ public actor StatefulHTTPServerTransport: Transport, HTTPContextProviding {
|
||||||
guard let continuation = requestSSEContinuations[requestID] else { return }
|
guard let continuation = requestSSEContinuations[requestID] else { return }
|
||||||
continuation.finish()
|
continuation.finish()
|
||||||
requestSSEContinuations.removeValue(forKey: requestID)
|
requestSSEContinuations.removeValue(forKey: requestID)
|
||||||
httpRequestContexts.removeValue(forKey: requestID)
|
|
||||||
}
|
|
||||||
|
|
||||||
// MARK: - HTTPContextProviding
|
|
||||||
|
|
||||||
public func httpRequestContext(for id: ID) -> HTTPRequest? {
|
|
||||||
httpRequestContexts[id.description]
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - Termination
|
// MARK: - Termination
|
||||||
|
|
@ -566,7 +552,6 @@ public actor StatefulHTTPServerTransport: Transport, HTTPContextProviding {
|
||||||
continuation.finish()
|
continuation.finish()
|
||||||
}
|
}
|
||||||
requestSSEContinuations.removeAll()
|
requestSSEContinuations.removeAll()
|
||||||
httpRequestContexts.removeAll()
|
|
||||||
|
|
||||||
// Close standalone GET stream
|
// Close standalone GET stream
|
||||||
standaloneSSEContinuation?.finish()
|
standaloneSSEContinuation?.finish()
|
||||||
|
|
|
||||||
|
|
@ -29,7 +29,7 @@ import Logging
|
||||||
/// - Session management is handled externally or not needed
|
/// - Session management is handled externally or not needed
|
||||||
///
|
///
|
||||||
/// For full streaming and session support, use ``StatefulHTTPServerTransport`` instead.
|
/// For full streaming and session support, use ``StatefulHTTPServerTransport`` instead.
|
||||||
public actor StatelessHTTPServerTransport: Transport, HTTPContextProviding {
|
public actor StatelessHTTPServerTransport: Transport {
|
||||||
public nonisolated let logger: Logger
|
public nonisolated let logger: Logger
|
||||||
|
|
||||||
// MARK: - Dependencies
|
// MARK: - Dependencies
|
||||||
|
|
@ -52,11 +52,6 @@ public actor StatelessHTTPServerTransport: Transport, HTTPContextProviding {
|
||||||
/// When the server calls `send()` with a response, the matching continuation is resumed.
|
/// When the server calls `send()` with a response, the matching continuation is resumed.
|
||||||
private var responseWaiters: [String: CheckedContinuation<Data, any Error>] = [:]
|
private var responseWaiters: [String: CheckedContinuation<Data, any Error>] = [:]
|
||||||
|
|
||||||
/// 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] = [:]
|
|
||||||
|
|
||||||
// MARK: - Init
|
// MARK: - Init
|
||||||
|
|
||||||
/// Creates a new stateless HTTP server transport.
|
/// Creates a new stateless HTTP server transport.
|
||||||
|
|
@ -212,16 +207,11 @@ public actor StatelessHTTPServerTransport: Transport, HTTPContextProviding {
|
||||||
return .accepted()
|
return .accepted()
|
||||||
|
|
||||||
case .request(let id, _):
|
case .request(let id, _):
|
||||||
return await handleJSONRPCRequest(body, requestID: id, request: request)
|
return await handleJSONRPCRequest(body, requestID: id)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private func handleJSONRPCRequest(
|
private func handleJSONRPCRequest(_ body: Data, requestID: String) async -> HTTPResponse {
|
||||||
_ body: Data,
|
|
||||||
requestID: String,
|
|
||||||
request: HTTPRequest
|
|
||||||
) async -> HTTPResponse {
|
|
||||||
httpRequestContexts[requestID] = request
|
|
||||||
// Yield the incoming message to the server
|
// Yield the incoming message to the server
|
||||||
incomingContinuation.yield(body)
|
incomingContinuation.yield(body)
|
||||||
|
|
||||||
|
|
@ -232,23 +222,15 @@ public actor StatelessHTTPServerTransport: Transport, HTTPContextProviding {
|
||||||
responseWaiters[requestID] = continuation
|
responseWaiters[requestID] = continuation
|
||||||
}
|
}
|
||||||
} catch {
|
} catch {
|
||||||
httpRequestContexts.removeValue(forKey: requestID)
|
|
||||||
return .error(
|
return .error(
|
||||||
statusCode: 500,
|
statusCode: 500,
|
||||||
.internalError("Error processing request: \(error.localizedDescription)")
|
.internalError("Error processing request: \(error.localizedDescription)")
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
httpRequestContexts.removeValue(forKey: requestID)
|
|
||||||
return .data(responseData, headers: [HTTPHeaderName.contentType: ContentType.json])
|
return .data(responseData, headers: [HTTPHeaderName.contentType: ContentType.json])
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - HTTPContextProviding
|
|
||||||
|
|
||||||
public func httpRequestContext(for id: ID) -> HTTPRequest? {
|
|
||||||
httpRequestContexts[id.description]
|
|
||||||
}
|
|
||||||
|
|
||||||
// MARK: - Termination
|
// MARK: - Termination
|
||||||
|
|
||||||
private func terminate() async {
|
private func terminate() async {
|
||||||
|
|
@ -263,7 +245,6 @@ public actor StatelessHTTPServerTransport: Transport, HTTPContextProviding {
|
||||||
logger.debug("Cancelled waiter for request", metadata: ["requestID": "\(id)"])
|
logger.debug("Cancelled waiter for request", metadata: ["requestID": "\(id)"])
|
||||||
}
|
}
|
||||||
responseWaiters.removeAll()
|
responseWaiters.removeAll()
|
||||||
httpRequestContexts.removeAll()
|
|
||||||
|
|
||||||
// Close incoming stream
|
// Close incoming stream
|
||||||
incomingContinuation.finish()
|
incomingContinuation.finish()
|
||||||
|
|
|
||||||
|
|
@ -298,41 +298,13 @@ public actor Server {
|
||||||
|
|
||||||
// MARK: - Request Context
|
// MARK: - Request Context
|
||||||
|
|
||||||
/// Per-dispatch context the SDK attaches to the currently executing handler.
|
/// The JSON-RPC request ID of the currently executing method handler.
|
||||||
///
|
|
||||||
/// Exposed via the ``Server/currentHandlerContext`` task-local so method
|
|
||||||
/// 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 originating HTTP request, if the active transport conforms to
|
|
||||||
/// ``HTTPContextProviding``. `nil` for transports that don't carry HTTP
|
|
||||||
/// context (stdio, in-memory) or for handlers reached off the dispatch
|
|
||||||
/// path.
|
|
||||||
public let httpContext: HTTPRequest?
|
|
||||||
|
|
||||||
package init(id: ID, httpContext: HTTPRequest?) {
|
|
||||||
self.id = id
|
|
||||||
self.httpContext = httpContext
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// The handler context for the currently executing method handler.
|
|
||||||
///
|
///
|
||||||
/// Set via `@TaskLocal` before dispatching each request, so it propagates
|
/// Set via `@TaskLocal` before dispatching each request, so it propagates
|
||||||
/// automatically into the handler task. `nil` outside of a handler.
|
/// automatically into the handler task. Accessible package-wide for
|
||||||
///
|
/// transports that need to identify the active request (e.g. closing an
|
||||||
/// When spawning `Task.detached { … }` from inside a handler, capture the
|
/// SSE stream mid-call for reconnection testing per SEP-1699).
|
||||||
/// value up front — detached tasks do not inherit task-locals:
|
@TaskLocal package static var currentRequestID: ID? = nil
|
||||||
///
|
|
||||||
/// ```swift
|
|
||||||
/// let ctx = Server.currentHandlerContext
|
|
||||||
/// Task.detached { await doWork(with: ctx?.httpContext) }
|
|
||||||
/// ```
|
|
||||||
@TaskLocal public static var currentHandlerContext: HandlerContext? = nil
|
|
||||||
|
|
||||||
// MARK: - Registration
|
// MARK: - Registration
|
||||||
|
|
||||||
|
|
@ -778,17 +750,10 @@ public actor Server {
|
||||||
return response
|
return response
|
||||||
}
|
}
|
||||||
|
|
||||||
// Ask the transport for the originating HTTP request so handlers can observe
|
|
||||||
// headers/auth via `Server.currentHandlerContext?.httpContext`. Transports
|
|
||||||
// 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)
|
|
||||||
|
|
||||||
// Create a task to handle the request with cancellation support.
|
// Create a task to handle the request with cancellation support.
|
||||||
// Set currentHandlerContext as a task local so handlers see it.
|
// Set currentRequestID as a task local so handlers can identify the active request.
|
||||||
var handlerTask: Task<Response<AnyMethod>, Error>!
|
var handlerTask: Task<Response<AnyMethod>, Error>!
|
||||||
Server.$currentHandlerContext.withValue(handlerContext) {
|
Server.$currentRequestID.withValue(request.id) {
|
||||||
handlerTask = Task<Response<AnyMethod>, Error> {
|
handlerTask = Task<Response<AnyMethod>, Error> {
|
||||||
do {
|
do {
|
||||||
// Check if task was cancelled before starting
|
// Check if task was cancelled before starting
|
||||||
|
|
|
||||||
|
|
@ -180,7 +180,8 @@ func createConformanceServer(state: ServerState, transport: StatefulHTTPServerTr
|
||||||
// SEP-1699: Close the SSE stream mid-call to trigger client 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
|
// The client should reconnect via GET with Last-Event-ID and receive the
|
||||||
// response on the new stream.
|
// response on the new stream.
|
||||||
if let requestID = Server.currentHandlerContext?.id {
|
if let requestID = Server.currentRequestID {
|
||||||
|
|
||||||
await transport.closeSSEStream(forRequestID: requestID.description)
|
await transport.closeSSEStream(forRequestID: requestID.description)
|
||||||
}
|
}
|
||||||
// Wait briefly for the client to reconnect before sending the response.
|
// Wait briefly for the client to reconnect before sending the response.
|
||||||
|
|
|
||||||
|
|
@ -1052,218 +1052,3 @@ struct StatelessHTTPServerTransportTests {
|
||||||
#expect(response.statusCode == 500)
|
#expect(response.statusCode == 500)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - HTTPContextProviding / Server.currentHandlerContext
|
|
||||||
|
|
||||||
@Suite("Server.currentHandlerContext Tests")
|
|
||||||
struct ServerHandlerContextTests {
|
|
||||||
|
|
||||||
// MARK: Stateful transport — HTTPContextProviding
|
|
||||||
|
|
||||||
@Test("Stateful: httpRequestContext returns in-flight request, cleared after response")
|
|
||||||
func testStatefulContextLifecycle() async throws {
|
|
||||||
let sessionID = "test-session"
|
|
||||||
let transport = makeStatefulTransport(
|
|
||||||
sessionIDGenerator: FixedSessionIDGenerator(sessionID: sessionID)
|
|
||||||
)
|
|
||||||
_ = try await initializeSession(transport: transport, sessionID: sessionID)
|
|
||||||
|
|
||||||
// POST a tools/list request with a distinctive Authorization header and path.
|
|
||||||
let body = makeRequestBody(id: "42", method: "tools/list")
|
|
||||||
let httpRequest = HTTPRequest(
|
|
||||||
method: "POST",
|
|
||||||
headers: [
|
|
||||||
"Content-Type": "application/json",
|
|
||||||
"Accept": "application/json, text/event-stream",
|
|
||||||
"Mcp-Session-Id": sessionID,
|
|
||||||
"Authorization": "Bearer token-xyz",
|
|
||||||
],
|
|
||||||
body: body,
|
|
||||||
path: "/mcp"
|
|
||||||
)
|
|
||||||
let response = await transport.handleRequest(httpRequest)
|
|
||||||
#expect(response.statusCode == 200)
|
|
||||||
|
|
||||||
// In-flight: context surfaces through the protocol lookup.
|
|
||||||
let inFlight = await transport.httpRequestContext(for: .string("42"))
|
|
||||||
#expect(inFlight != nil)
|
|
||||||
#expect(inFlight?.header("Authorization") == "Bearer token-xyz")
|
|
||||||
#expect(inFlight?.path == "/mcp")
|
|
||||||
|
|
||||||
// Drain the SSE stream in the background so the response routes cleanly.
|
|
||||||
if case .stream(let stream, _) = response {
|
|
||||||
Task { for try await _ in stream {} }
|
|
||||||
}
|
|
||||||
|
|
||||||
// Send the response — transport clears the context alongside the SSE map.
|
|
||||||
try await transport.send(makeResponseBody(id: "42"))
|
|
||||||
try await Task.sleep(for: .milliseconds(50))
|
|
||||||
|
|
||||||
let afterResponse = await transport.httpRequestContext(for: .string("42"))
|
|
||||||
#expect(afterResponse == nil)
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test("Stateful: context cleared on session termination")
|
|
||||||
func testStatefulContextClearedOnTerminate() async throws {
|
|
||||||
let sessionID = "term-session"
|
|
||||||
let transport = makeStatefulTransport(
|
|
||||||
sessionIDGenerator: FixedSessionIDGenerator(sessionID: sessionID)
|
|
||||||
)
|
|
||||||
_ = try await initializeSession(transport: transport, sessionID: sessionID)
|
|
||||||
|
|
||||||
// Register an in-flight request to populate the context map.
|
|
||||||
let body = makeRequestBody(id: "7", method: "tools/list")
|
|
||||||
_ = await transport.handleRequest(
|
|
||||||
makeStatefulPOSTRequest(
|
|
||||||
body: body,
|
|
||||||
sessionID: sessionID,
|
|
||||||
authorization: "Bearer keep"
|
|
||||||
)
|
|
||||||
)
|
|
||||||
#expect(await transport.httpRequestContext(for: .string("7")) != nil)
|
|
||||||
|
|
||||||
// DELETE terminates the session.
|
|
||||||
_ = await transport.handleRequest(makeDELETERequest(sessionID: sessionID))
|
|
||||||
|
|
||||||
#expect(await transport.httpRequestContext(for: .string("7")) == nil)
|
|
||||||
}
|
|
||||||
|
|
||||||
// MARK: Stateless transport — HTTPContextProviding
|
|
||||||
|
|
||||||
@Test("Stateless: context available during handling, cleared after response")
|
|
||||||
func testStatelessContextLifecycle() async throws {
|
|
||||||
let transport = makeStatelessTransport()
|
|
||||||
try await transport.connect()
|
|
||||||
|
|
||||||
let httpRequest = HTTPRequest(
|
|
||||||
method: "POST",
|
|
||||||
headers: [
|
|
||||||
"Content-Type": "application/json",
|
|
||||||
"Accept": "application/json",
|
|
||||||
"Authorization": "Bearer stateless-token",
|
|
||||||
],
|
|
||||||
body: makeRequestBody(id: "99", method: "tools/list"),
|
|
||||||
path: "/mcp"
|
|
||||||
)
|
|
||||||
|
|
||||||
// handleRequest blocks until a response is sent — run it concurrently.
|
|
||||||
let handleTask = Task { await transport.handleRequest(httpRequest) }
|
|
||||||
|
|
||||||
// Give the handler time to register its waiter (and store the context).
|
|
||||||
try await Task.sleep(for: .milliseconds(50))
|
|
||||||
|
|
||||||
let inFlight = await transport.httpRequestContext(for: .string("99"))
|
|
||||||
#expect(inFlight != nil)
|
|
||||||
#expect(inFlight?.header("Authorization") == "Bearer stateless-token")
|
|
||||||
#expect(inFlight?.path == "/mcp")
|
|
||||||
|
|
||||||
// Send the response to unblock the waiter.
|
|
||||||
try await transport.send(makeResponseBody(id: "99"))
|
|
||||||
_ = await handleTask.value
|
|
||||||
|
|
||||||
let afterResponse = await transport.httpRequestContext(for: .string("99"))
|
|
||||||
#expect(afterResponse == nil)
|
|
||||||
|
|
||||||
await transport.disconnect()
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test("Stateless: unknown request id returns nil")
|
|
||||||
func testStatelessContextUnknownID() async throws {
|
|
||||||
let transport = makeStatelessTransport()
|
|
||||||
try await transport.connect()
|
|
||||||
let none = await transport.httpRequestContext(for: .string("does-not-exist"))
|
|
||||||
#expect(none == nil)
|
|
||||||
await transport.disconnect()
|
|
||||||
}
|
|
||||||
|
|
||||||
// MARK: Server integration — end-to-end
|
|
||||||
|
|
||||||
@Test("Server handler reads HTTP context via Server.currentHandlerContext")
|
|
||||||
func testServerHandlerReadsHTTPContext() async throws {
|
|
||||||
actor Captured {
|
|
||||||
var auth: String?
|
|
||||||
var path: String?
|
|
||||||
var id: ID?
|
|
||||||
func record(auth: String?, path: String?, id: ID?) {
|
|
||||||
self.auth = auth
|
|
||||||
self.path = path
|
|
||||||
self.id = id
|
|
||||||
}
|
|
||||||
}
|
|
||||||
let captured = Captured()
|
|
||||||
|
|
||||||
let transport = makeStatelessTransport()
|
|
||||||
let server = Server(name: "TestServer", version: "1.0")
|
|
||||||
|
|
||||||
await server.withMethodHandler(CallTool.self) { _ in
|
|
||||||
let ctx = Server.currentHandlerContext
|
|
||||||
await captured.record(
|
|
||||||
auth: ctx?.httpContext?.header("Authorization"),
|
|
||||||
path: ctx?.httpContext?.path,
|
|
||||||
id: ctx?.id
|
|
||||||
)
|
|
||||||
return CallTool.Result(content: [.text(text: "ok", annotations: nil, _meta: nil)])
|
|
||||||
}
|
|
||||||
|
|
||||||
try await server.start(transport: transport)
|
|
||||||
defer { Task { await server.stop() } }
|
|
||||||
|
|
||||||
let body = try JSONSerialization.data(withJSONObject: [
|
|
||||||
"jsonrpc": "2.0",
|
|
||||||
"id": "call-1",
|
|
||||||
"method": "tools/call",
|
|
||||||
"params": ["name": "whoami"] as [String: Any],
|
|
||||||
])
|
|
||||||
let httpRequest = HTTPRequest(
|
|
||||||
method: "POST",
|
|
||||||
headers: [
|
|
||||||
"Content-Type": "application/json",
|
|
||||||
"Accept": "application/json",
|
|
||||||
"Authorization": "Bearer server-integration-token",
|
|
||||||
],
|
|
||||||
body: body,
|
|
||||||
path: "/mcp"
|
|
||||||
)
|
|
||||||
|
|
||||||
let httpResponse = await transport.handleRequest(httpRequest)
|
|
||||||
#expect(httpResponse.statusCode == 200)
|
|
||||||
|
|
||||||
#expect(await captured.auth == "Bearer server-integration-token")
|
|
||||||
#expect(await captured.path == "/mcp")
|
|
||||||
#expect(await captured.id == .string("call-1"))
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test("Non-HTTP transport yields nil httpContext")
|
|
||||||
func testNonHTTPTransportNilContext() async throws {
|
|
||||||
actor Captured {
|
|
||||||
var httpContext: HTTPRequest?
|
|
||||||
var sawDispatch = false
|
|
||||||
func set(_ v: HTTPRequest?) { httpContext = v; sawDispatch = true }
|
|
||||||
}
|
|
||||||
let captured = Captured()
|
|
||||||
|
|
||||||
let transport = MockTransport()
|
|
||||||
let server = Server(name: "TestServer", version: "1.0")
|
|
||||||
|
|
||||||
// Start first — `start` calls registerDefaultHandlers which would otherwise
|
|
||||||
// clobber our override.
|
|
||||||
try await server.start(transport: transport)
|
|
||||||
|
|
||||||
// Override the Ping handler so we can observe the task-local during dispatch.
|
|
||||||
await server.withMethodHandler(Ping.self) { _ in
|
|
||||||
await captured.set(Server.currentHandlerContext?.httpContext)
|
|
||||||
return Empty()
|
|
||||||
}
|
|
||||||
|
|
||||||
try await transport.queue(request: Ping.request(.init()))
|
|
||||||
|
|
||||||
// Wait for dispatch.
|
|
||||||
try await Task.sleep(for: .milliseconds(100))
|
|
||||||
|
|
||||||
#expect(await captured.sawDispatch == true)
|
|
||||||
#expect(await captured.httpContext == nil)
|
|
||||||
|
|
||||||
await server.stop()
|
|
||||||
await transport.disconnect()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
|
||||||
|
|
@ -64,7 +64,7 @@ final class MockDiscoveryClient: OAuthDiscoveryFetching, @unchecked Sendable {
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
func fetchProtectedResourceMetadata(candidates: [URL], fallbackIssuer: URL?, session: URLSession) async throws -> OAuthProtectedResourceMetadata {
|
func fetchProtectedResourceMetadata(candidates: [URL], session: URLSession) async throws -> OAuthProtectedResourceMetadata {
|
||||||
fetchProtectedResourceMetadataCallCount += 1
|
fetchProtectedResourceMetadataCallCount += 1
|
||||||
return protectedResourceMetadataResult
|
return protectedResourceMetadataResult
|
||||||
}
|
}
|
||||||
|
|
@ -100,10 +100,6 @@ final class MockTokenClient: OAuthTokenRequesting, @unchecked Sendable {
|
||||||
|
|
||||||
final class MockClientRegistrar: OAuthClientRegistering, @unchecked Sendable {
|
final class MockClientRegistrar: OAuthClientRegistering, @unchecked Sendable {
|
||||||
var registerCallCount = 0
|
var registerCallCount = 0
|
||||||
var registrationResult: (
|
|
||||||
response: OAuthClientRegistrationResponse,
|
|
||||||
updatedAuthentication: OAuthConfiguration.TokenEndpointAuthentication
|
|
||||||
)?
|
|
||||||
|
|
||||||
func register(
|
func register(
|
||||||
configuration: OAuthConfiguration,
|
configuration: OAuthConfiguration,
|
||||||
|
|
@ -114,7 +110,7 @@ final class MockClientRegistrar: OAuthClientRegistering, @unchecked Sendable {
|
||||||
updatedAuthentication: OAuthConfiguration.TokenEndpointAuthentication
|
updatedAuthentication: OAuthConfiguration.TokenEndpointAuthentication
|
||||||
)? {
|
)? {
|
||||||
registerCallCount += 1
|
registerCallCount += 1
|
||||||
return registrationResult
|
return nil
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -385,45 +381,4 @@ struct OAuthAuthorizerTests {
|
||||||
|
|
||||||
#expect(registrar.registerCallCount == 0)
|
#expect(registrar.registerCallCount == 0)
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test("handleChallenge persists the DCR-assigned clientID on the saved token")
|
|
||||||
func testHandleChallengePersistsDCRClientIDOnToken() async throws {
|
|
||||||
let assignedClientID = "dcr-assigned-client-id"
|
|
||||||
let tokenStorage = InMemoryTokenStorage()
|
|
||||||
let registrar = MockClientRegistrar()
|
|
||||||
registrar.registrationResult = (
|
|
||||||
response: OAuthClientRegistrationResponse(
|
|
||||||
clientID: assignedClientID,
|
|
||||||
clientSecret: nil,
|
|
||||||
tokenEndpointAuthMethod: nil,
|
|
||||||
clientSecretExpiresAt: nil
|
|
||||||
),
|
|
||||||
updatedAuthentication: .none(clientID: assignedClientID)
|
|
||||||
)
|
|
||||||
|
|
||||||
let config = OAuthConfiguration(
|
|
||||||
authentication: .none(clientID: "")
|
|
||||||
)
|
|
||||||
let authorizer = OAuthAuthorizer(
|
|
||||||
configuration: config,
|
|
||||||
tokenStorage: tokenStorage,
|
|
||||||
urlValidator: MockURLValidator(),
|
|
||||||
discoveryClient: MockDiscoveryClient(),
|
|
||||||
tokenEndpointClient: MockTokenClient(),
|
|
||||||
clientRegistrar: registrar,
|
|
||||||
authCodeFlow: MockAuthCodeFlow()
|
|
||||||
)
|
|
||||||
|
|
||||||
let handled = try await authorizer.handleChallenge(
|
|
||||||
statusCode: 401,
|
|
||||||
headers: headers401,
|
|
||||||
endpoint: endpoint,
|
|
||||||
operationKey: nil,
|
|
||||||
session: .shared
|
|
||||||
)
|
|
||||||
|
|
||||||
#expect(handled == true)
|
|
||||||
#expect(registrar.registerCallCount == 1)
|
|
||||||
#expect(tokenStorage.load()?.clientID == assignedClientID)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -49,7 +49,6 @@ import Testing
|
||||||
|
|
||||||
let metadata = try await makeClient().fetchProtectedResourceMetadata(
|
let metadata = try await makeClient().fetchProtectedResourceMetadata(
|
||||||
candidates: [URL(string: "https://example.com/.well-known/oauth-protected-resource")!],
|
candidates: [URL(string: "https://example.com/.well-known/oauth-protected-resource")!],
|
||||||
fallbackIssuer: nil,
|
|
||||||
session: session
|
session: session
|
||||||
)
|
)
|
||||||
let expected = OAuthProtectedResourceMetadata(
|
let expected = OAuthProtectedResourceMetadata(
|
||||||
|
|
@ -77,7 +76,6 @@ import Testing
|
||||||
URL(string: "https://example.com/.well-known/oauth-protected-resource/mcp")!,
|
URL(string: "https://example.com/.well-known/oauth-protected-resource/mcp")!,
|
||||||
URL(string: "https://example.com/.well-known/oauth-protected-resource")!,
|
URL(string: "https://example.com/.well-known/oauth-protected-resource")!,
|
||||||
],
|
],
|
||||||
fallbackIssuer: nil,
|
|
||||||
session: session
|
session: session
|
||||||
)
|
)
|
||||||
let expected = OAuthProtectedResourceMetadata(
|
let expected = OAuthProtectedResourceMetadata(
|
||||||
|
|
@ -105,7 +103,6 @@ import Testing
|
||||||
URL(string: "https://example.com/.well-known/oauth-protected-resource/mcp")!,
|
URL(string: "https://example.com/.well-known/oauth-protected-resource/mcp")!,
|
||||||
URL(string: "https://example.com/.well-known/oauth-protected-resource")!,
|
URL(string: "https://example.com/.well-known/oauth-protected-resource")!,
|
||||||
],
|
],
|
||||||
fallbackIssuer: nil,
|
|
||||||
session: session
|
session: session
|
||||||
)
|
)
|
||||||
let expected = OAuthProtectedResourceMetadata(
|
let expected = OAuthProtectedResourceMetadata(
|
||||||
|
|
@ -115,7 +112,7 @@ import Testing
|
||||||
#expect(metadata == expected)
|
#expect(metadata == expected)
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test("Throws metadataDiscoveryFailed when all candidates fail and no fallback issuer")
|
@Test("Throws metadataDiscoveryFailed when all candidates fail")
|
||||||
func testFetchProtectedResourceMetadataThrowsWhenAllFail() async throws {
|
func testFetchProtectedResourceMetadataThrowsWhenAllFail() async throws {
|
||||||
let (session, key) = makeIsolatedSession()
|
let (session, key) = makeIsolatedSession()
|
||||||
await IsolatedMockURLProtocol.setHandler(key: key) { request in
|
await IsolatedMockURLProtocol.setHandler(key: key) { request in
|
||||||
|
|
@ -129,34 +126,11 @@ import Testing
|
||||||
candidates: [
|
candidates: [
|
||||||
URL(string: "https://example.com/.well-known/oauth-protected-resource")!
|
URL(string: "https://example.com/.well-known/oauth-protected-resource")!
|
||||||
],
|
],
|
||||||
fallbackIssuer: nil,
|
|
||||||
session: session
|
session: session
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test("Returns synthetic metadata with fallback issuer when all candidates fail")
|
|
||||||
func testFetchProtectedResourceMetadataUsesFallbackIssuer() async throws {
|
|
||||||
let fallback = URL(string: "https://example.com")!
|
|
||||||
let (session, key) = makeIsolatedSession()
|
|
||||||
await IsolatedMockURLProtocol.setHandler(key: key) { request in
|
|
||||||
let response = HTTPURLResponse(
|
|
||||||
url: request.url!, statusCode: 404, httpVersion: nil, headerFields: nil)!
|
|
||||||
return (response, Data())
|
|
||||||
}
|
|
||||||
|
|
||||||
let metadata = try await makeClient().fetchProtectedResourceMetadata(
|
|
||||||
candidates: [URL(string: "https://example.com/.well-known/oauth-protected-resource")!],
|
|
||||||
fallbackIssuer: fallback,
|
|
||||||
session: session
|
|
||||||
)
|
|
||||||
let expected = OAuthProtectedResourceMetadata(
|
|
||||||
resource: nil,
|
|
||||||
authorizationServers: [fallback],
|
|
||||||
scopesSupported: nil)
|
|
||||||
#expect(metadata == expected)
|
|
||||||
}
|
|
||||||
|
|
||||||
// MARK: - fetchAuthorizationServerMetadata
|
// MARK: - fetchAuthorizationServerMetadata
|
||||||
|
|
||||||
@Test("Returns server and metadata when issuer matches")
|
@Test("Returns server and metadata when issuer matches")
|
||||||
|
|
@ -188,23 +162,23 @@ import Testing
|
||||||
#expect(metadata == expectedMetadata)
|
#expect(metadata == expectedMetadata)
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test("Uses metadata issuer as server identity when it differs from candidate URL")
|
@Test("Skips candidate when issuer field does not match")
|
||||||
func testFetchAuthorizationServerMetadataUsesMetadataIssuer() async throws {
|
func testFetchAuthorizationServerMetadataSkipsIssuerMismatch() async throws {
|
||||||
let metadataIssuer = "https://other.example.com"
|
let wrongIssuerBody = try makeASMetadataBody(issuer: "https://other.example.com")
|
||||||
let body = try makeASMetadataBody(issuer: metadataIssuer)
|
|
||||||
let (session, key) = makeIsolatedSession()
|
let (session, key) = makeIsolatedSession()
|
||||||
await IsolatedMockURLProtocol.setHandler(key: key) { _ in
|
await IsolatedMockURLProtocol.setHandler(key: key) { _ in
|
||||||
let response = HTTPURLResponse(
|
let response = HTTPURLResponse(
|
||||||
url: URL(string: "https://auth.example.com")!,
|
url: URL(string: "https://auth.example.com")!,
|
||||||
statusCode: 200, httpVersion: nil, headerFields: nil)!
|
statusCode: 200, httpVersion: nil, headerFields: nil)!
|
||||||
return (response, body)
|
return (response, wrongIssuerBody)
|
||||||
}
|
}
|
||||||
|
|
||||||
let (server, _) = try await makeClient().fetchAuthorizationServerMetadata(
|
await #expect(throws: OAuthAuthorizationError.self) {
|
||||||
candidates: [URL(string: "https://auth.example.com")!],
|
try await makeClient().fetchAuthorizationServerMetadata(
|
||||||
session: session
|
candidates: [URL(string: "https://auth.example.com")!],
|
||||||
)
|
session: session
|
||||||
#expect(server == URL(string: metadataIssuer)!)
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test("Skips private IP candidates without making HTTP calls")
|
@Test("Skips private IP candidates without making HTTP calls")
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue