* fix #203 NetworkTransport race, with main-isolated variables (#206)

Co-authored-by: wti <wti@users.noreply.github.com>

* feat/auth: Authentication implementation (#205)

* feat: added authentication

* feat(auth): improvement to error handling

* feat(auth): updated errors and tests in OAuthClientRegistrar

* feat(auth): updated ci conformance package version

* feat(auth): bumped node version

* feat(auth): updated ci conformance step

* feat(auth): improvement to Linux support

* chore: added docc plugin

* chore: added spi yml

* chore: added documentation step to ci

* chore: increased timeout for test from 5 to 10m

---------

Co-authored-by: Paal Øye-Strømme <paal.o.eye@gmail.com>
Co-authored-by: wti <wti@users.noreply.github.com>
Co-authored-by: yehorsobko <yehorsobko@macpaw.com>
This commit is contained in:
Maksym Mova 2026-03-24 11:05:22 +02:00 • committed by GitHub
commit 6132fd4b5b
No known key found for this signature in database
GPG key ID: B5690EEEBB952194

View file

@ -14,10 +14,359 @@ import Foundation
import Logging
import MCP
#if canImport(FoundationNetworking)
import FoundationNetworking
#endif
// MARK: - Scenario Handlers
typealias ScenarioHandler = ([String]) async throws -> Void
// MARK: - Authorization Scenarios
private func loadConformanceContext() -> [String: String] {
let env = ProcessInfo.processInfo.environment
if let raw = env["MCP_CONFORMANCE_CONTEXT"],
let data = raw.data(using: .utf8),
let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any]
{
var parsed: [String: String] = [:]
for (key, value) in json {
if let value = value as? String {
parsed[key] = value
}
}
return parsed
}
var parsed: [String: String] = [:]
if let clientID = env["MCP_CONFORMANCE_CLIENT_ID"] {
parsed["client_id"] = clientID
}
if let clientSecret = env["MCP_CONFORMANCE_CLIENT_SECRET"] {
parsed["client_secret"] = clientSecret
}
return parsed
}
private func percentEncodeFormValue(_ value: String) -> String {
let allowed = CharacterSet(charactersIn: "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-._~")
return value.addingPercentEncoding(withAllowedCharacters: allowed) ?? value
}
private func formURLEncodedBody(_ parameters: [String: String]) -> Data {
let encoded = parameters
.sorted { $0.key < $1.key }
.map { key, value in
"\(percentEncodeFormValue(key))=\(percentEncodeFormValue(value))"
}
.joined(separator: "&")
return Data(encoded.utf8)
}
private func clientAssertionAudience(from tokenEndpoint: URL) -> String {
guard var components = URLComponents(url: tokenEndpoint, resolvingAgainstBaseURL: false) else {
return tokenEndpoint.absoluteString
}
components.query = nil
components.fragment = nil
var path = components.path
if path.hasSuffix("/token") {
path = String(path.dropLast("/token".count))
} else {
let parts = path.split(separator: "/")
if !parts.isEmpty {
let parent = parts.dropLast()
path = parent.isEmpty ? "" : "/" + parent.joined(separator: "/")
}
}
if path == "/" {
path = ""
}
components.path = path
return components.url?.absoluteString ?? tokenEndpoint.absoluteString
}
private func parsePrivateKeyJWTSigningAlgorithm(
_ signingAlgorithm: String
) throws -> OAuthConfiguration.PrivateKeyJWTSigningAlgorithm {
switch signingAlgorithm.uppercased() {
case OAuthConfiguration.PrivateKeyJWTSigningAlgorithm.ES256.rawValue:
return .ES256
default:
throw ConformanceError.invalidArguments(
"Unsupported signing algorithm: \(signingAlgorithm)"
)
}
}
private func makeOAuthConfiguration(
for scenario: String,
context: [String: String]
) -> OAuthConfiguration {
let clientID = context["client_id"] ?? "test-client"
let clientSecret = context["client_secret"] ?? "test-secret"
var configuration: OAuthConfiguration
switch scenario {
case "auth/pre-registration":
configuration = .init(
grantType: .authorizationCode,
authentication: .clientSecretBasic(
clientID: clientID,
clientSecret: clientSecret
)
)
case "auth/token-endpoint-auth-basic":
configuration = .init(
grantType: .authorizationCode,
authentication: .clientSecretBasic(
clientID: clientID,
clientSecret: clientSecret
)
)
case "auth/token-endpoint-auth-post":
configuration = .init(
grantType: .authorizationCode,
authentication: .clientSecretPost(
clientID: clientID,
clientSecret: clientSecret
)
)
case "auth/client-credentials-basic":
configuration = .init(
authentication: .clientSecretBasic(
clientID: clientID,
clientSecret: clientSecret
)
)
case "auth/client-credentials-jwt":
let privateKeyPEM = context["private_key_pem"] ?? ""
let signingAlgorithm = context["signing_algorithm"] ?? "ES256"
configuration = .init(
authentication: .privateKeyJWT(
clientID: clientID,
assertionFactory: { tokenEndpoint, clientID in
try OAuthConfiguration.makePrivateKeyJWTAssertion(
clientID: clientID,
tokenEndpoint: tokenEndpoint,
privateKeyPEM: privateKeyPEM,
signingAlgorithm: try parsePrivateKeyJWTSigningAlgorithm(signingAlgorithm),
audience: clientAssertionAudience(from: tokenEndpoint)
)
}
)
)
case "auth/basic-cimd":
configuration = .init(
grantType: .authorizationCode,
authentication: .none(
clientID: context["client_id"]
?? "https://conformance-test.local/client-metadata.json")
)
case "auth/cross-app-access-complete-flow":
configuration = .init(
authentication: .clientSecretBasic(
clientID: clientID,
clientSecret: clientSecret
),
accessTokenProvider: makeCrossAppAccessTokenProvider(context: context)
)
case let s where s.hasPrefix("auth/client-credentials"):
configuration = .init(
authentication: .none(clientID: clientID)
)
default:
configuration = .init(
grantType: .authorizationCode,
authentication: .none(clientID: clientID)
)
}
// Conformance harness currently uses loopback http AS endpoints.
configuration.allowLoopbackHTTPAuthorizationServerEndpoints = true
return configuration
}
private struct ConformanceTokenResponse: Decodable {
let accessToken: String
enum CodingKeys: String, CodingKey {
case accessToken = "access_token"
}
}
private func requestOAuthToken(
url: URL,
parameters: [String: String],
authorizationHeader: String?,
session: URLSession
) async throws -> String {
var request = URLRequest(url: url)
request.httpMethod = "POST"
request.setValue("application/x-www-form-urlencoded", forHTTPHeaderField: "Content-Type")
request.setValue("application/json", forHTTPHeaderField: "Accept")
if let authorizationHeader {
request.setValue(authorizationHeader, forHTTPHeaderField: "Authorization")
}
request.httpBody = formURLEncodedBody(parameters)
let (data, response) = try await session.data(for: request)
guard let httpResponse = response as? HTTPURLResponse else {
throw ConformanceError.invalidArguments("Token endpoint returned an invalid response")
}
guard (200..<300).contains(httpResponse.statusCode) else {
let body = String(data: data, encoding: .utf8) ?? "<non-utf8 body>"
throw ConformanceError.invalidArguments(
"Token endpoint error (\(httpResponse.statusCode)): \(body)"
)
}
let token = try JSONDecoder().decode(ConformanceTokenResponse.self, from: data)
guard !token.accessToken.isEmpty else {
throw ConformanceError.invalidArguments("Token endpoint returned an empty access token")
}
return token.accessToken
}
private func makeCrossAppAccessTokenProvider(
context: [String: String]
) -> OAuthConfiguration.AccessTokenProvider {
return { discovery, session in
guard let clientID = context["client_id"],
let clientSecret = context["client_secret"],
let idpClientID = context["idp_client_id"],
let idpIDToken = context["idp_id_token"],
let idpTokenEndpointValue = context["idp_token_endpoint"],
let idpTokenEndpoint = URL(string: idpTokenEndpointValue)
else {
throw ConformanceError.invalidArguments(
"Cross-app scenario requires client_id, client_secret, idp_client_id, idp_id_token, and idp_token_endpoint"
)
}
guard let authorizationServer = discovery.authorizationServer else {
throw ConformanceError.invalidArguments(
"SDK did not provide authorization server discovery context"
)
}
guard let tokenEndpoint = discovery.tokenEndpoint else {
throw ConformanceError.invalidArguments(
"SDK did not provide token endpoint discovery context"
)
}
let resource = discovery.resource.absoluteString
let idJag = try await requestOAuthToken(
url: idpTokenEndpoint,
parameters: [
"grant_type": "urn:ietf:params:oauth:grant-type:token-exchange",
"subject_token": idpIDToken,
"subject_token_type": "urn:ietf:params:oauth:token-type:id_token",
"requested_token_type": "urn:ietf:params:oauth:token-type:id-jag",
"audience": authorizationServer.absoluteString,
"resource": resource,
"client_id": idpClientID,
],
authorizationHeader: nil,
session: session
)
var accessTokenParameters: [String: String] = [
"grant_type": "urn:ietf:params:oauth:grant-type:jwt-bearer",
"assertion": idJag,
"resource": resource,
]
if let requestedScopes = discovery.requestedScopes, !requestedScopes.isEmpty {
accessTokenParameters["scope"] = requestedScopes.sorted().joined(separator: " ")
}
let basicCredentials = Data("\(clientID):\(clientSecret)".utf8).base64EncodedString()
let accessToken = try await requestOAuthToken(
url: tokenEndpoint,
parameters: accessTokenParameters,
authorizationHeader: "Basic \(basicCredentials)",
session: session
)
return accessToken
}
}
func runAuthorizationScenario(scenario: String, args: [String]) async throws {
var logger = Logger(
label: "mcp.conformance.client.auth",
factory: { StreamLogHandler.standardError(label: $0) }
)
logger.logLevel = .debug
logger.debug("Starting auth scenario", metadata: ["scenario": "\(scenario)"])
guard let serverURLString = args.last,
let serverURL = URL(string: serverURLString)
else {
throw ConformanceError.invalidArguments("Valid server URL is required")
}
let context = loadConformanceContext()
let oauthConfig = makeOAuthConfiguration(for: scenario, context: context)
let transport = HTTPClientTransport(
endpoint: serverURL,
streaming: true,
authorizer: OAuthAuthorizer(configuration: oauthConfig),
logger: logger
)
let client = Client(name: "test-client", version: "1.0.0")
// Scenarios that expect the connection to fail with a specific error.
if scenario == "auth/resource-mismatch" {
do {
_ = try await client.connect(transport: transport)
throw ConformanceError.invalidArguments(
"Expected authorization to fail with resource mismatch, but connection succeeded"
)
} catch let error as MCPError {
guard case .internalError(let detail) = error,
detail?.contains("resource mismatch") == true
else {
throw ConformanceError.invalidArguments(
"Connection failed, but not due to resource mismatch: \(error.localizedDescription)"
)
}
logger.debug("Client correctly rejected mismatched PRM resource")
}
return
}
_ = try await client.connect(transport: transport)
// Exercise both initialization and regular request paths.
let (tools, _) = try await client.listTools()
logger.debug("Auth scenario listed tools", metadata: ["count": "\(tools.count)"])
// Trigger an additional request for scenarios that involve runtime scope behavior.
if scenario.contains("scope"), let firstTool = tools.first {
_ = try? await client.callTool(name: firstTool.name, arguments: [:])
}
await client.disconnect()
logger.debug("Auth scenario completed", metadata: ["scenario": "\(scenario)"])
}
// MARK: - Basic Scenarios
/// Basic client that connects, initializes, and lists tools
@ -315,7 +664,6 @@ nonisolated(unsafe) let scenarioHandlers: [String: ScenarioHandler] = [
"tools_call": runToolsCallScenario,
"sse-retry": runSSEScenario,
"elicitation-sep1034-client-defaults": runElicitationSEP1034ClientDefaults,
// Note: Other scenarios (auth/*) will use the default handler
]
// MARK: - Error Types
@ -353,11 +701,16 @@ struct ConformanceClient {
Foundation.exit(1)
}
// Get handler for scenario, or use default if not implemented
let handler = scenarioHandlers[scenario] ?? runDefaultScenario
// Log if using default handler
if scenarioHandlers[scenario] == nil {
// Get handler for scenario
let handler: ScenarioHandler
if let explicitHandler = scenarioHandlers[scenario] {
handler = explicitHandler
} else if scenario.hasPrefix("auth/") {
handler = { args in
try await runAuthorizationScenario(scenario: scenario, args: args)
}
} else {
handler = runDefaultScenario
var stderr = StandardError()
print("⚠️ Scenario '\(scenario)' not fully implemented - using default handler", to: &stderr)
}