V0.12.x (#215)
* 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:
parent
973b46b8f7
commit
6132fd4b5b
1 changed files with 10925 additions and 199 deletions
|
|
@ -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)
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue