mirror of
https://github.com/abue-ammar/tinycast.git
synced 2026-10-02 00:04:44 +08:00
Add OAuth authentication for HTTP MCP servers (#1077)
* Add OAuth authentication for HTTP MCP servers * Escape a plus sign in the OAuth authorization URL * Drop an issuer's terminating slash before inserting its well-known path * Keep the issuer comment under the 100-character cap * Keep a saved server signed in when Test Connection tries an edited URL * Let a token refresh finish when the server editor closes or saves * Say why an MCP server was kept when Remove cannot delete its credentials * Cover supplied OAuth client credentials in the harness
This commit is contained in:
@@ -541,6 +541,17 @@ run apple-intelligence-test Tinycast/Features/Settings/AppSettingsKey.swift \
|
||||
Tinycast/Features/AI/Model/*.swift \
|
||||
Tinycast/Features/AI/Service/AIProvider.swift \
|
||||
Tinycast/Features/AI/Service/AppleIntelligenceProvider.swift
|
||||
run mcp-oauth-test Tinycast/Platform/ExecutableLocator.swift \
|
||||
Tinycast/Platform/KeychainSecretStore.swift \
|
||||
Tinycast/Features/Settings/AppSettingsKey.swift \
|
||||
Tinycast/Features/AI/Model/AIConnection.swift \
|
||||
Tinycast/Features/AI/Model/AppleIntelligence.swift \
|
||||
Tinycast/Features/AI/Model/AITool.swift \
|
||||
Tinycast/Features/AI/Model/AIStreamDecoder.swift \
|
||||
Tinycast/Features/AI/Model/AIRequest.swift \
|
||||
Tinycast/Features/AI/Model/JSONValue.swift \
|
||||
Tinycast/Features/MCP/Model/*.swift \
|
||||
Tinycast/Features/MCP/Service/*.swift
|
||||
run slow mcp-stdio-test Tinycast/Platform/ExecutableLocator.swift \
|
||||
Tinycast/Platform/KeychainSecretStore.swift \
|
||||
Tinycast/Features/Settings/AppSettingsKey.swift \
|
||||
|
||||
@@ -0,0 +1,89 @@
|
||||
const http = require('node:http');
|
||||
const base = 'http://127.0.0.1:4963';
|
||||
let refreshes = 0;
|
||||
let calls = 0;
|
||||
let redirects = 0;
|
||||
let held = [];
|
||||
let registrations = 0;
|
||||
const supplied = 'supplied+client';
|
||||
const clientAuth = [];
|
||||
const issued = { access_token: 'fixture-access', token_type: 'Bearer', refresh_token: 'rotated-refresh', expires_in: 3600 };
|
||||
const server = http.createServer(async (req, res) => {
|
||||
let raw = '';
|
||||
for await (const chunk of req) raw += chunk;
|
||||
const send = (status, body, headers = {}) => {
|
||||
res.writeHead(status, { 'Content-Type': 'application/json', ...headers });
|
||||
res.end(JSON.stringify(body));
|
||||
};
|
||||
const metadata = {
|
||||
issuer: base, authorization_endpoint: base + '/authorize', token_endpoint: base + '/token',
|
||||
registration_endpoint: base + '/register', code_challenge_methods_supported: ['S256'],
|
||||
token_endpoint_auth_methods_supported: ['none'], authorization_response_iss_parameter_supported: true,
|
||||
};
|
||||
if (req.url === '/.well-known/oauth-protected-resource/mcp') {
|
||||
return send(200, { resource: base + '/mcp', authorization_servers: [base], scopes_supported: ['read'] });
|
||||
}
|
||||
if (req.url === '/.well-known/oauth-authorization-server') return send(200, metadata);
|
||||
if (req.url === '/register') {
|
||||
registrations++;
|
||||
const body = JSON.parse(raw);
|
||||
if (body.application_type !== 'native' || body.token_endpoint_auth_method !== 'none' ||
|
||||
!body.grant_types.includes('refresh_token') || body.redirect_uris[0] !== 'http://127.0.0.1:4962/callback') {
|
||||
return send(400, {});
|
||||
}
|
||||
return send(201, { client_id: 'fixture-client', token_endpoint_auth_method: 'none', redirect_uris: body.redirect_uris });
|
||||
}
|
||||
if (req.url === '/token') {
|
||||
const fields = new URLSearchParams(raw);
|
||||
const client = fields.get('client_id');
|
||||
if (client === supplied) {
|
||||
const basic = (req.headers.authorization || '').replace(/^Basic /, '');
|
||||
const used = [basic && 'basic ' + Buffer.from(basic, 'base64').toString(),
|
||||
fields.has('client_secret') && 'post ' + fields.get('client_secret')].filter(Boolean);
|
||||
clientAuth.push(used.join(' + ') || 'none');
|
||||
} else if (client !== 'fixture-client') return send(400, {});
|
||||
if (fields.get('resource') !== base + '/mcp') return send(400, {});
|
||||
if (fields.get('grant_type') === 'refresh_token') {
|
||||
if (fields.get('refresh_token') === 'fixture-unavailable') return send(503, {});
|
||||
if (fields.get('refresh_token') === 'fixture-dropped') return req.socket.destroy();
|
||||
if (fields.get('refresh_token') === 'fixture-held') return held.push(() => send(200, issued));
|
||||
refreshes++;
|
||||
if (fields.get('refresh_token') !== 'fixture-refresh') return send(400, {error: 'invalid_grant'});
|
||||
} else if (fields.get('code') !== 'fixture-code' || !fields.get('code_verifier')) return send(400, {});
|
||||
return send(200, issued);
|
||||
}
|
||||
if (req.url === '/redirect') {
|
||||
res.writeHead(307, { Location: base + '/unexpected' });
|
||||
return res.end();
|
||||
}
|
||||
if (req.url === '/mcp-moved') {
|
||||
res.writeHead(307, { Location: base + '/mcp' });
|
||||
return res.end();
|
||||
}
|
||||
if (req.url === '/mcp-away') {
|
||||
res.writeHead(307, { Location: 'http://localhost:4963/unexpected' });
|
||||
return res.end();
|
||||
}
|
||||
if (req.url === '/unexpected') { redirects++; return send(200, {}); }
|
||||
if (req.url === '/release') { held.splice(0).forEach(reply => reply()); return send(200, {}); }
|
||||
if (req.url === '/counts') return send(200, { refreshes, calls, redirects, held: held.length, registrations });
|
||||
if (req.url === '/client-auth') return send(200, clientAuth);
|
||||
if (req.url === '/always-401') { calls++; return send(401, {}); }
|
||||
if (req.url === '/mcp') {
|
||||
calls++;
|
||||
if (req.headers.authorization !== 'Bearer fixture-access') {
|
||||
return send(401, {}, { 'WWW-Authenticate': `Bearer resource_metadata="${base}/.well-known/oauth-protected-resource/mcp", scope="read"` });
|
||||
}
|
||||
const request = JSON.parse(raw);
|
||||
if (!request.id) { res.writeHead(202); return res.end(); }
|
||||
const result = request.method === 'initialize' ? {
|
||||
protocolVersion: '2025-03-26', capabilities: { tools: {} }, serverInfo: { name: 'oauth-fixture', version: '1' }
|
||||
} : request.method === 'tools/list' ? {
|
||||
tools: [{name: 'greet', description: 'Say hello', inputSchema: {type: 'object', properties: {}}}]
|
||||
} : { content: [{type: 'text', text: 'Hello from OAuth'}] };
|
||||
return send(200, { jsonrpc: '2.0', id: request.id, result });
|
||||
}
|
||||
send(404, {});
|
||||
});
|
||||
server.on('error', error => { console.error(error.code); process.exit(1); });
|
||||
server.listen(4963, '127.0.0.1', () => console.log('ready'));
|
||||
@@ -0,0 +1,443 @@
|
||||
import CryptoKit
|
||||
import Foundation
|
||||
|
||||
@main
|
||||
@MainActor
|
||||
struct MCPOAuthTests {
|
||||
static var passes = 0
|
||||
static var failures = 0
|
||||
static let base = "http://127.0.0.1:4963"
|
||||
|
||||
static func expect(_ condition: @autoclosure () throws -> Bool, _ message: String) {
|
||||
if (try? condition()) == true { passes += 1 } else { failures += 1; print("FAIL: \(message)") }
|
||||
}
|
||||
|
||||
static func rejects(_ message: String, _ operation: () throws -> Void) {
|
||||
do {
|
||||
try operation()
|
||||
expect(false, message)
|
||||
} catch {
|
||||
expect(true, message)
|
||||
}
|
||||
}
|
||||
|
||||
static func main() async {
|
||||
do {
|
||||
try pureRules()
|
||||
try await listenerLifecycle()
|
||||
try await networkFlow()
|
||||
} catch { expect(false, "unexpected failure: \(error)") }
|
||||
print("\(passes) passed, \(failures) failed")
|
||||
if failures > 0 { exit(1) }
|
||||
}
|
||||
|
||||
static func pureRules() throws {
|
||||
let entropy = Data(base64Encoded: "dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk="
|
||||
.replacingOccurrences(of: "-", with: "+").replacingOccurrences(of: "_", with: "/")) ?? Data()
|
||||
let pkce = MCPOAuth.pkce(entropy: entropy) { Data(SHA256.hash(data: $0)) }
|
||||
expect(pkce.verifier == "dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk", "RFC 7636 verifier")
|
||||
expect(pkce.challenge == "E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM", "RFC 7636 S256 vector")
|
||||
expect(try MCPOAuth.resource("https://example.com/") == "https://example.com", "canonical root")
|
||||
for value in ["http://example.com/mcp", "file:///secret", "https://user:pass@example.com/mcp", "https://example.com/#x"] {
|
||||
rejects("reject unsafe endpoint") { _ = try MCPOAuth.endpoint(value) }
|
||||
}
|
||||
let header = "Basic realm=\"other\", Bearer resource_metadata=\"https://example.com/meta?a=1,b=2\", scope=\"read write\""
|
||||
let challenge = MCPOAuthChallenge.parse(header)
|
||||
expect(challenge["resource_metadata"] == "https://example.com/meta?a=1,b=2", "quoted comma survives")
|
||||
expect(challenge["scope"] == "read write", "bearer scopes")
|
||||
expect(MCPOAuthChallenge.parse("Bearer scope=\"a\", scope=\"b\"").isEmpty, "duplicate challenge refused")
|
||||
expect(MCPOAuthChallenge.parse("Bearer scope=\"unterminated").isEmpty, "bad quotes refused")
|
||||
let urls = try MCPOAuth.protectedMetadataURLs(resource: "https://example.com/team/mcp", challenge: [:])
|
||||
expect(urls.map(\.absoluteString) == ["https://example.com/.well-known/oauth-protected-resource/team/mcp",
|
||||
"https://example.com/.well-known/oauth-protected-resource"], "path before root discovery")
|
||||
let issuers = try MCPOAuth.serverMetadataURLs(issuer: "https://example.com/tenant")
|
||||
expect(issuers.map(\.absoluteString) == ["https://example.com/.well-known/oauth-authorization-server/tenant",
|
||||
"https://example.com/.well-known/openid-configuration/tenant",
|
||||
"https://example.com/tenant/.well-known/openid-configuration"], "all issuer discovery locations")
|
||||
expect(try MCPOAuth.serverMetadataURLs(issuer: "https://example.com/tenant/") == issuers,
|
||||
"an issuer's terminating slash is dropped before the well-known path goes in")
|
||||
expect(try MCPOAuth.protectedMetadataURLs(resource: "https://example.com/team/mcp/", challenge: [:]) == urls,
|
||||
"a resource's terminating slash is dropped the same way")
|
||||
let metadataJSON = #"{"issuer":"https://auth.test","authorization_endpoint":"https://auth.test/authorize","#
|
||||
+ #""token_endpoint":"https://auth.test/token","code_challenge_methods_supported":["S256"]}"#
|
||||
let metadata = try MCPOAuth.parseServer(Data(metadataJSON.utf8), issuer: "https://auth.test")
|
||||
let origin = URL(string: "https://example.com/mcp")!
|
||||
expect(MCPOAuth.sameOrigin(origin, URL(string: "HTTPS://Example.com:443/mcp/")!),
|
||||
"case and the default port do not change an origin")
|
||||
for other in ["https://example.com:8443/mcp", "https://evil.example.com/mcp", "http://example.com/mcp"] {
|
||||
expect(!MCPOAuth.sameOrigin(origin, URL(string: other)!), "\(other) is another origin")
|
||||
}
|
||||
expect([400, 401].allSatisfy { MCPOAuth.tokenFailure(status: $0) == .signInRequired },
|
||||
"a rejected grant requires sign-in")
|
||||
expect([403, 429, 500, 503].allSatisfy { MCPOAuth.tokenFailure(status: $0) == .network },
|
||||
"a server fault is not a rejected grant")
|
||||
let slashed = try MCPOAuth.parseServer(Data(metadataJSON.utf8), issuer: "https://auth.test/")
|
||||
expect(slashed.issuer == "https://auth.test", "a trailing slash is the same issuer, and the server's own spelling is kept")
|
||||
rejects("another issuer is refused") { _ = try MCPOAuth.parseServer(Data(metadataJSON.utf8), issuer: "https://auth.test/tenant") }
|
||||
rejects("S256 required") {
|
||||
_ = try MCPOAuth.parseServer(Data(metadataJSON.replacingOccurrences(of: "S256", with: "plain").utf8),
|
||||
issuer: "https://auth.test")
|
||||
}
|
||||
rejects("resource must match requested server") {
|
||||
_ = try MCPOAuth.parseResource(Data(#"{"resource":"https://other.test","authorization_servers":["https://auth.test"]}"#.utf8),
|
||||
expected: "https://example.com")
|
||||
}
|
||||
expect(try MCPOAuth.resourceCovers("https://example.com", endpoint: "https://example.com/mcp"),
|
||||
"root resource can describe its MCP endpoint")
|
||||
expect(try !MCPOAuth.resourceCovers("https://example.com/team", endpoint: "https://example.com/teammate"),
|
||||
"path prefix cannot cross a tenant boundary")
|
||||
expect(try !MCPOAuth.resourceCovers("https://example.com/team", endpoint: "https://other.com/team"),
|
||||
"resource cannot describe another origin")
|
||||
let registration = MCPOAuth.Registration(resource: "https://example.com/mcp", issuer: "https://auth.test",
|
||||
clientID: "test+client", clientSecret: nil, tokenEndpoint: "https://auth.test/token", authMethod: "none",
|
||||
redirectURI: MCPOAuthListener.redirectURI)
|
||||
let url = try MCPOAuth.authorizeURL(metadata: metadata, registration: registration, challenge: pkce.challenge,
|
||||
state: "state", scope: "read write")
|
||||
let query = URLComponents(url: url, resolvingAgainstBaseURL: false)?.queryItems ?? []
|
||||
expect(query.contains(URLQueryItem(name: "resource", value: registration.resource)), "authorize resource")
|
||||
expect(query.contains(URLQueryItem(name: "code_challenge_method", value: "S256")), "authorize S256")
|
||||
expect(query.contains(URLQueryItem(name: "client_id", value: "test+client"))
|
||||
&& url.absoluteString.contains("client_id=test%2Bclient") && url.absoluteString.contains("scope=read%20write"),
|
||||
"a plus in the authorization URL is escaped, so the server cannot read it as a space")
|
||||
let tenantJSON = metadataJSON.replacingOccurrences(
|
||||
of: "auth.test/authorize", with: "auth.test/authorize?tenant=a%2Bb&client_id=spoofed")
|
||||
let tenant = try MCPOAuth.authorizeURL(
|
||||
metadata: MCPOAuth.parseServer(Data(tenantJSON.utf8), issuer: "https://auth.test"),
|
||||
registration: registration, challenge: pkce.challenge, state: "state", scope: nil)
|
||||
expect(tenant.absoluteString.contains("?tenant=a%2Bb&client_id=test%2Bclient&"),
|
||||
"the endpoint's own query keeps its encoding and loses the fields it may not set")
|
||||
expect(String(bytes: MCPOAuth.form(["a+b": "&= +"]), encoding: .utf8) == "a%2Bb=%26%3D%20%2B", "form encoding")
|
||||
let good = "/callback?code=hello%2Bworld&state=state&iss=https%3A%2F%2Fauth.test"
|
||||
expect(try MCPOAuth.callback(good, state: "state", issuer: "https://auth.test", requiresIssuer: true) == "hello+world",
|
||||
"callback decodes code")
|
||||
for bad in [good + "&state=state", good.replacingOccurrences(of: "state=state", with: "state=wrong"),
|
||||
good.replacingOccurrences(of: "auth.test", with: "attacker.test"), "/callback?code=c&state=state",
|
||||
"/other?code=c&state=state"] {
|
||||
rejects("invalid callback refused") {
|
||||
_ = try MCPOAuth.callback(bad, state: "state", issuer: "https://auth.test", requiresIssuer: true)
|
||||
}
|
||||
}
|
||||
let now = Date(timeIntervalSince1970: 1_000)
|
||||
let token = try MCPOAuth.parseToken(
|
||||
Data(#"{"access_token":"a","token_type":"Bearer","refresh_token":"r","expires_in":3600}"#.utf8),
|
||||
previous: nil, now: now)
|
||||
expect(!token.needsRefresh(now: now.addingTimeInterval(3539)), "refresh not premature")
|
||||
expect(token.needsRefresh(now: now.addingTimeInterval(3540)), "refresh at skew boundary")
|
||||
let rotated = try MCPOAuth.parseToken(Data(#"{"access_token":"b","token_type":"bearer","refresh_token":"r2"}"#.utf8),
|
||||
previous: token, now: now)
|
||||
expect(rotated.refreshToken == "r2", "refresh rotates")
|
||||
let preserved = try MCPOAuth.parseToken(
|
||||
Data(#"{"access_token":"b","token_type":"Bearer"}"#.utf8), previous: token, now: now)
|
||||
expect(preserved.refreshToken == "r" && preserved.expiresAt == nil, "omitted refresh retained, expiry not invented")
|
||||
for bad in [#"{"access_token":"a","token_type":"MAC"}"#, #"{"access_token":"a\r\nx","token_type":"Bearer"}"#,
|
||||
#"{"access_token":"a","token_type":"Bearer","expires_in":-1}"#] {
|
||||
rejects("invalid token refused") { _ = try MCPOAuth.parseToken(Data(bad.utf8), previous: nil, now: now) }
|
||||
}
|
||||
var old = MCPServer(name: "Old")
|
||||
let encoded = try JSONEncoder().encode(old)
|
||||
expect(try JSONDecoder().decode(MCPServer.self, from: encoded).oauth == nil, "old server shape decodes")
|
||||
old.oauth = true
|
||||
expect(try JSONDecoder().decode(MCPServer.self, from: JSONEncoder().encode(old)).oauth == true, "OAuth mode persists")
|
||||
let oldSecrets = try JSONDecoder().decode(
|
||||
MCPSecretStore.Secrets.self, from: Data(#"{"headerValue":"old","environment":{}}"#.utf8))
|
||||
expect(oldSecrets.headerValue == "old" && oldSecrets.oauth == nil, "old secrets decode")
|
||||
}
|
||||
|
||||
static func listenerLifecycle() async throws {
|
||||
let listener = MCPOAuthListener()
|
||||
try await listener.start(state: "expected", issuer: "https://auth.test", requiresIssuer: true, timeout: .seconds(10))
|
||||
let competing = MCPOAuthListener()
|
||||
do {
|
||||
try await competing.start(state: "other", issuer: "https://auth.test", requiresIssuer: true, timeout: .seconds(2))
|
||||
expect(false, "occupied port must fail")
|
||||
} catch { expect(true, "occupied port fails") }
|
||||
competing.cancel()
|
||||
let bad = URLRequest(url: URL(string: MCPOAuthListener.redirectURI + "?state=wrong&code=c")!)
|
||||
let (_, refused) = try await MCPOAuthHTTP.send(bad)
|
||||
expect(refused.statusCode == 400, "wrong state refused without consuming listener")
|
||||
let target = MCPOAuthListener.redirectURI + "?state=expected&code=fixture-code&iss=https%3A%2F%2Fauth.test"
|
||||
let (_, accepted) = try await MCPOAuthHTTP.send(URLRequest(url: URL(string: target)!))
|
||||
expect(accepted.statusCode == 200, "browser receives close-tab page")
|
||||
let code = try await listener.code()
|
||||
expect(code == "fixture-code", "callback yields code once")
|
||||
let expiring = MCPOAuthListener()
|
||||
try await expiring.start(state: "expected", issuer: "https://auth.test", requiresIssuer: false, timeout: .milliseconds(80))
|
||||
do {
|
||||
_ = try await expiring.code()
|
||||
expect(false, "timeout required")
|
||||
} catch {
|
||||
expect(error as? MCPOAuth.Failure == .timedOut, "timeout tears down listener")
|
||||
}
|
||||
let cancelled = MCPOAuthListener()
|
||||
try await cancelled.start(state: "expected", issuer: "https://auth.test", requiresIssuer: false)
|
||||
cancelled.cancel()
|
||||
do {
|
||||
_ = try await cancelled.code()
|
||||
expect(false, "cancellation required")
|
||||
} catch {
|
||||
expect(error is CancellationError, "cancel releases waiter")
|
||||
}
|
||||
}
|
||||
|
||||
/// A refresh the server could not serve is not a rejected grant: the session must survive it.
|
||||
static func transientRefresh(
|
||||
_ refresh: String, registration: MCPOAuth.Registration, secrets: MCPSecretStore,
|
||||
manager: MCPOAuthManager
|
||||
) async throws {
|
||||
var configured = MCPServer(
|
||||
name: refresh, transport: .http(url: base + "/mcp", headerName: ""))
|
||||
configured.oauth = true
|
||||
let server = configured
|
||||
defer { try? secrets.remove(for: server.id) }
|
||||
var stored = MCPSecretStore.Secrets()
|
||||
stored.oauth = MCPOAuth.Credentials(registration: registration,
|
||||
token: MCPOAuth.Token(accessToken: "expired", refreshToken: refresh,
|
||||
expiresAt: .distantPast, scope: "read"))
|
||||
try secrets.save(stored, for: server.id)
|
||||
do {
|
||||
_ = try await manager.accessToken(for: server)
|
||||
expect(false, "\(refresh): an unserved refresh must fail")
|
||||
} catch {
|
||||
expect(error as? MCPOAuth.Failure == .network, "\(refresh): a network failure")
|
||||
}
|
||||
expect(manager.status(for: server, stored: stored.oauth) == .signedIn,
|
||||
"\(refresh): session survives")
|
||||
stored.oauth?.token = MCPOAuth.Token(
|
||||
accessToken: "expired", refreshToken: "fixture-refresh",
|
||||
expiresAt: .distantPast, scope: "read")
|
||||
try secrets.save(stored, for: server.id)
|
||||
let recovered = try await manager.accessToken(for: server)
|
||||
expect(recovered == "fixture-access", "\(refresh): next refresh recovers")
|
||||
}
|
||||
|
||||
/// A rotating server may already have spent the old refresh token; the new one must be kept.
|
||||
static func refreshOutlivesEditor(
|
||||
registration: MCPOAuth.Registration, secrets: MCPSecretStore, manager: MCPOAuthManager
|
||||
) async throws {
|
||||
var configured = MCPServer(name: "Held", transport: .http(url: base + "/mcp", headerName: ""))
|
||||
configured.oauth = true
|
||||
let server = configured
|
||||
defer { try? secrets.remove(for: server.id) }
|
||||
var stored = MCPSecretStore.Secrets()
|
||||
stored.oauth = MCPOAuth.Credentials(registration: registration,
|
||||
token: MCPOAuth.Token(accessToken: "expired", refreshToken: "fixture-held",
|
||||
expiresAt: .distantPast, scope: "read"))
|
||||
try secrets.save(stored, for: server.id)
|
||||
async let refreshed = manager.accessToken(for: server)
|
||||
var polls = 0
|
||||
while try await count("held") == 0, polls < 200 {
|
||||
polls += 1
|
||||
try await Task.sleep(for: .milliseconds(10))
|
||||
}
|
||||
manager.cancelSignIn(server.id)
|
||||
_ = try await MCPOAuthHTTP.json(URL(string: base + "/release")!)
|
||||
let token: String?
|
||||
do { token = try await refreshed } catch { token = nil }
|
||||
expect(polls < 200 && token == "fixture-access"
|
||||
&& secrets.secrets(for: server.id).oauth?.token?.refreshToken == "rotated-refresh",
|
||||
"closing or saving the editor lets a refresh in flight finish and keep its rotated token")
|
||||
try secrets.save(stored, for: server.id)
|
||||
async let abandoned = manager.accessToken(for: server)
|
||||
polls = 0
|
||||
while try await count("held") == 0, polls < 200 {
|
||||
polls += 1
|
||||
try await Task.sleep(for: .milliseconds(10))
|
||||
}
|
||||
try manager.signOut(server.id)
|
||||
_ = try await MCPOAuthHTTP.json(URL(string: base + "/release")!)
|
||||
let late: String?
|
||||
do { late = try await abandoned } catch { late = nil }
|
||||
expect(polls < 200 && late == nil && secrets.secrets(for: server.id).oauth?.token == nil,
|
||||
"sign-out still discards a refresh in flight")
|
||||
}
|
||||
|
||||
static func count(_ name: String) async throws -> Int {
|
||||
let data = try await MCPOAuthHTTP.json(URL(string: base + "/counts")!)
|
||||
return (try JSONSerialization.jsonObject(with: data) as? [String: Int])?[name] ?? 0
|
||||
}
|
||||
|
||||
/// A supplied client ID wins over registration and authenticates the way the server advertises.
|
||||
static func suppliedClient(_ discovered: MCPOAuthService.Discovery) async throws {
|
||||
let registered = try await count("registrations")
|
||||
func advertising(_ methods: String?) throws -> MCPOAuthService.Discovery {
|
||||
let field = methods.map { #","token_endpoint_auth_methods_supported":\#($0)"# } ?? ""
|
||||
let json = #"{"issuer":"\#(base)","authorization_endpoint":"\#(base)/authorize","#
|
||||
+ #""token_endpoint":"\#(base)/token","registration_endpoint":"\#(base)/register","#
|
||||
+ #""code_challenge_methods_supported":["S256"]\#(field)}"#
|
||||
return MCPOAuthService.Discovery(
|
||||
resource: discovered.resource, metadata: try MCPOAuth.parseServer(Data(json.utf8), issuer: base),
|
||||
scope: nil)
|
||||
}
|
||||
let pasted = MCPOAuth.Credentials.supplied(clientID: " supplied+client\n", clientSecret: "\ts3cr:t/= \n")
|
||||
expect(pasted.clientID == "supplied+client" && pasted.clientSecret == "s3cr:t/=",
|
||||
"a pasted client ID and secret lose the whitespace a paste brings")
|
||||
let publicClient = MCPOAuth.Credentials.supplied(clientID: "supplied+client", clientSecret: "")
|
||||
let none = try await MCPOAuthService.registration(for: discovered, credentials: publicClient)
|
||||
expect(none.clientID == "supplied+client" && none.clientSecret == nil && none.authMethod == "none",
|
||||
"a supplied client ID takes precedence over dynamic registration")
|
||||
let both = try advertising(#"["client_secret_post","client_secret_basic"]"#)
|
||||
let basic = try await MCPOAuthService.registration(for: both, credentials: pasted)
|
||||
let post = try await MCPOAuthService.registration(
|
||||
for: advertising(#"["none","client_secret_post"]"#), credentials: pasted)
|
||||
let unstated = try await MCPOAuthService.registration(for: advertising(nil), credentials: pasted)
|
||||
expect(basic.authMethod == "client_secret_basic" && post.authMethod == "client_secret_post"
|
||||
&& unstated.authMethod == "client_secret_basic",
|
||||
"a secret goes as Basic when advertised or unstated, and in the body when only post is")
|
||||
do {
|
||||
_ = try await MCPOAuthService.registration(for: advertising(#"["none"]"#), credentials: pasted)
|
||||
expect(false, "a secret the token endpoint cannot take must be refused")
|
||||
} catch {
|
||||
expect(error as? MCPOAuth.Failure == .invalidMetadata, "a secret the token endpoint cannot take is refused")
|
||||
}
|
||||
for registration in [none, basic, post] {
|
||||
let token = try await MCPOAuthService.token(
|
||||
registration: registration, code: "fixture-code", verifier: "fixture-verifier")
|
||||
expect(token.accessToken == "fixture-access", "\(registration.authMethod): the code exchange succeeds")
|
||||
}
|
||||
let recorded = try await MCPOAuthHTTP.json(URL(string: base + "/client-auth")!)
|
||||
let authentication = try JSONSerialization.jsonObject(with: recorded) as? [String]
|
||||
expect(authentication == ["none", "basic supplied%2Bclient:s3cr%3At%2F%3D", "post s3cr:t/="],
|
||||
"the token endpoint sees no secret, a form-encoded Basic pair, or one body field")
|
||||
var stored = pasted
|
||||
stored.registration = MCPOAuth.Registration(
|
||||
resource: discovered.resource, issuer: base, clientID: pasted.clientID, clientSecret: pasted.clientSecret,
|
||||
tokenEndpoint: base + "/token", authMethod: "client_secret_post", redirectURI: MCPOAuthListener.redirectURI)
|
||||
let reused = try await MCPOAuthService.registration(for: both, credentials: stored)
|
||||
expect(reused == stored.registration, "a stored supplied registration is reused, not renegotiated")
|
||||
var resecreted = stored
|
||||
resecreted.clientSecret = "rotated"
|
||||
let renewed = try await MCPOAuthService.registration(for: both, credentials: resecreted)
|
||||
expect(renewed.clientSecret == "rotated" && renewed.authMethod == "client_secret_basic",
|
||||
"a changed secret is not answered with the stored registration")
|
||||
var moved = stored
|
||||
moved.registration = MCPOAuth.Registration(
|
||||
resource: discovered.resource, issuer: "https://old.test", clientID: pasted.clientID,
|
||||
clientSecret: pasted.clientSecret, tokenEndpoint: "https://old.test/token",
|
||||
authMethod: "client_secret_basic", redirectURI: MCPOAuthListener.redirectURI)
|
||||
do {
|
||||
_ = try await MCPOAuthService.registration(for: both, credentials: moved)
|
||||
expect(false, "a client ID from another issuer must be refused")
|
||||
} catch {
|
||||
expect(error as? MCPOAuth.Failure == .issuerChanged, "a client ID from another issuer is refused")
|
||||
}
|
||||
let registeredAfter = try await count("registrations")
|
||||
expect(registeredAfter == registered, "a supplied client ID never reaches the registration endpoint")
|
||||
}
|
||||
|
||||
static func networkFlow() async throws {
|
||||
let process = Process()
|
||||
process.executableURL = URL(fileURLWithPath: "/usr/bin/env")
|
||||
process.arguments = ["node", "Tests/ai-fixtures/mcp-oauth-stub.js"]
|
||||
let pipe = Pipe()
|
||||
process.standardOutput = pipe
|
||||
try process.run()
|
||||
defer { if process.isRunning { process.terminate(); process.waitUntilExit() } }
|
||||
let ready = pipe.fileHandleForReading.availableData
|
||||
guard String(bytes: ready, encoding: .utf8)?.contains("ready") == true else { throw MCPOAuth.Failure.network }
|
||||
let discovered = try await MCPOAuthService.discover(base + "/mcp")
|
||||
expect(discovered.scope == "read", "401 discovery carries requested scopes")
|
||||
let registration = try await MCPOAuthService.registration(for: discovered, credentials: MCPOAuth.Credentials())
|
||||
expect(registration.clientID == "fixture-client", "native DCR succeeded")
|
||||
try await suppliedClient(discovered)
|
||||
let token = try await MCPOAuthService.token(
|
||||
registration: registration, code: "fixture-code", verifier: "fixture-verifier")
|
||||
expect(token.accessToken == "fixture-access", "code exchange includes resource")
|
||||
let keychain = KeychainSecretStore(scope: "mcp-oauth-test-" + UUID().uuidString, bundleIdentifier: "test.tinycast")
|
||||
let secrets = MCPSecretStore(keychain: keychain)
|
||||
var configured = MCPServer(name: "Fixture", transport: .http(url: base + "/mcp", headerName: ""))
|
||||
configured.oauth = true
|
||||
let server = configured
|
||||
defer { try? secrets.remove(for: server.id) }
|
||||
var stored = MCPSecretStore.Secrets()
|
||||
stored.oauth = MCPOAuth.Credentials(registration: registration,
|
||||
token: MCPOAuth.Token(accessToken: "expired", refreshToken: "fixture-refresh",
|
||||
expiresAt: .distantPast, scope: "read"))
|
||||
try secrets.save(stored, for: server.id)
|
||||
let manager = MCPOAuthManager(secrets: secrets)
|
||||
async let first = manager.accessToken(for: server)
|
||||
async let second = manager.accessToken(for: server)
|
||||
let refreshed = try await (first, second)
|
||||
expect(refreshed.0 == "fixture-access" && refreshed.1 == "fixture-access", "concurrent refreshes coalesce")
|
||||
expect(secrets.secrets(for: server.id).oauth?.token?.refreshToken == "rotated-refresh", "rotated token persisted")
|
||||
let connection = MCPServerConnection(server: server, secrets: stored, oauth: manager)
|
||||
await connection.start()
|
||||
expect(connection.status.isReady && connection.tools.count == 1, "signed-in connection discovers tools")
|
||||
connection.stop()
|
||||
var sends = 0
|
||||
let transport = try MCPHTTPTransport(url: base + "/mcp", headerName: "", headerValue: "") { rejected in
|
||||
sends += 1
|
||||
return rejected == nil ? "expired" : "fixture-access"
|
||||
}
|
||||
try await transport.connect()
|
||||
let result = try await transport.request("tools/call", ["name": "greet", "arguments": [:]])
|
||||
expect(MCPToolOutput.flatten(result).0.contains("Hello from OAuth") && sends == 2,
|
||||
"401 refresh retries tool request once")
|
||||
transport.close()
|
||||
var attempts = 0
|
||||
let failing = try MCPHTTPTransport(url: base + "/always-401", headerName: "", headerValue: "") { _ in
|
||||
attempts += 1
|
||||
return "expired"
|
||||
}
|
||||
try await failing.connect()
|
||||
do {
|
||||
_ = try await failing.request("tools/list")
|
||||
expect(false, "second 401 must require sign-in")
|
||||
} catch {
|
||||
expect(error as? MCPOAuth.Failure == .signInRequired && attempts == 2, "401 retry bounded")
|
||||
}
|
||||
failing.close()
|
||||
let relocated = try MCPHTTPTransport(url: base + "/mcp-moved", headerName: "", headerValue: "") { _ in
|
||||
"fixture-access"
|
||||
}
|
||||
try await relocated.connect()
|
||||
let followed = try await relocated.request("tools/call", ["name": "greet", "arguments": [:]])
|
||||
expect(MCPToolOutput.flatten(followed).0.contains("Hello from OAuth"),
|
||||
"a same-origin redirect is followed with its credentials")
|
||||
relocated.close()
|
||||
let away = try MCPHTTPTransport(url: base + "/mcp-away", headerName: "", headerValue: "") { _ in
|
||||
"fixture-access"
|
||||
}
|
||||
try await away.connect()
|
||||
do {
|
||||
_ = try await away.request("tools/list")
|
||||
expect(false, "a cross-origin redirect must not be followed")
|
||||
} catch { expect(true, "a cross-origin redirect is refused") }
|
||||
away.close()
|
||||
var redirected = URLRequest(url: URL(string: base + "/redirect")!)
|
||||
redirected.setValue("Bearer fixture-access", forHTTPHeaderField: "Authorization")
|
||||
let (_, response) = try await MCPOAuthHTTP.send(redirected)
|
||||
expect(response.statusCode == 307, "authenticated redirects not followed")
|
||||
let counts = try await MCPOAuthHTTP.json(URL(string: base + "/counts")!)
|
||||
let object = try JSONSerialization.jsonObject(with: counts) as? [String: Int]
|
||||
expect(object?["refreshes"] == 1, "one token exchange for simultaneous refresh")
|
||||
expect(object?["redirects"] == 0, "redirect target never receives credentials")
|
||||
for refresh in ["fixture-unavailable", "fixture-dropped"] {
|
||||
try await transientRefresh(
|
||||
refresh, registration: registration, secrets: secrets, manager: manager)
|
||||
}
|
||||
try await refreshOutlivesEditor(registration: registration, secrets: secrets, manager: manager)
|
||||
var moved = server
|
||||
moved.transport = .http(url: base + "/other", headerName: "")
|
||||
do {
|
||||
_ = try await manager.accessToken(for: moved)
|
||||
expect(false, "retargeted server must not borrow token")
|
||||
} catch {
|
||||
expect(error as? MCPOAuth.Failure == .signInRequired, "resource binding survives URL edit")
|
||||
}
|
||||
let edited = MCPServerConnection(server: moved, secrets: stored, oauth: manager)
|
||||
await edited.start()
|
||||
let kept: String?
|
||||
do { kept = try await manager.accessToken(for: server) } catch { kept = nil }
|
||||
expect(edited.status == .signInRequired && kept == "fixture-access"
|
||||
&& manager.status(for: server, stored: secrets.secrets(for: server.id).oauth) == .signedIn,
|
||||
"testing an edited URL leaves the saved server signed in")
|
||||
try manager.signOut(server.id)
|
||||
expect(secrets.secrets(for: server.id).oauth?.token == nil, "sign-out deletes access and refresh tokens")
|
||||
do {
|
||||
_ = try await manager.accessToken(for: server)
|
||||
expect(false, "signed-out token unavailable")
|
||||
} catch {
|
||||
expect(error as? MCPOAuth.Failure == .signInRequired, "signed-out requests require sign-in")
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -23,6 +23,7 @@
|
||||
058EFDE8E2770FAE8B5EC45D /* ModalActionButtonStyle.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5E62A0BA3E85C8942167A310 /* ModalActionButtonStyle.swift */; };
|
||||
05A237890B4BD21EE91D8090 /* DictionaryService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 732AEAA3A5C5DE559B739295 /* DictionaryService.swift */; };
|
||||
05DD364ADC75E040E2C17AE1 /* MenuSearchQuery.swift in Sources */ = {isa = PBXBuildFile; fileRef = 631BCDD61FD48D31C3A7AE6D /* MenuSearchQuery.swift */; };
|
||||
06118397F59E4C9F6F2EAC3A /* MCPOAuthService.swift in Sources */ = {isa = PBXBuildFile; fileRef = A4618E5608C3E4F7D1E911F1 /* MCPOAuthService.swift */; };
|
||||
064C3281B4FABBBD5979F24A /* CustomWindowSize.swift in Sources */ = {isa = PBXBuildFile; fileRef = 18A864436CAD5C5F62946603 /* CustomWindowSize.swift */; };
|
||||
067A064C013F0F66208E7DB1 /* KeychainSecretStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = CC02192DE787DBE081C589B8 /* KeychainSecretStore.swift */; };
|
||||
06B25F0B7A2B2D887A9B62FC /* WindowLayoutDraft.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4D9C37A9079B2BE573136DFF /* WindowLayoutDraft.swift */; };
|
||||
@@ -73,6 +74,7 @@
|
||||
184F26705A2A2DB127CD90D6 /* AIToolLoopProvider.swift in Sources */ = {isa = PBXBuildFile; fileRef = 95E1C2AD9D1D83F90048FD8A /* AIToolLoopProvider.swift */; };
|
||||
18B3F78B6363E80149D4A646 /* ReleaseNotesView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6CC42C482CC8B87188F55F4 /* ReleaseNotesView.swift */; };
|
||||
19317D6C8A55E3F85BB7AE1F /* OnboardingState.swift in Sources */ = {isa = PBXBuildFile; fileRef = 02BCAB1F3B6930FE0B819F6D /* OnboardingState.swift */; };
|
||||
195465DB2A155EDD8C0809BA /* MCPOAuthChallenge.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3265CBD6C91FC67E3996AF8E /* MCPOAuthChallenge.swift */; };
|
||||
19F36636F27933FA8278642A /* AITool.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4FBE70702D22F894C6B1A790 /* AITool.swift */; };
|
||||
1A4283A863BE56A7BC43D9F7 /* ColorSwatch.swift in Sources */ = {isa = PBXBuildFile; fileRef = 89DBFE62B513EC161F194D0A /* ColorSwatch.swift */; };
|
||||
1A7F214D77D831C7A223631E /* QuickActionResultView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 31F2C83F4B985EA6CD03BC69 /* QuickActionResultView.swift */; };
|
||||
@@ -230,6 +232,7 @@
|
||||
54FC1A629F03AE0F234A01B2 /* ShortcutRecorderPopover.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5DADE9F769787D44F72A2071 /* ShortcutRecorderPopover.swift */; };
|
||||
552B7230E78D2C05793EC013 /* ExtensionNodeShims.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9F9A0A3867D1B8E1D3284471 /* ExtensionNodeShims.swift */; };
|
||||
555791AE6CDCF42ECA171B2C /* FavoritesStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = D43F1080FB443A1D0616790E /* FavoritesStore.swift */; };
|
||||
55C603F567C3C627865B6A0E /* MCPOAuthManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 370B1767182D5C53ADBEDA35 /* MCPOAuthManager.swift */; };
|
||||
55CDBFDB13B816FFFB6CC867 /* MCPTrustPolicy.swift in Sources */ = {isa = PBXBuildFile; fileRef = A724FAAD08B77AA54D080E45 /* MCPTrustPolicy.swift */; };
|
||||
564CB6D0052C9A7C0AF9C9BF /* InProcessInjection.swift in Sources */ = {isa = PBXBuildFile; fileRef = 33A73B92320ED5A4F802EE54 /* InProcessInjection.swift */; };
|
||||
5679661058689EC8FF1C5C11 /* PaletteFilterAction.swift in Sources */ = {isa = PBXBuildFile; fileRef = 621B09CB5CF15517D9FDAB5B /* PaletteFilterAction.swift */; };
|
||||
@@ -381,6 +384,7 @@
|
||||
9553DDEBD6FCC205069ED996 /* MenuPanel.swift in Sources */ = {isa = PBXBuildFile; fileRef = BF96A1566757811AD434C0F9 /* MenuPanel.swift */; };
|
||||
9608AFDEDC3B330E64F53437 /* ExtensionStoreClient.swift in Sources */ = {isa = PBXBuildFile; fileRef = 410684C9DEB3754962C20C9C /* ExtensionStoreClient.swift */; };
|
||||
96BC636877D935C5EB20BF7F /* SnippetsScreen.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6E06AA30E7BA44BC211B667F /* SnippetsScreen.swift */; };
|
||||
978FE09E53A632B1473DC5D3 /* MCPOAuthListener.swift in Sources */ = {isa = PBXBuildFile; fileRef = 98310BAA8713A12DF56F19D7 /* MCPOAuthListener.swift */; };
|
||||
97D71FF4C0D582828C6EC7F5 /* UpdateFailure.swift in Sources */ = {isa = PBXBuildFile; fileRef = 00B475B5929CC0478EFAC7D8 /* UpdateFailure.swift */; };
|
||||
97EB36833C6785F22D2AAA15 /* WindowSwitchSweep.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0F1A3A9CEDBEBC132FC4A552 /* WindowSwitchSweep.swift */; };
|
||||
97F6E58046553BDCEF5AD447 /* ExtensionOAuthSession.swift in Sources */ = {isa = PBXBuildFile; fileRef = DE7DEB147E5D718E00D67755 /* ExtensionOAuthSession.swift */; };
|
||||
@@ -496,6 +500,7 @@
|
||||
BFC17B1BD5D239E0CCD56DDF /* QuickActionFailure.swift in Sources */ = {isa = PBXBuildFile; fileRef = 53DBA322BA1F117530075F51 /* QuickActionFailure.swift */; };
|
||||
C05022DE7B77920ECFB177EE /* LauncherItemsTable.swift in Sources */ = {isa = PBXBuildFile; fileRef = 934A79BF0F9B66225E5C38CE /* LauncherItemsTable.swift */; };
|
||||
C0642512B709EF0517ABF4CB /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9C37CCD024267EB21876C7E9 /* AppDelegate.swift */; };
|
||||
C0D1D4D7D03F48E8AC7901CF /* MCPOAuthHTTP.swift in Sources */ = {isa = PBXBuildFile; fileRef = 10A05EA2484C7E9EC8B05A69 /* MCPOAuthHTTP.swift */; };
|
||||
C188A43ED700D78BF0832091 /* ExtensionRegistriesPanel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0CB8DC0BB8BFB2AC298B1DCF /* ExtensionRegistriesPanel.swift */; };
|
||||
C1FC3507BFC408DCD7F142D8 /* ExtensionCommandMetadataStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = BD4C0743DE901F5E248991B4 /* ExtensionCommandMetadataStore.swift */; };
|
||||
C215695FE671BC591DC99C2B /* DialogPanel.swift in Sources */ = {isa = PBXBuildFile; fileRef = BDB241B179A08518424F7C1B /* DialogPanel.swift */; };
|
||||
@@ -513,6 +518,7 @@
|
||||
C7111FA661D5C3CA0A5E7EF6 /* WindowLayoutDisplay.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1FB153A0D53D42771F1CA99 /* WindowLayoutDisplay.swift */; };
|
||||
C7468FBE50BAEC5084D4DB17 /* AIProvider.swift in Sources */ = {isa = PBXBuildFile; fileRef = 848E14DE51403235A82BC287 /* AIProvider.swift */; };
|
||||
C75868B7FBA96939089EDBA6 /* CustomWindowSizesSection.swift in Sources */ = {isa = PBXBuildFile; fileRef = F81A385A824D02C1466F8BF8 /* CustomWindowSizesSection.swift */; };
|
||||
C8D4DDD0DDA678429552E062 /* MCPOAuth.swift in Sources */ = {isa = PBXBuildFile; fileRef = 222F9EE432F608B5C907E7C2 /* MCPOAuth.swift */; };
|
||||
C9A7290682E9D0881713C009 /* ExtensionGridGeometry.swift in Sources */ = {isa = PBXBuildFile; fileRef = F85C82C66132C7723272E873 /* ExtensionGridGeometry.swift */; };
|
||||
CA6BA9C381351D0B2CADFB9D /* IconCache.swift in Sources */ = {isa = PBXBuildFile; fileRef = E5CBCCE63360E4590CF5884A /* IconCache.swift */; };
|
||||
CA9C4158D6102028733745F5 /* NoteMarkdownTypography.swift in Sources */ = {isa = PBXBuildFile; fileRef = 99EF603B1761C0EF33EAA15E /* NoteMarkdownTypography.swift */; };
|
||||
@@ -573,6 +579,7 @@
|
||||
DF45F095F4BE5E5BAF0A3DF8 /* ModifierTapMonitor.swift in Sources */ = {isa = PBXBuildFile; fileRef = 709C3F8952A500267E1D8242 /* ModifierTapMonitor.swift */; };
|
||||
DFB25A4CDD35071B745F2EF9 /* DoubleTapModifier.swift in Sources */ = {isa = PBXBuildFile; fileRef = 72F0F0DDA5FC1F6ED59E73E9 /* DoubleTapModifier.swift */; };
|
||||
DFED3F4C7B67FED3DBD016E5 /* FallbackActionsMenu.swift in Sources */ = {isa = PBXBuildFile; fileRef = E9AEDF156B61DD90C5ADA8FA /* FallbackActionsMenu.swift */; };
|
||||
E06AA23D307A8FBA62C950BA /* MCPOAuthRequest.swift in Sources */ = {isa = PBXBuildFile; fileRef = 19E96BEA3A5436619297675B /* MCPOAuthRequest.swift */; };
|
||||
E078243555477EA5F02C9119 /* MenuSearchTarget.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5DFF1EF5B5DA85F78C040BD1 /* MenuSearchTarget.swift */; };
|
||||
E0C0746AD3C7E2FED3C50374 /* WindowActionMemory.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5A1E7B96668721F12170CD72 /* WindowActionMemory.swift */; };
|
||||
E0C75F1DD4F879772C04F8CA /* WindowLayoutNumberField.swift in Sources */ = {isa = PBXBuildFile; fileRef = D245764DB29BFEEB47A024A1 /* WindowLayoutNumberField.swift */; };
|
||||
@@ -730,6 +737,7 @@
|
||||
0F5AFB680B55BBAFF2207F99 /* NotesCoordinator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NotesCoordinator.swift; sourceTree = "<group>"; };
|
||||
102B53830D247FAE19479BCA /* ThumbnailCache.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ThumbnailCache.swift; sourceTree = "<group>"; };
|
||||
106F9FEF3F0C36E0C5686DA7 /* AIRequest.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AIRequest.swift; sourceTree = "<group>"; };
|
||||
10A05EA2484C7E9EC8B05A69 /* MCPOAuthHTTP.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MCPOAuthHTTP.swift; sourceTree = "<group>"; };
|
||||
112551581ECF83B53D405334 /* BackupSettingsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BackupSettingsView.swift; sourceTree = "<group>"; };
|
||||
112FAE46D58E17331C1CEEAF /* WindowLayoutStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WindowLayoutStore.swift; sourceTree = "<group>"; };
|
||||
114F2E3148CA76595B795B37 /* PinnedEmojiStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PinnedEmojiStore.swift; sourceTree = "<group>"; };
|
||||
@@ -753,6 +761,7 @@
|
||||
18A864436CAD5C5F62946603 /* CustomWindowSize.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CustomWindowSize.swift; sourceTree = "<group>"; };
|
||||
197035E213BEBA67DDA3377B /* ClipboardManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ClipboardManager.swift; sourceTree = "<group>"; };
|
||||
198E4EC8AECAAD0D90C4BA50 /* ReleaseChannel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ReleaseChannel.swift; sourceTree = "<group>"; };
|
||||
19E96BEA3A5436619297675B /* MCPOAuthRequest.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MCPOAuthRequest.swift; sourceTree = "<group>"; };
|
||||
1A39D6A1B28175B4E394ED7A /* MenuSnapshotPolicy.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MenuSnapshotPolicy.swift; sourceTree = "<group>"; };
|
||||
1AA26F487C06DC6F03F78B13 /* ExtensionAppearanceStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ExtensionAppearanceStore.swift; sourceTree = "<group>"; };
|
||||
1B865FF16D3C395A8D509B94 /* QuicklinkLauncher.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = QuicklinkLauncher.swift; sourceTree = "<group>"; };
|
||||
@@ -766,6 +775,7 @@
|
||||
2069789D62057E302322AFDB /* MeetingRowParts.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MeetingRowParts.swift; sourceTree = "<group>"; };
|
||||
209D951FDD587A040F1F76F3 /* PaletteWindowController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PaletteWindowController.swift; sourceTree = "<group>"; };
|
||||
220CECFE780E8BE18FAE0A1C /* ExtensionGridLayout.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ExtensionGridLayout.swift; sourceTree = "<group>"; };
|
||||
222F9EE432F608B5C907E7C2 /* MCPOAuth.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MCPOAuth.swift; sourceTree = "<group>"; };
|
||||
22701685D1187235DA195F3B /* LauncherOrder.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LauncherOrder.swift; sourceTree = "<group>"; };
|
||||
23591B301A183579098AA019 /* OnboardingView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OnboardingView.swift; sourceTree = "<group>"; };
|
||||
23A119A569C07DE20F315212 /* InlineArgumentFields.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = InlineArgumentFields.swift; sourceTree = "<group>"; };
|
||||
@@ -819,6 +829,7 @@
|
||||
31757FAE5F3E9E06D9B77DCA /* ReleaseNotes.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ReleaseNotes.swift; sourceTree = "<group>"; };
|
||||
31BAD6EE1BA3036C4B5A3D19 /* HUDPresenter.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HUDPresenter.swift; sourceTree = "<group>"; };
|
||||
31F2C83F4B985EA6CD03BC69 /* QuickActionResultView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = QuickActionResultView.swift; sourceTree = "<group>"; };
|
||||
3265CBD6C91FC67E3996AF8E /* MCPOAuthChallenge.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MCPOAuthChallenge.swift; sourceTree = "<group>"; };
|
||||
32733B27AE0BD75742D1394F /* DialogView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DialogView.swift; sourceTree = "<group>"; };
|
||||
32D20FAFD9F3CC0E5802A966 /* ExtensionImage.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ExtensionImage.swift; sourceTree = "<group>"; };
|
||||
33994DC2FB0A6B23A403CA29 /* AppLauncher.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppLauncher.swift; sourceTree = "<group>"; };
|
||||
@@ -829,6 +840,7 @@
|
||||
369115AB459927FE90BCBEB5 /* WindowSwitchScreen.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WindowSwitchScreen.swift; sourceTree = "<group>"; };
|
||||
36B6DAC3B9FF387367C3FAB4 /* UninstallSession.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UninstallSession.swift; sourceTree = "<group>"; };
|
||||
36C6775BBA0FC6F134296606 /* ImageThumbnail.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ImageThumbnail.swift; sourceTree = "<group>"; };
|
||||
370B1767182D5C53ADBEDA35 /* MCPOAuthManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MCPOAuthManager.swift; sourceTree = "<group>"; };
|
||||
375734577C888260A8023181 /* SnippetsStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SnippetsStore.swift; sourceTree = "<group>"; };
|
||||
37602A3CBE03EB41BF09622A /* CustomCommandArgumentsAccessory.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CustomCommandArgumentsAccessory.swift; sourceTree = "<group>"; };
|
||||
37A48095F6487DEE76663F88 /* UninstallProtection.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UninstallProtection.swift; sourceTree = "<group>"; };
|
||||
@@ -1085,6 +1097,7 @@
|
||||
978DA4527C5B96C98BD33899 /* WindowLayoutArgumentField.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WindowLayoutArgumentField.swift; sourceTree = "<group>"; };
|
||||
97986EE62AE844FFCCD27449 /* SupportCoordinator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SupportCoordinator.swift; sourceTree = "<group>"; };
|
||||
97F7A18CE243DB037673EB63 /* DialogTextField.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DialogTextField.swift; sourceTree = "<group>"; };
|
||||
98310BAA8713A12DF56F19D7 /* MCPOAuthListener.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MCPOAuthListener.swift; sourceTree = "<group>"; };
|
||||
98784BF77E97C75859888301 /* QuickLookSurface.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = QuickLookSurface.swift; sourceTree = "<group>"; };
|
||||
988EA3698EA86BA53590C24E /* ExtensionFetcher.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ExtensionFetcher.swift; sourceTree = "<group>"; };
|
||||
98C29DA5656628B41C17D42A /* QuickActionSettingsStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = QuickActionSettingsStore.swift; sourceTree = "<group>"; };
|
||||
@@ -1113,6 +1126,7 @@
|
||||
A178C17DB989B6D2D71C021E /* ExtensionActionsPanel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ExtensionActionsPanel.swift; sourceTree = "<group>"; };
|
||||
A17C22B5CE1449E12331FA94 /* WindowLayoutEditorPanel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WindowLayoutEditorPanel.swift; sourceTree = "<group>"; };
|
||||
A379CB384B76FF70AC72E235 /* AppleShortcut.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppleShortcut.swift; sourceTree = "<group>"; };
|
||||
A4618E5608C3E4F7D1E911F1 /* MCPOAuthService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MCPOAuthService.swift; sourceTree = "<group>"; };
|
||||
A4942C19E48EF6DECD2D303D /* FileSearchMediaPlayer.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FileSearchMediaPlayer.swift; sourceTree = "<group>"; };
|
||||
A4B1949D20DAEA3F80649D17 /* WindowCycle.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WindowCycle.swift; sourceTree = "<group>"; };
|
||||
A5873BB8728FD8BC5D256A21 /* CameraCoordinator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CameraCoordinator.swift; sourceTree = "<group>"; };
|
||||
@@ -1837,6 +1851,9 @@
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
6F2CBDE3042FDA4FCA561857 /* MCPComposerAddress.swift */,
|
||||
222F9EE432F608B5C907E7C2 /* MCPOAuth.swift */,
|
||||
3265CBD6C91FC67E3996AF8E /* MCPOAuthChallenge.swift */,
|
||||
19E96BEA3A5436619297675B /* MCPOAuthRequest.swift */,
|
||||
DBED1FDAAD4735FDB4B2453A /* MCPProtocol.swift */,
|
||||
5B2DBBB997953D2E50EBB050 /* MCPServer.swift */,
|
||||
A7B8990163AB02BC5E29D7B8 /* MCPTool.swift */,
|
||||
@@ -2861,6 +2878,10 @@
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
3FD57D9B2C6C46B5ADDDE173 /* MCPHTTPTransport.swift */,
|
||||
10A05EA2484C7E9EC8B05A69 /* MCPOAuthHTTP.swift */,
|
||||
98310BAA8713A12DF56F19D7 /* MCPOAuthListener.swift */,
|
||||
370B1767182D5C53ADBEDA35 /* MCPOAuthManager.swift */,
|
||||
A4618E5608C3E4F7D1E911F1 /* MCPOAuthService.swift */,
|
||||
81C6D5C5A9A1D66BF3A77A8E /* MCPSecretStore.swift */,
|
||||
5B883E55507FA5BE11A31B54 /* MCPServerConnection.swift */,
|
||||
B2F8B8787A7197579A6F6ECC /* MCPServerManager.swift */,
|
||||
@@ -3587,6 +3608,13 @@
|
||||
50A5E2CC839C7A65B3391391 /* MCPComposerAddress.swift in Sources */,
|
||||
F37AC0478A0A96243E833900 /* MCPCoordinator.swift in Sources */,
|
||||
B374B57D0E9D902AF877153D /* MCPHTTPTransport.swift in Sources */,
|
||||
C8D4DDD0DDA678429552E062 /* MCPOAuth.swift in Sources */,
|
||||
195465DB2A155EDD8C0809BA /* MCPOAuthChallenge.swift in Sources */,
|
||||
C0D1D4D7D03F48E8AC7901CF /* MCPOAuthHTTP.swift in Sources */,
|
||||
978FE09E53A632B1473DC5D3 /* MCPOAuthListener.swift in Sources */,
|
||||
55C603F567C3C627865B6A0E /* MCPOAuthManager.swift in Sources */,
|
||||
E06AA23D307A8FBA62C950BA /* MCPOAuthRequest.swift in Sources */,
|
||||
06118397F59E4C9F6F2EAC3A /* MCPOAuthService.swift in Sources */,
|
||||
B748B0BA6AB0780CAB4DA304 /* MCPProtocol.swift in Sources */,
|
||||
5CA4B45552F689689FBAFA21 /* MCPSecretStore.swift in Sources */,
|
||||
54D56A2119945AA73F4FBA99 /* MCPServer.swift in Sources */,
|
||||
|
||||
@@ -56,7 +56,8 @@ final class AppCore {
|
||||
let aiSettings = AISettingsStore(
|
||||
isAppleIntelligenceAvailable: { AppleIntelligenceProvider.status().isAvailable })
|
||||
let mcpSettings = MCPSettingsStore()
|
||||
let mcp = MCPServerManager()
|
||||
let mcpOAuth = MCPOAuthManager()
|
||||
@ObservationIgnored private(set) lazy var mcp = MCPServerManager(oauth: mcpOAuth)
|
||||
let quickActionSettings = QuickActionSettingsStore()
|
||||
let customQuickActions = CustomQuickActionStore()
|
||||
let chatGPTSubscription = ChatGPTSubscriptionManager()
|
||||
@@ -486,6 +487,7 @@ final class AppCore {
|
||||
snippetsStore.stop()
|
||||
aiChat.cancel()
|
||||
chatGPTSubscription.stop()
|
||||
mcpOAuth.stop()
|
||||
mcp.stop()
|
||||
installedAI.stop()
|
||||
}
|
||||
|
||||
@@ -0,0 +1,269 @@
|
||||
import Foundation
|
||||
|
||||
enum MCPOAuth {
|
||||
enum Failure: LocalizedError, Equatable {
|
||||
case invalidMetadata
|
||||
case unsupportedPKCE
|
||||
case clientRequired
|
||||
case issuerChanged
|
||||
case invalidCallback
|
||||
case denied
|
||||
case invalidToken
|
||||
case signInRequired
|
||||
case network
|
||||
case registration
|
||||
case listenerUnavailable
|
||||
case signInInProgress
|
||||
case timedOut
|
||||
|
||||
var errorDescription: String? {
|
||||
switch self {
|
||||
case .invalidMetadata: return "The server's OAuth metadata is invalid."
|
||||
case .unsupportedPKCE: return "The authorization server must advertise PKCE S256 support."
|
||||
case .clientRequired: return "Enter a registered client ID. This server cannot register Tinycast automatically."
|
||||
case .issuerChanged: return "The authorization server changed. Enter client credentials for the new server."
|
||||
case .invalidCallback: return "The sign-in response could not be verified."
|
||||
case .denied: return "Sign-in was declined."
|
||||
case .invalidToken: return "The authorization server did not return a usable bearer token."
|
||||
case .signInRequired: return "Sign-in required. Open this MCP server in Settings to sign in."
|
||||
case .network: return "The OAuth request failed. Check the connection and try again."
|
||||
case .registration: return "Client registration failed. Enter a registered client ID and try again."
|
||||
case .listenerUnavailable: return "Sign-in could not open loopback port 4962. Close the app using it and retry."
|
||||
case .signInInProgress: return "Another sign-in is still waiting. Finish it first."
|
||||
case .timedOut: return "Sign-in timed out. Try again."
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct Credentials: Codable, Equatable, Sendable {
|
||||
var clientID: String = ""
|
||||
var clientSecret: String = ""
|
||||
var registration: Registration?
|
||||
var token: Token?
|
||||
|
||||
/// A pasted ID often ends in a newline, and Google answers that with "client not found".
|
||||
static func supplied(clientID: String, clientSecret: String) -> Credentials {
|
||||
Credentials(clientID: clientID.trimmingCharacters(in: .whitespacesAndNewlines),
|
||||
clientSecret: clientSecret.trimmingCharacters(in: .whitespacesAndNewlines))
|
||||
}
|
||||
}
|
||||
|
||||
struct Registration: Codable, Equatable, Sendable {
|
||||
let resource: String
|
||||
let issuer: String
|
||||
let clientID: String
|
||||
let clientSecret: String?
|
||||
let tokenEndpoint: String
|
||||
let authMethod: String
|
||||
let redirectURI: String
|
||||
}
|
||||
|
||||
struct Token: Codable, Equatable, Sendable {
|
||||
let accessToken: String
|
||||
let refreshToken: String?
|
||||
let expiresAt: Date?
|
||||
let scope: String?
|
||||
|
||||
func needsRefresh(now: Date) -> Bool {
|
||||
expiresAt.map { $0.timeIntervalSince(now) <= 60 } ?? false
|
||||
}
|
||||
}
|
||||
|
||||
struct ResourceMetadata: Decodable, Sendable {
|
||||
let resource: String
|
||||
let authorization_servers: [String]
|
||||
let scopes_supported: [String]?
|
||||
}
|
||||
|
||||
struct ServerMetadata: Decodable, Sendable {
|
||||
let issuer: String
|
||||
let authorization_endpoint: String
|
||||
let token_endpoint: String
|
||||
let registration_endpoint: String?
|
||||
let code_challenge_methods_supported: [String]?
|
||||
let token_endpoint_auth_methods_supported: [String]?
|
||||
let scopes_supported: [String]?
|
||||
let authorization_response_iss_parameter_supported: Bool?
|
||||
}
|
||||
|
||||
static func endpoint(_ value: String) throws -> URL {
|
||||
let url = try AIEndpointPolicy.validate(value)
|
||||
guard url.user == nil, url.password == nil, url.fragment == nil else { throw Failure.invalidMetadata }
|
||||
return url
|
||||
}
|
||||
|
||||
static func resource(_ value: String) throws -> String {
|
||||
let url = try endpoint(value)
|
||||
guard var parts = URLComponents(url: url, resolvingAgainstBaseURL: false) else {
|
||||
throw Failure.invalidMetadata
|
||||
}
|
||||
parts.scheme = parts.scheme?.lowercased()
|
||||
parts.host = parts.host?.lowercased()
|
||||
if parts.path == "/" { parts.path = "" }
|
||||
guard let result = parts.string else { throw Failure.invalidMetadata }
|
||||
return result
|
||||
}
|
||||
|
||||
static func protectedMetadataURLs(resource: String, challenge: [String: String]) throws -> [URL] {
|
||||
if let location = challenge["resource_metadata"] { return [try endpoint(location)] }
|
||||
return try wellKnown(resource, suffixes: ["oauth-protected-resource"], appendOIDC: false, rootFallback: true)
|
||||
}
|
||||
|
||||
static func serverMetadataURLs(issuer: String) throws -> [URL] {
|
||||
let url = try endpoint(issuer)
|
||||
guard url.query == nil else { throw Failure.invalidMetadata }
|
||||
return try wellKnown(issuer, suffixes: ["oauth-authorization-server", "openid-configuration"], appendOIDC: true)
|
||||
}
|
||||
|
||||
private static func wellKnown(
|
||||
_ value: String, suffixes: [String], appendOIDC: Bool, rootFallback: Bool = false
|
||||
) throws -> [URL] {
|
||||
let url = try endpoint(value)
|
||||
guard var parts = URLComponents(url: url, resolvingAgainstBaseURL: false) else {
|
||||
throw Failure.invalidMetadata
|
||||
}
|
||||
let encoded = parts.percentEncodedPath
|
||||
let path = encoded.hasSuffix("/") ? String(encoded.dropLast()) : encoded
|
||||
parts.query = nil
|
||||
var results: [URL] = []
|
||||
for suffix in suffixes {
|
||||
parts.percentEncodedPath = "/.well-known/\(suffix)\(path)"
|
||||
if let url = parts.url { results.append(url) }
|
||||
}
|
||||
if !path.isEmpty {
|
||||
if appendOIDC {
|
||||
parts.percentEncodedPath = path + "/.well-known/openid-configuration"
|
||||
} else if rootFallback {
|
||||
parts.percentEncodedPath = "/.well-known/\(suffixes[0])"
|
||||
}
|
||||
if let url = parts.url, !results.contains(url) { results.append(url) }
|
||||
}
|
||||
return results
|
||||
}
|
||||
|
||||
/// A redirect may carry credentials only here: scheme, host and effective port all unchanged.
|
||||
static func sameOrigin(_ first: URL, _ second: URL) -> Bool {
|
||||
func port(_ url: URL) -> Int { url.port ?? (url.scheme?.lowercased() == "https" ? 443 : 80) }
|
||||
return first.scheme?.lowercased() == second.scheme?.lowercased()
|
||||
&& first.host()?.lowercased() == second.host()?.lowercased() && port(first) == port(second)
|
||||
}
|
||||
|
||||
static func resourceCovers(_ resource: String, endpoint: String) throws -> Bool {
|
||||
let parent = try Self.endpoint(resource)
|
||||
let child = try Self.endpoint(endpoint)
|
||||
guard sameOrigin(parent, child),
|
||||
parent.query == nil || parent.query == child.query else { return false }
|
||||
let parentPath = parent.path.hasSuffix("/") ? parent.path : parent.path + "/"
|
||||
let childPath = child.path.hasSuffix("/") ? child.path : child.path + "/"
|
||||
return childPath.hasPrefix(parentPath)
|
||||
}
|
||||
|
||||
static func parseResource(_ data: Data, expected: String) throws -> ResourceMetadata {
|
||||
guard let metadata = try? JSONDecoder().decode(ResourceMetadata.self, from: data),
|
||||
try resourceCovers(metadata.resource, endpoint: expected), !metadata.authorization_servers.isEmpty
|
||||
else { throw Failure.invalidMetadata }
|
||||
for issuer in metadata.authorization_servers { _ = try endpoint(issuer) }
|
||||
return metadata
|
||||
}
|
||||
|
||||
/// Google advertises `https://accounts.google.com/` and publishes it without the slash.
|
||||
static func sameIssuer(_ first: String, _ second: String) -> Bool {
|
||||
func bare(_ value: String) -> String { value.hasSuffix("/") ? String(value.dropLast()) : value }
|
||||
return bare(first) == bare(second)
|
||||
}
|
||||
|
||||
static func parseServer(_ data: Data, issuer: String) throws -> ServerMetadata {
|
||||
guard let metadata = try? JSONDecoder().decode(ServerMetadata.self, from: data),
|
||||
sameIssuer(metadata.issuer, issuer)
|
||||
else { throw Failure.invalidMetadata }
|
||||
guard metadata.code_challenge_methods_supported?.contains("S256") == true else {
|
||||
throw Failure.unsupportedPKCE
|
||||
}
|
||||
_ = try endpoint(metadata.authorization_endpoint)
|
||||
_ = try endpoint(metadata.token_endpoint)
|
||||
if let registration = metadata.registration_endpoint { _ = try endpoint(registration) }
|
||||
return metadata
|
||||
}
|
||||
|
||||
static func base64URL(_ bytes: Data) -> String {
|
||||
bytes.base64EncodedString().replacingOccurrences(of: "+", with: "-")
|
||||
.replacingOccurrences(of: "/", with: "_").replacingOccurrences(of: "=", with: "")
|
||||
}
|
||||
|
||||
static func pkce(entropy: Data, sha256: (Data) -> Data) -> (verifier: String, challenge: String) {
|
||||
let verifier = base64URL(entropy)
|
||||
return (verifier, base64URL(sha256(Data(verifier.utf8))))
|
||||
}
|
||||
|
||||
static func form(_ fields: [String: String]) -> Data {
|
||||
Data(fields.sorted { $0.key < $1.key }.map { "\(escape($0.key))=\(escape($0.value))" }
|
||||
.joined(separator: "&").utf8)
|
||||
}
|
||||
|
||||
static func escape(_ value: String) -> String {
|
||||
let allowed = CharacterSet(charactersIn: "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-._~")
|
||||
return value.addingPercentEncoding(withAllowedCharacters: allowed) ?? ""
|
||||
}
|
||||
|
||||
static func authorizeURL(
|
||||
metadata: ServerMetadata, registration: Registration, challenge: String, state: String, scope: String?
|
||||
) throws -> URL {
|
||||
let endpoint = try endpoint(metadata.authorization_endpoint)
|
||||
guard var parts = URLComponents(url: endpoint, resolvingAgainstBaseURL: false) else {
|
||||
throw Failure.invalidMetadata
|
||||
}
|
||||
var fields = ["response_type": "code", "client_id": registration.clientID,
|
||||
"redirect_uri": registration.redirectURI, "code_challenge": challenge,
|
||||
"code_challenge_method": "S256", "state": state, "resource": registration.resource]
|
||||
fields["scope"] = scope
|
||||
let reserved = Set(fields.keys).union(["scope"])
|
||||
parts.percentEncodedQueryItems = (parts.percentEncodedQueryItems ?? []).filter { !reserved.contains($0.name) }
|
||||
+ fields.sorted { $0.key < $1.key }.map { URLQueryItem(name: escape($0.key), value: escape($0.value)) }
|
||||
guard let url = parts.url else { throw Failure.invalidMetadata }
|
||||
return url
|
||||
}
|
||||
|
||||
static func callback(_ target: String, state: String, issuer: String, requiresIssuer: Bool) throws -> String {
|
||||
guard target.hasPrefix("/callback?"),
|
||||
let parts = URLComponents(string: "http://127.0.0.1" + target), parts.path == "/callback",
|
||||
parts.fragment == nil else { throw Failure.invalidCallback }
|
||||
var fields: [String: String] = [:]
|
||||
for part in (parts.percentEncodedQuery ?? "").split(separator: "&") {
|
||||
let pair = part.split(separator: "=", maxSplits: 1, omittingEmptySubsequences: false)
|
||||
guard pair.count == 2,
|
||||
let key = String(pair[0]).replacingOccurrences(of: "+", with: " ").removingPercentEncoding,
|
||||
let value = String(pair[1]).replacingOccurrences(of: "+", with: " ").removingPercentEncoding,
|
||||
fields[key] == nil else { throw Failure.invalidCallback }
|
||||
fields[key] = value
|
||||
}
|
||||
guard fields["state"] == state,
|
||||
fields["iss"].map({ $0 == issuer }) ?? !requiresIssuer else { throw Failure.invalidCallback }
|
||||
if fields["error"] != nil { throw Failure.denied }
|
||||
guard let code = fields["code"], !code.isEmpty else { throw Failure.invalidCallback }
|
||||
return code
|
||||
}
|
||||
|
||||
/// RFC 6749 §5.2: only 400 and 401 reject the grant; anything else is a server fault.
|
||||
static func tokenFailure(status: Int) -> Failure {
|
||||
status == 400 || status == 401 ? .signInRequired : .network
|
||||
}
|
||||
|
||||
static func parseToken(_ data: Data, previous: Token?, now: Date) throws -> Token {
|
||||
struct Response: Decodable {
|
||||
let access_token: String
|
||||
let token_type: String
|
||||
let refresh_token: String?
|
||||
let expires_in: Double?
|
||||
let scope: String?
|
||||
}
|
||||
guard let response = try? JSONDecoder().decode(Response.self, from: data),
|
||||
response.token_type.lowercased() == "bearer", !response.access_token.isEmpty,
|
||||
response.access_token.utf8.allSatisfy({ $0 > 32 && $0 < 127 }),
|
||||
response.expires_in.map({ $0.isFinite && $0 > 0 }) ?? true
|
||||
else { throw Failure.invalidToken }
|
||||
return Token(accessToken: response.access_token,
|
||||
refreshToken: response.refresh_token ?? previous?.refreshToken,
|
||||
expiresAt: response.expires_in.map { now.addingTimeInterval($0) },
|
||||
scope: response.scope ?? previous?.scope)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
import Foundation
|
||||
|
||||
enum MCPOAuthChallenge {
|
||||
static func parse(_ header: String?) -> [String: String] {
|
||||
guard let header, header.utf8.count <= 16_384 else { return [:] }
|
||||
var segments: [String] = []
|
||||
var segment = ""
|
||||
var quoted = false
|
||||
var escaped = false
|
||||
for character in header {
|
||||
if escaped { segment.append(character); escaped = false; continue }
|
||||
if character == "\\", quoted { segment.append(character); escaped = true; continue }
|
||||
if character == "\"" { quoted.toggle() }
|
||||
if character == ",", !quoted { segments.append(segment); segment = "" } else { segment.append(character) }
|
||||
}
|
||||
guard !quoted, !escaped else { return [:] }
|
||||
segments.append(segment)
|
||||
var bearer = false
|
||||
var fields: [String: String] = [:]
|
||||
for segment in segments {
|
||||
var text = segment.trimmingCharacters(in: .whitespaces)
|
||||
if let space = text.firstIndex(where: \.isWhitespace) {
|
||||
let prefix = String(text[..<space])
|
||||
if !prefix.contains("="), !text[text.index(after: space)...].hasPrefix("=") {
|
||||
if bearer { return fields }
|
||||
bearer = prefix.lowercased() == "bearer"
|
||||
text = String(text[space...]).trimmingCharacters(in: .whitespaces)
|
||||
}
|
||||
}
|
||||
guard bearer, let equal = text.firstIndex(of: "=") else { continue }
|
||||
let key = text[..<equal].trimmingCharacters(in: .whitespaces).lowercased()
|
||||
var value = text[text.index(after: equal)...].trimmingCharacters(in: .whitespaces)
|
||||
if value.hasPrefix("\""), value.hasSuffix("\"") {
|
||||
value = String(value.dropFirst().dropLast())
|
||||
var decoded = ""
|
||||
var escape = false
|
||||
for character in value {
|
||||
if escape {
|
||||
decoded.append(character)
|
||||
escape = false
|
||||
} else if character == "\\" {
|
||||
escape = true
|
||||
} else {
|
||||
decoded.append(character)
|
||||
}
|
||||
}
|
||||
value = decoded
|
||||
}
|
||||
guard fields[key] == nil else { return [:] }
|
||||
fields[key] = value
|
||||
}
|
||||
return fields
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
import Foundation
|
||||
|
||||
enum MCPOAuthRequest {
|
||||
static func token(
|
||||
registration: MCPOAuth.Registration, code: String?, verifier: String?, previous: MCPOAuth.Token?
|
||||
) throws -> URLRequest {
|
||||
var fields = ["client_id": registration.clientID, "resource": registration.resource]
|
||||
if let code, let verifier {
|
||||
fields["grant_type"] = "authorization_code"
|
||||
fields["code"] = code
|
||||
fields["code_verifier"] = verifier
|
||||
fields["redirect_uri"] = registration.redirectURI
|
||||
} else if let refresh = previous?.refreshToken, !refresh.isEmpty {
|
||||
fields["grant_type"] = "refresh_token"
|
||||
fields["refresh_token"] = refresh
|
||||
} else { throw MCPOAuth.Failure.signInRequired }
|
||||
var request = URLRequest(url: try MCPOAuth.endpoint(registration.tokenEndpoint), timeoutInterval: 30)
|
||||
if registration.authMethod == "client_secret_basic", let secret = registration.clientSecret {
|
||||
let basic = MCPOAuth.escape(registration.clientID) + ":" + MCPOAuth.escape(secret)
|
||||
request.setValue("Basic " + Data(basic.utf8).base64EncodedString(), forHTTPHeaderField: "Authorization")
|
||||
} else if registration.authMethod == "client_secret_post" {
|
||||
fields["client_secret"] = registration.clientSecret
|
||||
}
|
||||
request.httpMethod = "POST"
|
||||
request.httpBody = MCPOAuth.form(fields)
|
||||
request.setValue("application/x-www-form-urlencoded", forHTTPHeaderField: "Content-Type")
|
||||
request.setValue("application/json", forHTTPHeaderField: "Accept")
|
||||
return request
|
||||
}
|
||||
|
||||
static func registration(redirectURI: String) throws -> Data {
|
||||
try JSONSerialization.data(withJSONObject: [
|
||||
"client_name": "Tinycast", "application_type": "native",
|
||||
"redirect_uris": [redirectURI],
|
||||
"grant_types": ["authorization_code", "refresh_token"], "response_types": ["code"],
|
||||
"token_endpoint_auth_method": "none"])
|
||||
}
|
||||
}
|
||||
@@ -40,6 +40,7 @@ struct MCPServer: Codable, Equatable, Identifiable, Sendable {
|
||||
var transport: MCPTransportKind
|
||||
var isEnabled: Bool
|
||||
var trust: MCPTrust
|
||||
var oauth: Bool?
|
||||
|
||||
init(
|
||||
id: UUID = UUID(), name: String = "", slug: String = "",
|
||||
|
||||
@@ -8,12 +8,16 @@ final class MCPHTTPTransport: MCPTransport {
|
||||
private let endpoint: URL
|
||||
private let headerName: String
|
||||
private let headerValue: String
|
||||
private let authorization: ((String?) async throws -> String)?
|
||||
private var sessionID: String?
|
||||
private var protocolVersion = MCPProtocol.version
|
||||
private var nextID = 1
|
||||
private var isConnected = false
|
||||
|
||||
init(url: String, headerName: String, headerValue: String) throws {
|
||||
init(
|
||||
url: String, headerName: String, headerValue: String,
|
||||
authorization: ((String?) async throws -> String)? = nil
|
||||
) throws {
|
||||
do {
|
||||
endpoint = try AIEndpointPolicy.validate(url)
|
||||
} catch {
|
||||
@@ -21,6 +25,7 @@ final class MCPHTTPTransport: MCPTransport {
|
||||
}
|
||||
self.headerName = headerName.trimmingCharacters(in: .whitespaces)
|
||||
self.headerValue = headerValue
|
||||
self.authorization = authorization
|
||||
}
|
||||
|
||||
func connect() async throws {
|
||||
@@ -71,16 +76,27 @@ final class MCPHTTPTransport: MCPTransport {
|
||||
request.setValue("application/json, text/event-stream", forHTTPHeaderField: "Accept")
|
||||
request.setValue(protocolVersion, forHTTPHeaderField: "MCP-Protocol-Version")
|
||||
if let sessionID { request.setValue(sessionID, forHTTPHeaderField: "Mcp-Session-Id") }
|
||||
if !headerName.isEmpty, !headerValue.isEmpty {
|
||||
let token = try await authorization?(nil)
|
||||
if let token {
|
||||
request.setValue("Bearer \(token)", forHTTPHeaderField: "Authorization")
|
||||
} else if !headerName.isEmpty, !headerValue.isEmpty {
|
||||
request.setValue(headerValue, forHTTPHeaderField: headerName)
|
||||
}
|
||||
let session = Self.makeSession()
|
||||
let session = MCPOAuthHTTP.session(followsSameOrigin: true)
|
||||
defer { session.invalidateAndCancel() }
|
||||
do {
|
||||
let (data, response) = try await session.data(for: request)
|
||||
guard let response = response as? HTTPURLResponse else {
|
||||
throw MCPTransportError.malformedResponse
|
||||
}
|
||||
if response.statusCode == 401, let authorization, let token {
|
||||
let refreshed = try await authorization(token)
|
||||
request.setValue("Bearer \(refreshed)", forHTTPHeaderField: "Authorization")
|
||||
let (retried, reply) = try await session.data(for: request)
|
||||
guard let reply = reply as? HTTPURLResponse else { throw MCPTransportError.malformedResponse }
|
||||
if reply.statusCode == 401 { throw MCPOAuth.Failure.signInRequired }
|
||||
return (retried, reply)
|
||||
}
|
||||
return (data, response)
|
||||
} catch let error as URLError {
|
||||
throw MCPTransportError.requestFailed(Self.networkMessage(error.code))
|
||||
@@ -109,13 +125,6 @@ final class MCPHTTPTransport: MCPTransport {
|
||||
return (parser.feed(data) + parser.finish()).map { MCPProtocol.parse(Data($0.utf8)) }
|
||||
}
|
||||
|
||||
private static func makeSession() -> URLSession {
|
||||
let configuration = URLSessionConfiguration.ephemeral
|
||||
configuration.urlCache = nil
|
||||
configuration.requestCachePolicy = .reloadIgnoringLocalAndRemoteCacheData
|
||||
return URLSession(configuration: configuration)
|
||||
}
|
||||
|
||||
private static func networkMessage(_ code: URLError.Code) -> String {
|
||||
switch code {
|
||||
case .notConnectedToInternet: return "No internet connection."
|
||||
|
||||
@@ -0,0 +1,69 @@
|
||||
import Foundation
|
||||
|
||||
final class MCPOAuthHTTP: NSObject, URLSessionTaskDelegate, Sendable {
|
||||
private let followsSameOrigin: Bool
|
||||
|
||||
init(followsSameOrigin: Bool) { self.followsSameOrigin = followsSameOrigin }
|
||||
|
||||
func urlSession(
|
||||
_ session: URLSession, task: URLSessionTask, willPerformHTTPRedirection response: HTTPURLResponse,
|
||||
newRequest request: URLRequest, completionHandler: @escaping @Sendable (URLRequest?) -> Void
|
||||
) {
|
||||
guard followsSameOrigin, let from = response.url, let to = request.url,
|
||||
MCPOAuth.sameOrigin(from, to) else { return completionHandler(nil) }
|
||||
// URLSession strips Authorization on a redirect; inside one origin it is safe to restore.
|
||||
var next = request
|
||||
for (name, value) in task.originalRequest?.allHTTPHeaderFields ?? [:]
|
||||
where next.value(forHTTPHeaderField: name) == nil {
|
||||
next.setValue(value, forHTTPHeaderField: name)
|
||||
}
|
||||
completionHandler(next)
|
||||
}
|
||||
|
||||
/// OAuth endpoints refuse every redirect; an MCP endpoint may move within its own origin.
|
||||
static func session(followsSameOrigin: Bool = false) -> URLSession {
|
||||
let configuration = URLSessionConfiguration.ephemeral
|
||||
configuration.urlCache = nil
|
||||
configuration.httpCookieStorage = nil
|
||||
configuration.urlCredentialStorage = nil
|
||||
configuration.requestCachePolicy = .reloadIgnoringLocalAndRemoteCacheData
|
||||
return URLSession(
|
||||
configuration: configuration, delegate: MCPOAuthHTTP(followsSameOrigin: followsSameOrigin),
|
||||
delegateQueue: nil)
|
||||
}
|
||||
|
||||
static func send(_ request: URLRequest, limit: Int = 1_048_576) async throws -> (Data, HTTPURLResponse) {
|
||||
guard let url = request.url else { throw MCPOAuth.Failure.invalidMetadata }
|
||||
_ = try MCPOAuth.endpoint(url.absoluteString)
|
||||
let session = session()
|
||||
defer { session.invalidateAndCancel() }
|
||||
do {
|
||||
let (bytes, response) = try await session.bytes(for: request)
|
||||
guard let response = response as? HTTPURLResponse else { throw MCPOAuth.Failure.network }
|
||||
var data = Data()
|
||||
for try await byte in bytes {
|
||||
guard data.count < limit else { throw MCPOAuth.Failure.network }
|
||||
data.append(byte)
|
||||
}
|
||||
return (data, response)
|
||||
} catch is CancellationError {
|
||||
throw CancellationError()
|
||||
} catch {
|
||||
try Task.checkCancellation()
|
||||
throw MCPOAuth.Failure.network
|
||||
}
|
||||
}
|
||||
|
||||
static func json(_ url: URL, body: Data? = nil, contentType: String = "application/json") async throws -> Data {
|
||||
var request = URLRequest(url: url, timeoutInterval: 30)
|
||||
request.setValue("application/json", forHTTPHeaderField: "Accept")
|
||||
if let body {
|
||||
request.httpMethod = "POST"
|
||||
request.httpBody = body
|
||||
request.setValue(contentType, forHTTPHeaderField: "Content-Type")
|
||||
}
|
||||
let (data, response) = try await send(request)
|
||||
guard (200...299).contains(response.statusCode) else { throw MCPOAuth.Failure.network }
|
||||
return data
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,147 @@
|
||||
import Foundation
|
||||
import Network
|
||||
|
||||
@MainActor
|
||||
final class MCPOAuthListener {
|
||||
nonisolated static let redirectURI = "http://127.0.0.1:4962/callback"
|
||||
private var task: Task<Void, Never>?
|
||||
private var ready: CheckedContinuation<Void, Error>?
|
||||
private var reply: CheckedContinuation<String, Error>?
|
||||
private var result: Result<String, Error>?
|
||||
private var accepted = false
|
||||
|
||||
isolated deinit { task?.cancel() }
|
||||
|
||||
func start(state: String, issuer: String, requiresIssuer: Bool, timeout: Duration = .seconds(300)) async throws {
|
||||
guard result == nil else { throw CancellationError() }
|
||||
let listener = try NetworkListener(using: .parameters { TCP() }
|
||||
.localEndpoint(.hostPort(host: .ipv4(.loopback), port: 4962)))
|
||||
listener.newConnectionLimit = 16
|
||||
try await withTaskCancellationHandler {
|
||||
try Task.checkCancellation()
|
||||
try await withCheckedThrowingContinuation { continuation in
|
||||
ready = continuation
|
||||
task = Task { [weak self] in
|
||||
do {
|
||||
try await withThrowingTaskGroup(of: Void.self) { group in
|
||||
group.addTask { [weak self] in
|
||||
try await self?.run(listener, state: state, issuer: issuer, requiresIssuer: requiresIssuer)
|
||||
}
|
||||
group.addTask {
|
||||
try await Task.sleep(for: timeout)
|
||||
throw MCPOAuth.Failure.timedOut
|
||||
}
|
||||
defer { group.cancelAll() }
|
||||
_ = try await group.next()
|
||||
}
|
||||
} catch {
|
||||
self?.finish(.failure(error))
|
||||
}
|
||||
}
|
||||
}
|
||||
} onCancel: {
|
||||
Task { @MainActor [weak self] in self?.cancel() }
|
||||
}
|
||||
}
|
||||
|
||||
func code() async throws -> String {
|
||||
try await withTaskCancellationHandler {
|
||||
try Task.checkCancellation()
|
||||
if let result { return try result.get() }
|
||||
return try await withCheckedThrowingContinuation { reply = $0 }
|
||||
} onCancel: {
|
||||
Task { @MainActor [weak self] in self?.cancel() }
|
||||
}
|
||||
}
|
||||
|
||||
func cancel() { finish(.failure(CancellationError())) }
|
||||
|
||||
private func finish(_ result: Result<String, Error>) {
|
||||
guard self.result == nil else { return }
|
||||
self.result = result
|
||||
ready?.resume(throwing: result.failure ?? CancellationError())
|
||||
ready = nil
|
||||
reply?.resume(with: result)
|
||||
reply = nil
|
||||
task?.cancel()
|
||||
task = nil
|
||||
}
|
||||
|
||||
private func run(
|
||||
_ listener: NetworkListener<TCP>, state: String, issuer: String, requiresIssuer: Bool
|
||||
) async throws {
|
||||
try await listener.onStateUpdate { [weak self] _, status in
|
||||
switch status {
|
||||
case .ready:
|
||||
self?.ready?.resume()
|
||||
self?.ready = nil
|
||||
case .waiting, .failed:
|
||||
self?.finish(.failure(MCPOAuth.Failure.listenerUnavailable))
|
||||
default: break
|
||||
}
|
||||
}.run { [weak self] connection in
|
||||
await self?.receive(connection, state: state, issuer: issuer, requiresIssuer: requiresIssuer)
|
||||
}
|
||||
}
|
||||
|
||||
private func read(
|
||||
_ connection: NetworkConnection<TCP>, state: String, issuer: String, requiresIssuer: Bool
|
||||
) async throws {
|
||||
var bytes = Data()
|
||||
while bytes.count < 8192 {
|
||||
let message = try await connection.receive(atLeast: 1, atMost: 8192 - bytes.count)
|
||||
bytes.append(message.content)
|
||||
if bytes.range(of: Data("\r\n\r\n".utf8)) != nil { break }
|
||||
if message.metadata.endOfStream { return }
|
||||
}
|
||||
guard !accepted else { return }
|
||||
guard let request = String(bytes: bytes, encoding: .utf8), request.contains("\r\n\r\n") else { return }
|
||||
let line = request.components(separatedBy: "\r\n").first ?? ""
|
||||
let parts = line.split(separator: " ")
|
||||
var outcome: Result<String, Error>?
|
||||
if parts.count == 3, parts[0] == "GET" {
|
||||
do {
|
||||
let code = try MCPOAuth.callback(String(parts[1]), state: state, issuer: issuer,
|
||||
requiresIssuer: requiresIssuer)
|
||||
outcome = .success(code)
|
||||
} catch MCPOAuth.Failure.denied {
|
||||
outcome = .failure(MCPOAuth.Failure.denied)
|
||||
} catch { outcome = nil }
|
||||
}
|
||||
let status = outcome == nil ? "400 Bad Request" : "200 OK"
|
||||
let page = outcome == nil ? "Invalid sign-in response." : "Return to Tinycast. You can close this tab."
|
||||
let response = "HTTP/1.1 \(status)\r\nContent-Type: text/html; charset=utf-8\r\n"
|
||||
+ "Cache-Control: no-store\r\nContent-Security-Policy: default-src 'none'\r\n"
|
||||
+ "Connection: close\r\nContent-Length: \(page.utf8.count)\r\n\r\n\(page)"
|
||||
if outcome != nil { self.accepted = true }
|
||||
do {
|
||||
try await connection.send(Data(response.utf8), endOfStream: true)
|
||||
} catch {
|
||||
if let outcome { self.finish(outcome) }
|
||||
throw error
|
||||
}
|
||||
if let outcome { self.finish(outcome) }
|
||||
}
|
||||
|
||||
private func receive(
|
||||
_ connection: NetworkConnection<TCP>, state: String, issuer: String, requiresIssuer: Bool
|
||||
) async {
|
||||
do {
|
||||
try await withThrowingTaskGroup(of: Void.self) { group in
|
||||
group.addTask { [weak self] in
|
||||
try await self?.read(connection, state: state, issuer: issuer, requiresIssuer: requiresIssuer)
|
||||
}
|
||||
group.addTask { try await Task.sleep(for: .seconds(5)) }
|
||||
defer { group.cancelAll() }
|
||||
_ = try await group.next()
|
||||
}
|
||||
} catch { return }
|
||||
}
|
||||
}
|
||||
|
||||
private extension Result where Success == String, Failure == Error {
|
||||
var failure: Error? {
|
||||
if case .failure(let error) = self { return error }
|
||||
return nil
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,164 @@
|
||||
import AppKit
|
||||
import Observation
|
||||
|
||||
@MainActor
|
||||
@Observable
|
||||
final class MCPOAuthManager {
|
||||
enum Status: Equatable {
|
||||
case signedOut
|
||||
case signingIn
|
||||
case signedIn
|
||||
case required
|
||||
case failed(String)
|
||||
|
||||
var label: String {
|
||||
switch self {
|
||||
case .signedOut: return "Not signed in"
|
||||
case .signingIn: return "Waiting for sign-in…"
|
||||
case .signedIn: return "Signed in"
|
||||
case .required: return "Sign-in required"
|
||||
case .failed(let message): return message
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private(set) var statuses: [UUID: Status] = [:]
|
||||
@ObservationIgnored private let secrets: MCPSecretStore
|
||||
@ObservationIgnored private var revisions: [UUID: UUID] = [:]
|
||||
@ObservationIgnored private var refreshes: [UUID: Task<String, Error>] = [:]
|
||||
@ObservationIgnored private var listener: MCPOAuthListener?
|
||||
@ObservationIgnored private var signingIn: UUID?
|
||||
|
||||
init(secrets: MCPSecretStore = MCPSecretStore()) { self.secrets = secrets }
|
||||
|
||||
/// Takes the caller's credentials: a view body asks this and must not read the Keychain.
|
||||
func status(for server: MCPServer, stored credentials: MCPOAuth.Credentials?) -> Status {
|
||||
if let status = statuses[server.id] { return status }
|
||||
guard let registration = credentials?.registration, registration.resource == Self.resource(of: server),
|
||||
let token = credentials?.token else { return .signedOut }
|
||||
return token.needsRefresh(now: Date()) && token.refreshToken == nil ? .required : .signedIn
|
||||
}
|
||||
|
||||
func signIn(server: MCPServer, credentials: MCPOAuth.Credentials) async throws {
|
||||
guard signingIn == nil else { throw MCPOAuth.Failure.signInInProgress }
|
||||
guard case .http(let url, _) = server.transport else { throw MCPOAuth.Failure.invalidMetadata }
|
||||
let revision = UUID()
|
||||
revisions[server.id] = revision
|
||||
refreshes.removeValue(forKey: server.id)?.cancel()
|
||||
signingIn = server.id
|
||||
statuses[server.id] = .signingIn
|
||||
let callback = MCPOAuthListener()
|
||||
listener = callback
|
||||
defer {
|
||||
callback.cancel()
|
||||
listener = nil
|
||||
signingIn = nil
|
||||
}
|
||||
do {
|
||||
let discovery = try await MCPOAuthService.discover(url)
|
||||
try checkRevision(server.id, revision)
|
||||
let registration = try await MCPOAuthService.registration(for: discovery, credentials: credentials)
|
||||
let pair = try MCPOAuthService.pkce()
|
||||
let state = MCPOAuth.base64URL(try MCPOAuthService.random())
|
||||
try await callback.start(state: state, issuer: registration.issuer,
|
||||
requiresIssuer: discovery.metadata.authorization_response_iss_parameter_supported == true)
|
||||
let authorize = try MCPOAuth.authorizeURL(metadata: discovery.metadata, registration: registration,
|
||||
challenge: pair.challenge, state: state, scope: discovery.scope)
|
||||
try checkRevision(server.id, revision)
|
||||
guard NSWorkspace.shared.open(authorize) else { throw MCPOAuth.Failure.network }
|
||||
let code = try await callback.code()
|
||||
let token = try await MCPOAuthService.token(registration: registration, code: code, verifier: pair.verifier)
|
||||
try checkRevision(server.id, revision)
|
||||
var stored = secrets.secrets(for: server.id)
|
||||
var signedIn = credentials
|
||||
signedIn.registration = registration
|
||||
signedIn.token = token
|
||||
stored.oauth = signedIn
|
||||
try secrets.save(stored, for: server.id)
|
||||
statuses[server.id] = .signedIn
|
||||
} catch {
|
||||
if revisions[server.id] == revision {
|
||||
statuses[server.id] = error is CancellationError ? .signedOut : .failed(error.localizedDescription)
|
||||
}
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
func signOut(_ id: UUID) throws {
|
||||
cancelSignIn(id)
|
||||
revisions[id] = UUID()
|
||||
refreshes.removeValue(forKey: id)?.cancel()
|
||||
var stored = secrets.secrets(for: id)
|
||||
stored.oauth?.token = nil
|
||||
try secrets.save(stored, for: id)
|
||||
statuses[id] = .signedOut
|
||||
}
|
||||
|
||||
/// A refresh in flight finishes: a rotating server may already have spent the old token.
|
||||
func cancelSignIn(_ id: UUID) {
|
||||
guard signingIn == id else { return }
|
||||
revisions[id] = UUID()
|
||||
listener?.cancel()
|
||||
statuses[id] = .signedOut
|
||||
}
|
||||
|
||||
func stop() {
|
||||
if let signingIn { cancelSignIn(signingIn) }
|
||||
}
|
||||
|
||||
func accessToken(for server: MCPServer, rejectedToken: String? = nil) async throws -> String {
|
||||
guard statuses[server.id] != .required else { throw MCPOAuth.Failure.signInRequired }
|
||||
let credentials = secrets.secrets(for: server.id).oauth
|
||||
guard let registration = credentials?.registration, registration.resource == Self.resource(of: server),
|
||||
let token = credentials?.token else {
|
||||
requireSignIn(server, stored: credentials)
|
||||
throw MCPOAuth.Failure.signInRequired
|
||||
}
|
||||
if let pending = refreshes[server.id] { return try await pending.value }
|
||||
if rejectedToken != token.accessToken, !token.needsRefresh(now: Date()) { return token.accessToken }
|
||||
let revision = revisions[server.id] ?? UUID()
|
||||
revisions[server.id] = revision
|
||||
let task = Task { [weak self] in
|
||||
let refreshed = try await MCPOAuthService.token(registration: registration, previous: token)
|
||||
guard let self else { throw CancellationError() }
|
||||
try self.checkRevision(server.id, revision)
|
||||
var stored = self.secrets.secrets(for: server.id)
|
||||
guard stored.oauth?.registration == registration else { throw CancellationError() }
|
||||
stored.oauth?.token = refreshed
|
||||
try self.secrets.save(stored, for: server.id)
|
||||
return refreshed.accessToken
|
||||
}
|
||||
refreshes[server.id] = task
|
||||
defer { if revisions[server.id] == revision { refreshes[server.id] = nil } }
|
||||
do {
|
||||
let value = try await task.value
|
||||
try checkRevision(server.id, revision)
|
||||
statuses[server.id] = .signedIn
|
||||
return value
|
||||
} catch MCPOAuth.Failure.signInRequired {
|
||||
// Only a rejected grant ends the session; an offline refresh is retried next request.
|
||||
if revisions[server.id] == revision { statuses[server.id] = .required }
|
||||
throw MCPOAuth.Failure.signInRequired
|
||||
}
|
||||
}
|
||||
|
||||
func requireSignIn(_ server: MCPServer) {
|
||||
requireSignIn(server, stored: secrets.secrets(for: server.id).oauth)
|
||||
}
|
||||
|
||||
/// Test Connection on an edited URL says nothing about the session the saved server holds.
|
||||
private func requireSignIn(_ server: MCPServer, stored: MCPOAuth.Credentials?) {
|
||||
if let resource = stored?.registration?.resource, resource != Self.resource(of: server) { return }
|
||||
statuses[server.id] = .required
|
||||
}
|
||||
|
||||
private static func resource(of server: MCPServer) -> String? {
|
||||
guard case .http(let url, _) = server.transport else { return nil }
|
||||
return try? MCPOAuth.resource(url)
|
||||
}
|
||||
|
||||
private func checkRevision(_ id: UUID, _ revision: UUID) throws {
|
||||
try Task.checkCancellation()
|
||||
guard revisions[id] == revision else { throw CancellationError() }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,128 @@
|
||||
import CryptoKit
|
||||
import Foundation
|
||||
import Security
|
||||
|
||||
enum MCPOAuthService {
|
||||
struct Discovery: Sendable {
|
||||
let resource: String
|
||||
let metadata: MCPOAuth.ServerMetadata
|
||||
let scope: String?
|
||||
}
|
||||
|
||||
static func discover(_ url: String) async throws -> Discovery {
|
||||
let resource = try MCPOAuth.resource(url)
|
||||
var probe = URLRequest(url: try MCPOAuth.endpoint(url), timeoutInterval: 15)
|
||||
probe.httpMethod = "POST"
|
||||
probe.httpBody = try MCPProtocol.request(id: 1, method: "initialize", params: [
|
||||
"protocolVersion": MCPProtocol.version, "capabilities": [:],
|
||||
"clientInfo": ["name": "tinycast", "version": "1"]])
|
||||
probe.setValue("application/json", forHTTPHeaderField: "Content-Type")
|
||||
probe.setValue("application/json, text/event-stream", forHTTPHeaderField: "Accept")
|
||||
let (_, response) = try await MCPOAuthHTTP.send(probe)
|
||||
let challenge = MCPOAuthChallenge.parse(response.value(forHTTPHeaderField: "WWW-Authenticate"))
|
||||
let resourceData = try await discoverDocument(MCPOAuth.protectedMetadataURLs(resource: resource, challenge: challenge))
|
||||
let protected = try MCPOAuth.parseResource(resourceData, expected: resource)
|
||||
let issuer = protected.authorization_servers[0]
|
||||
let serverData = try await discoverDocument(MCPOAuth.serverMetadataURLs(issuer: issuer))
|
||||
let metadata = try MCPOAuth.parseServer(serverData, issuer: issuer)
|
||||
var scope = challenge["scope"] ?? protected.scopes_supported?.joined(separator: " ")
|
||||
if metadata.scopes_supported?.contains("offline_access") == true {
|
||||
var scopes = (scope ?? "").split(separator: " ").map(String.init)
|
||||
if !scopes.contains("offline_access") { scopes.append("offline_access") }
|
||||
scope = scopes.joined(separator: " ")
|
||||
}
|
||||
return Discovery(resource: resource, metadata: metadata, scope: scope)
|
||||
}
|
||||
|
||||
private static func discoverDocument(_ urls: [URL]) async throws -> Data {
|
||||
for url in urls {
|
||||
let (data, response) = try await MCPOAuthHTTP.send(URLRequest(url: url, timeoutInterval: 15))
|
||||
if (200...299).contains(response.statusCode) { return data }
|
||||
guard response.statusCode == 404 || response.statusCode == 405 else { throw MCPOAuth.Failure.invalidMetadata }
|
||||
}
|
||||
throw MCPOAuth.Failure.invalidMetadata
|
||||
}
|
||||
|
||||
static func registration(
|
||||
for discovery: Discovery, credentials: MCPOAuth.Credentials
|
||||
) async throws -> MCPOAuth.Registration {
|
||||
let metadata = discovery.metadata
|
||||
if let stored = credentials.registration,
|
||||
stored.resource == discovery.resource, stored.issuer == metadata.issuer,
|
||||
stored.tokenEndpoint == metadata.token_endpoint, stored.redirectURI == MCPOAuthListener.redirectURI,
|
||||
credentials.clientID.isEmpty || (stored.clientID == credentials.clientID
|
||||
&& stored.clientSecret == credentials.clientSecret.nilIfEmpty) { return stored }
|
||||
if !credentials.clientID.isEmpty {
|
||||
if let stored = credentials.registration, stored.issuer != metadata.issuer {
|
||||
throw MCPOAuth.Failure.issuerChanged
|
||||
}
|
||||
let methods = metadata.token_endpoint_auth_methods_supported ?? ["client_secret_basic"]
|
||||
let method: String
|
||||
if credentials.clientSecret.isEmpty {
|
||||
method = "none"
|
||||
} else if methods.contains("client_secret_basic") {
|
||||
method = "client_secret_basic"
|
||||
} else if methods.contains("client_secret_post") {
|
||||
method = "client_secret_post"
|
||||
} else {
|
||||
throw MCPOAuth.Failure.invalidMetadata
|
||||
}
|
||||
return MCPOAuth.Registration(resource: discovery.resource, issuer: metadata.issuer,
|
||||
clientID: credentials.clientID, clientSecret: credentials.clientSecret.nilIfEmpty,
|
||||
tokenEndpoint: metadata.token_endpoint, authMethod: method,
|
||||
redirectURI: MCPOAuthListener.redirectURI)
|
||||
}
|
||||
guard let endpoint = metadata.registration_endpoint else { throw MCPOAuth.Failure.clientRequired }
|
||||
let body = try MCPOAuthRequest.registration(redirectURI: MCPOAuthListener.redirectURI)
|
||||
let data: Data
|
||||
do {
|
||||
data = try await MCPOAuthHTTP.json(MCPOAuth.endpoint(endpoint), body: body)
|
||||
} catch {
|
||||
try Task.checkCancellation()
|
||||
throw MCPOAuth.Failure.registration
|
||||
}
|
||||
struct Registered: Decodable {
|
||||
let client_id: String
|
||||
let client_secret: String?
|
||||
let token_endpoint_auth_method: String?
|
||||
let redirect_uris: [String]?
|
||||
}
|
||||
guard let response = try? JSONDecoder().decode(Registered.self, from: data), !response.client_id.isEmpty,
|
||||
response.redirect_uris?.contains(MCPOAuthListener.redirectURI) ?? true,
|
||||
response.token_endpoint_auth_method == nil || response.token_endpoint_auth_method == "none"
|
||||
else { throw MCPOAuth.Failure.registration }
|
||||
return MCPOAuth.Registration(resource: discovery.resource, issuer: metadata.issuer,
|
||||
clientID: response.client_id, clientSecret: response.client_secret,
|
||||
tokenEndpoint: metadata.token_endpoint, authMethod: "none",
|
||||
redirectURI: MCPOAuthListener.redirectURI)
|
||||
}
|
||||
|
||||
static func token(
|
||||
registration: MCPOAuth.Registration, code: String? = nil, verifier: String? = nil,
|
||||
previous: MCPOAuth.Token? = nil
|
||||
) async throws -> MCPOAuth.Token {
|
||||
let request = try MCPOAuthRequest.token(registration: registration, code: code,
|
||||
verifier: verifier, previous: previous)
|
||||
let (data, response) = try await MCPOAuthHTTP.send(request)
|
||||
guard (200...299).contains(response.statusCode) else {
|
||||
throw MCPOAuth.tokenFailure(status: response.statusCode)
|
||||
}
|
||||
return try MCPOAuth.parseToken(data, previous: previous, now: Date())
|
||||
}
|
||||
|
||||
static func random() throws -> Data {
|
||||
var bytes = [UInt8](repeating: 0, count: 32)
|
||||
guard SecRandomCopyBytes(kSecRandomDefault, bytes.count, &bytes) == errSecSuccess else {
|
||||
throw MCPOAuth.Failure.invalidCallback
|
||||
}
|
||||
return Data(bytes)
|
||||
}
|
||||
|
||||
static func pkce() throws -> (verifier: String, challenge: String) {
|
||||
MCPOAuth.pkce(entropy: try random()) { Data(SHA256.hash(data: $0)) }
|
||||
}
|
||||
}
|
||||
|
||||
private extension String {
|
||||
var nilIfEmpty: String? { isEmpty ? nil : self }
|
||||
}
|
||||
@@ -5,13 +5,14 @@ struct MCPSecretStore: Sendable {
|
||||
struct Secrets: Codable, Equatable, Sendable {
|
||||
var headerValue: String
|
||||
var environment: [String: String]
|
||||
var oauth: MCPOAuth.Credentials?
|
||||
|
||||
init(headerValue: String = "", environment: [String: String] = [:]) {
|
||||
self.headerValue = headerValue
|
||||
self.environment = environment
|
||||
}
|
||||
|
||||
var isEmpty: Bool { headerValue.isEmpty && environment.isEmpty }
|
||||
var isEmpty: Bool { headerValue.isEmpty && environment.isEmpty && oauth == nil }
|
||||
}
|
||||
|
||||
private let keychain: KeychainSecretStore
|
||||
|
||||
@@ -8,12 +8,15 @@ final class MCPServerConnection {
|
||||
private(set) var status: MCPServerStatus = .stopped
|
||||
private(set) var tools: [MCPTool] = []
|
||||
|
||||
@ObservationIgnored private let oauth: MCPOAuthManager?
|
||||
@ObservationIgnored let server: MCPServer
|
||||
@ObservationIgnored private let secrets: MCPSecretStore.Secrets
|
||||
@ObservationIgnored private var transport: (any MCPTransport)?
|
||||
@ObservationIgnored private var generation = UUID()
|
||||
@ObservationIgnored private var listTask: Task<Void, Never>?
|
||||
|
||||
init(server: MCPServer, secrets: MCPSecretStore.Secrets) {
|
||||
init(server: MCPServer, secrets: MCPSecretStore.Secrets, oauth: MCPOAuthManager? = nil) {
|
||||
self.oauth = oauth
|
||||
self.server = server
|
||||
self.secrets = secrets
|
||||
}
|
||||
@@ -21,7 +24,7 @@ final class MCPServerConnection {
|
||||
/// A failed server is startable again: the next visit to chat is where a blip gets retried.
|
||||
var isIdle: Bool {
|
||||
switch status {
|
||||
case .stopped, .failed: return true
|
||||
case .stopped, .failed, .signInRequired: return true
|
||||
case .connecting, .ready: return false
|
||||
}
|
||||
}
|
||||
@@ -29,29 +32,43 @@ final class MCPServerConnection {
|
||||
func start() async {
|
||||
guard isIdle else { return }
|
||||
status = .connecting
|
||||
let generation = generation
|
||||
do {
|
||||
let transport = try makeTransport()
|
||||
self.transport = transport
|
||||
try await transport.connect()
|
||||
_ = try await transport.request("initialize", Self.handshake)
|
||||
guard self.generation == generation, !Task.isCancelled else { return }
|
||||
try transport.notify("notifications/initialized", nil)
|
||||
let result = try await transport.request("tools/list")
|
||||
guard self.generation == generation, !Task.isCancelled else { return }
|
||||
tools = MCPTool.list(
|
||||
try await transport.request("tools/list"), serverID: server.id,
|
||||
result, serverID: server.id,
|
||||
serverSlug: server.slug, serverTitle: server.title)
|
||||
status = .ready(tools: tools.count)
|
||||
} catch MCPOAuth.Failure.signInRequired {
|
||||
guard self.generation == generation else { return }
|
||||
requireSignIn()
|
||||
} catch {
|
||||
guard self.generation == generation else { return }
|
||||
fail(error.localizedDescription)
|
||||
}
|
||||
}
|
||||
|
||||
func call(_ name: String, arguments: JSONValue) async throws -> (String, Bool) {
|
||||
guard let transport, status.isReady else { throw MCPTransportError.notRunning }
|
||||
let result = try await transport.request(
|
||||
"tools/call", ["name": name, "arguments": arguments.jsonObject])
|
||||
return MCPToolOutput.flatten(result)
|
||||
do {
|
||||
let result = try await transport.request(
|
||||
"tools/call", ["name": name, "arguments": arguments.jsonObject])
|
||||
return MCPToolOutput.flatten(result)
|
||||
} catch MCPOAuth.Failure.signInRequired {
|
||||
requireSignIn()
|
||||
throw MCPOAuth.Failure.signInRequired
|
||||
}
|
||||
}
|
||||
|
||||
func stop() {
|
||||
generation = UUID()
|
||||
listTask?.cancel()
|
||||
listTask = nil
|
||||
transport?.close()
|
||||
@@ -60,6 +77,12 @@ final class MCPServerConnection {
|
||||
status = .stopped
|
||||
}
|
||||
|
||||
private func requireSignIn() {
|
||||
stop()
|
||||
status = .signInRequired
|
||||
oauth?.requireSignIn(server)
|
||||
}
|
||||
|
||||
private func fail(_ message: String) {
|
||||
transport?.close()
|
||||
transport = nil
|
||||
@@ -70,8 +93,17 @@ final class MCPServerConnection {
|
||||
private func makeTransport() throws -> any MCPTransport {
|
||||
switch server.transport {
|
||||
case .http(let url, let headerName):
|
||||
var authorization: ((String?) async throws -> String)?
|
||||
if server.oauth == true {
|
||||
let server = server
|
||||
authorization = { [weak oauth] rejected in
|
||||
guard let oauth else { throw MCPOAuth.Failure.signInRequired }
|
||||
return try await oauth.accessToken(for: server, rejectedToken: rejected)
|
||||
}
|
||||
}
|
||||
let transport = try MCPHTTPTransport(
|
||||
url: url, headerName: headerName, headerValue: secrets.headerValue)
|
||||
url: url, headerName: headerName, headerValue: secrets.headerValue,
|
||||
authorization: authorization)
|
||||
transport.onNotification = { [weak self] method, _ in self?.received(method) }
|
||||
return transport
|
||||
case .stdio(let command, let arguments, _):
|
||||
@@ -88,13 +120,18 @@ final class MCPServerConnection {
|
||||
guard method == "notifications/tools/list_changed", listTask == nil else { return }
|
||||
listTask = Task { [weak self] in
|
||||
defer { self?.listTask = nil }
|
||||
guard let self, let transport = self.transport,
|
||||
let listed = try? await transport.request("tools/list")
|
||||
else { return }
|
||||
self.tools = MCPTool.list(
|
||||
listed, serverID: self.server.id, serverSlug: self.server.slug,
|
||||
serverTitle: self.server.title)
|
||||
self.status = .ready(tools: self.tools.count)
|
||||
guard let self, let transport = self.transport else { return }
|
||||
let generation = self.generation
|
||||
do {
|
||||
let listed = try await transport.request("tools/list")
|
||||
guard self.generation == generation, !Task.isCancelled else { return }
|
||||
self.tools = MCPTool.list(
|
||||
listed, serverID: self.server.id, serverSlug: self.server.slug,
|
||||
serverTitle: self.server.title)
|
||||
self.status = .ready(tools: self.tools.count)
|
||||
} catch MCPOAuth.Failure.signInRequired {
|
||||
if self.generation == generation { self.requireSignIn() }
|
||||
} catch { return }
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -7,13 +7,15 @@ import Observation
|
||||
final class MCPServerManager {
|
||||
private(set) var connections: [UUID: MCPServerConnection] = [:]
|
||||
|
||||
@ObservationIgnored private let oauth: MCPOAuthManager?
|
||||
@ObservationIgnored private let secrets: MCPSecretStore
|
||||
@ObservationIgnored private var idleTask: Task<Void, Never>?
|
||||
|
||||
/// Servers outlive one summon but not an afternoon; a resident helper is the memory budget.
|
||||
private static let idleTimeout: Duration = .seconds(600)
|
||||
|
||||
init(secrets: MCPSecretStore = MCPSecretStore()) {
|
||||
init(secrets: MCPSecretStore = MCPSecretStore(), oauth: MCPOAuthManager? = nil) {
|
||||
self.oauth = oauth
|
||||
self.secrets = secrets
|
||||
}
|
||||
|
||||
@@ -35,7 +37,7 @@ final class MCPServerManager {
|
||||
for server in servers {
|
||||
guard let existing = connections[server.id] else {
|
||||
let connection = MCPServerConnection(
|
||||
server: server, secrets: secrets.secrets(for: server.id))
|
||||
server: server, secrets: secrets.secrets(for: server.id), oauth: oauth)
|
||||
connections[server.id] = connection
|
||||
Task { await connection.start() }
|
||||
continue
|
||||
@@ -50,6 +52,10 @@ final class MCPServerManager {
|
||||
connections.values.first { $0.server.slug == slug }
|
||||
}
|
||||
|
||||
func disconnect(_ id: UUID) {
|
||||
connections.removeValue(forKey: id)?.stop()
|
||||
}
|
||||
|
||||
func stop() {
|
||||
idleTask?.cancel()
|
||||
idleTask = nil
|
||||
|
||||
@@ -38,6 +38,7 @@ enum MCPTransportError: LocalizedError, Equatable {
|
||||
/// What a Settings row shows, and what decides whether a server's tools are on offer.
|
||||
enum MCPServerStatus: Equatable, Sendable {
|
||||
case stopped
|
||||
case signInRequired
|
||||
case connecting
|
||||
case ready(tools: Int)
|
||||
case failed(String)
|
||||
@@ -50,6 +51,7 @@ enum MCPServerStatus: Equatable, Sendable {
|
||||
var label: String {
|
||||
switch self {
|
||||
case .stopped: return "Stopped"
|
||||
case .signInRequired: return "Sign-in required"
|
||||
case .connecting: return "Connecting…"
|
||||
case .ready(let tools): return tools == 1 ? "1 tool" : "\(tools) tools"
|
||||
case .failed(let message): return message
|
||||
|
||||
@@ -8,6 +8,7 @@ struct MCPServerEditorTarget: Identifiable {
|
||||
|
||||
/// Adds or edits one server, and can prove it connects before the panel is dismissed.
|
||||
struct MCPServerEditor: View {
|
||||
@Environment(MCPCoordinator.self) private var coordinator
|
||||
let target: MCPServerEditorTarget
|
||||
let onSave: (MCPServer, MCPSecretStore.Secrets) -> String?
|
||||
let onCancel: () -> Void
|
||||
@@ -27,6 +28,12 @@ struct MCPServerEditor: View {
|
||||
case failed(String)
|
||||
}
|
||||
|
||||
@State private var usesOAuth: Bool
|
||||
@State private var clientID: String
|
||||
@State private var clientSecret: String
|
||||
// The status label's copy: a view body never reads the Keychain, actions read it fresh.
|
||||
@State private var storedOAuth: MCPOAuth.Credentials?
|
||||
@State private var operation: Task<Void, Never>?
|
||||
@State private var name: String
|
||||
@State private var kind: Kind
|
||||
@State private var url: String
|
||||
@@ -50,6 +57,10 @@ struct MCPServerEditor: View {
|
||||
self.onCancel = onCancel
|
||||
let server = target.server
|
||||
let secrets = target.isNew ? MCPSecretStore.Secrets() : MCPSecretStore().secrets(for: server.id)
|
||||
_usesOAuth = State(initialValue: server.oauth == true)
|
||||
_clientID = State(initialValue: secrets.oauth?.clientID ?? "")
|
||||
_clientSecret = State(initialValue: secrets.oauth?.clientSecret ?? "")
|
||||
_storedOAuth = State(initialValue: secrets.oauth)
|
||||
_name = State(initialValue: server.name)
|
||||
_isEnabled = State(initialValue: server.isEnabled)
|
||||
_trust = State(initialValue: server.trust)
|
||||
@@ -105,13 +116,24 @@ struct MCPServerEditor: View {
|
||||
TextField("URL", text: $url, prompt: Text("https://example.com/mcp"))
|
||||
.settingsEditorTextField()
|
||||
}
|
||||
field("Header") {
|
||||
TextField("Header", text: $headerName, prompt: Text("Authorization"))
|
||||
.settingsEditorTextField()
|
||||
field("Authentication") {
|
||||
Picker("Authentication", selection: $usesOAuth) {
|
||||
Text("Header").tag(false)
|
||||
Text("OAuth").tag(true)
|
||||
}
|
||||
.labelsHidden()
|
||||
}
|
||||
field("Value") {
|
||||
SecureField("Value", text: $headerValue, prompt: Text("Bearer …"))
|
||||
.settingsEditorTextField()
|
||||
if usesOAuth {
|
||||
oauthFields
|
||||
} else {
|
||||
field("Header") {
|
||||
TextField("Header", text: $headerName, prompt: Text("Authorization"))
|
||||
.settingsEditorTextField()
|
||||
}
|
||||
field("Value") {
|
||||
SecureField("Value", text: $headerValue, prompt: Text("Bearer …"))
|
||||
.settingsEditorTextField()
|
||||
}
|
||||
}
|
||||
} else {
|
||||
field("Command") {
|
||||
@@ -138,7 +160,7 @@ struct MCPServerEditor: View {
|
||||
} footer: {
|
||||
Text(
|
||||
kind == .http
|
||||
? "Remote endpoints must use HTTPS. The header value is stored in your "
|
||||
? "Remote endpoints must use HTTPS. Credentials are stored in your "
|
||||
+ "login Keychain, never in preferences."
|
||||
: "The command runs on this Mac with your own account. One "
|
||||
+ "NAME=value per line; values are stored in your login Keychain."
|
||||
@@ -157,7 +179,7 @@ struct MCPServerEditor: View {
|
||||
}
|
||||
HStack(spacing: Theme.Spacing.lg) {
|
||||
Button("Test Connection", action: test)
|
||||
.disabled(probe == .running)
|
||||
.disabled(operation != nil)
|
||||
probeLabel
|
||||
}
|
||||
if let error {
|
||||
@@ -181,6 +203,7 @@ struct MCPServerEditor: View {
|
||||
.buttonStyle(.modalAction(.cancel))
|
||||
.keyboardShortcut(.cancelAction)
|
||||
Button("Save", action: save)
|
||||
.disabled(operation != nil)
|
||||
.buttonStyle(.modalAction(.primary))
|
||||
.keyboardShortcut(.defaultAction)
|
||||
}
|
||||
@@ -188,6 +211,91 @@ struct MCPServerEditor: View {
|
||||
}
|
||||
.frame(width: 620, height: 560)
|
||||
.settingsEditorPanelSurface()
|
||||
.onDisappear {
|
||||
operation?.cancel()
|
||||
coordinator.cancelSignIn(target.server.id)
|
||||
if target.isNew { coordinator.discardUnsaved(target.server.id) }
|
||||
}
|
||||
.onChange(of: url) { cancelOperation() }
|
||||
.onChange(of: usesOAuth) { cancelOperation() }
|
||||
.onChange(of: kind) { cancelOperation() }
|
||||
.onChange(of: clientID) { cancelOperation() }
|
||||
.onChange(of: clientSecret) { cancelOperation() }
|
||||
}
|
||||
|
||||
private var oauthFields: some View {
|
||||
Group {
|
||||
field("Client ID") {
|
||||
TextField("Client ID", text: $clientID, prompt: Text("Optional — register automatically"))
|
||||
.settingsEditorTextField()
|
||||
}
|
||||
field("Client secret") {
|
||||
SecureField("Client secret", text: $clientSecret, prompt: Text("Optional"))
|
||||
.settingsEditorTextField()
|
||||
}
|
||||
field("Sign-in") {
|
||||
HStack(spacing: Theme.Spacing.lg) {
|
||||
switch authenticationStatus {
|
||||
case .signedIn: Button("Sign Out", action: signOut).disabled(operation != nil)
|
||||
case .signingIn: Button("Cancel", action: cancelOperation)
|
||||
default: Button("Sign In", action: signIn).disabled(operation != nil)
|
||||
}
|
||||
Text(authenticationStatus.label)
|
||||
.font(.caption).foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private var supplied: MCPOAuth.Credentials { .supplied(clientID: clientID, clientSecret: clientSecret) }
|
||||
|
||||
private var authenticationStatus: MCPOAuthManager.Status {
|
||||
var server = target.server
|
||||
server.transport = .http(
|
||||
url: url.trimmingCharacters(in: .whitespaces),
|
||||
headerName: headerName.trimmingCharacters(in: .whitespaces))
|
||||
let status = coordinator.authenticationStatus(server, stored: storedOAuth)
|
||||
let supplied = supplied
|
||||
guard storedOAuth?.clientID == supplied.clientID, storedOAuth?.clientSecret == supplied.clientSecret,
|
||||
storedOAuth?.registration?.resource == (try? MCPOAuth.resource(url)) else {
|
||||
if case .signingIn = status { return status }
|
||||
if case .failed = status { return status }
|
||||
return .signedOut
|
||||
}
|
||||
return status
|
||||
}
|
||||
|
||||
private func signIn() {
|
||||
if let message = validate() { error = message; return }
|
||||
error = nil
|
||||
let draft = draft
|
||||
guard let credentials = draft.secrets.oauth else { return }
|
||||
operation = Task {
|
||||
defer { operation = nil }
|
||||
do {
|
||||
try await coordinator.signIn(draft.server, credentials: credentials)
|
||||
storedOAuth = MCPSecretStore().secrets(for: target.server.id).oauth
|
||||
} catch is CancellationError {
|
||||
return
|
||||
} catch {
|
||||
self.error = error.localizedDescription
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func signOut() {
|
||||
do {
|
||||
try coordinator.signOut(target.server.id)
|
||||
storedOAuth?.token = nil
|
||||
probe = .idle
|
||||
error = nil
|
||||
} catch { self.error = "The credentials could not be removed from your login Keychain." }
|
||||
}
|
||||
|
||||
private func cancelOperation() {
|
||||
operation?.cancel()
|
||||
coordinator.cancelSignIn(target.server.id)
|
||||
probe = .idle
|
||||
}
|
||||
|
||||
@ViewBuilder private var probeLabel: some View {
|
||||
@@ -224,8 +332,21 @@ struct MCPServerEditor: View {
|
||||
server.transport = .http(
|
||||
url: url.trimmingCharacters(in: .whitespaces),
|
||||
headerName: headerName.trimmingCharacters(in: .whitespaces))
|
||||
return (server, MCPSecretStore.Secrets(headerValue: headerValue))
|
||||
server.oauth = usesOAuth ? true : nil
|
||||
var secrets = MCPSecretStore.Secrets(headerValue: usesOAuth ? "" : headerValue)
|
||||
if usesOAuth {
|
||||
var credentials = MCPSecretStore().secrets(for: server.id).oauth ?? MCPOAuth.Credentials()
|
||||
let supplied = supplied
|
||||
if credentials.clientID != supplied.clientID || credentials.clientSecret != supplied.clientSecret {
|
||||
credentials = supplied
|
||||
}
|
||||
if let registration = credentials.registration,
|
||||
registration.resource != (try? MCPOAuth.resource(url)) { credentials.token = nil }
|
||||
secrets.oauth = credentials
|
||||
}
|
||||
return (server, secrets)
|
||||
case .stdio:
|
||||
server.oauth = nil
|
||||
server.transport = .stdio(
|
||||
command: command.trimmingCharacters(in: .whitespaces),
|
||||
arguments: Self.arguments(from: argumentText),
|
||||
@@ -243,15 +364,16 @@ struct MCPServerEditor: View {
|
||||
error = nil
|
||||
probe = .running
|
||||
let draft = draft
|
||||
Task {
|
||||
let connection = MCPServerConnection(server: draft.server, secrets: draft.secrets)
|
||||
await connection.start()
|
||||
switch connection.status {
|
||||
operation = Task {
|
||||
defer { operation = nil }
|
||||
let status = await coordinator.test(draft.server, secrets: draft.secrets)
|
||||
guard !Task.isCancelled else { return }
|
||||
switch status {
|
||||
case .ready(let tools): probe = .found(tools)
|
||||
case .failed(let message): probe = .failed(message)
|
||||
case .signInRequired: probe = .failed("Sign-in required")
|
||||
default: probe = .failed("The server did not answer.")
|
||||
}
|
||||
connection.stop()
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -2,11 +2,12 @@ import SwiftUI
|
||||
|
||||
/// Settings → AI's MCP half: the switch, the servers, and what each one is doing right now.
|
||||
struct MCPSettingsSection: View {
|
||||
@Environment(AppCore.self) private var core
|
||||
@Environment(MCPCoordinator.self) private var coordinator
|
||||
@Environment(AppSettings.self) private var appSettings
|
||||
@Environment(MCPSettingsStore.self) private var store
|
||||
@State private var editor: MCPServerEditorTarget?
|
||||
@State private var pendingRemoval: MCPServer?
|
||||
@State private var removalError: String?
|
||||
|
||||
var body: some View {
|
||||
@Bindable var appSettings = appSettings
|
||||
@@ -21,7 +22,7 @@ struct MCPSettingsSection: View {
|
||||
} else {
|
||||
ForEach(store.servers) { server in
|
||||
MCPServerRow(
|
||||
server: server, status: core.mcp.status(of: server.id),
|
||||
server: server, status: coordinator.status(of: server.id),
|
||||
onEdit: { editor = MCPServerEditorTarget(server: server, isNew: false) },
|
||||
onRemove: { pendingRemoval = server })
|
||||
}
|
||||
@@ -37,6 +38,10 @@ struct MCPSettingsSection: View {
|
||||
}
|
||||
}
|
||||
.settingsEnabled(appSettings.mcpEnabled)
|
||||
if let removalError {
|
||||
Label(removalError, systemImage: "exclamationmark.triangle")
|
||||
.foregroundStyle(.orange)
|
||||
}
|
||||
} header: {
|
||||
SettingsSectionHeader(.aiMCPServers)
|
||||
} footer: {
|
||||
@@ -64,21 +69,22 @@ struct MCPSettingsSection: View {
|
||||
/// A returned message is shown in the panel; nil closes it.
|
||||
private func save(_ server: MCPServer, _ secrets: MCPSecretStore.Secrets) -> String? {
|
||||
do {
|
||||
try MCPSecretStore().save(secrets, for: server.id)
|
||||
try coordinator.save(server, secrets: secrets)
|
||||
} catch {
|
||||
return "The credentials could not be saved to your login Keychain."
|
||||
}
|
||||
store.save(server)
|
||||
editor = nil
|
||||
core.mcpCoordinator.applyEnabled()
|
||||
return nil
|
||||
}
|
||||
|
||||
private func remove(_ server: MCPServer) {
|
||||
try? MCPSecretStore().remove(for: server.id)
|
||||
store.remove(id: server.id)
|
||||
pendingRemoval = nil
|
||||
core.mcpCoordinator.applyEnabled()
|
||||
do {
|
||||
try coordinator.remove(server.id)
|
||||
removalError = nil
|
||||
} catch {
|
||||
removalError = "\(server.title) was kept: its credentials could not be removed from your login Keychain."
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
import Foundation
|
||||
import Observation
|
||||
|
||||
/// MCP's action surface: what is running, what the model may call, and who is asked first.
|
||||
@MainActor
|
||||
@Observable
|
||||
final class MCPCoordinator {
|
||||
private let settings: AppSettings
|
||||
private let store: MCPSettingsStore
|
||||
@@ -9,7 +11,7 @@ final class MCPCoordinator {
|
||||
private unowned let core: AppCore
|
||||
|
||||
/// Servers this conversation has already been asked about; a new chat asks again.
|
||||
private var chatGrants: (chat: UUID, servers: Set<UUID>) = (UUID(), [])
|
||||
@ObservationIgnored private var chatGrants: (chat: UUID, servers: Set<UUID>) = (UUID(), [])
|
||||
|
||||
init(
|
||||
settings: AppSettings, store: MCPSettingsStore, manager: MCPServerManager, core: AppCore
|
||||
@@ -25,6 +27,7 @@ final class MCPCoordinator {
|
||||
/// Off means off: no connection, no resident process, and nothing offered to a model.
|
||||
func applyEnabled() {
|
||||
guard isActive else {
|
||||
core.mcpOAuth.stop()
|
||||
manager.stop()
|
||||
return
|
||||
}
|
||||
@@ -78,6 +81,59 @@ final class MCPCoordinator {
|
||||
}
|
||||
}
|
||||
|
||||
func signIn(_ server: MCPServer, credentials: MCPOAuth.Credentials) async throws {
|
||||
guard isActive else { throw MCPOAuth.Failure.signInRequired }
|
||||
manager.disconnect(server.id)
|
||||
try await core.mcpOAuth.signIn(server: server, credentials: credentials)
|
||||
}
|
||||
|
||||
func signOut(_ id: UUID) throws {
|
||||
manager.disconnect(id)
|
||||
try core.mcpOAuth.signOut(id)
|
||||
}
|
||||
|
||||
func cancelSignIn(_ id: UUID) { core.mcpOAuth.cancelSignIn(id) }
|
||||
|
||||
func status(of id: UUID) -> MCPServerStatus { manager.status(of: id) }
|
||||
|
||||
func save(_ server: MCPServer, secrets: MCPSecretStore.Secrets) throws {
|
||||
try MCPSecretStore().save(secrets, for: server.id)
|
||||
core.mcpOAuth.cancelSignIn(server.id)
|
||||
manager.disconnect(server.id)
|
||||
store.save(server)
|
||||
applyEnabled()
|
||||
}
|
||||
|
||||
func remove(_ id: UUID) throws {
|
||||
core.mcpOAuth.cancelSignIn(id)
|
||||
try MCPSecretStore().remove(for: id)
|
||||
store.remove(id: id)
|
||||
applyEnabled()
|
||||
}
|
||||
|
||||
func discardUnsaved(_ id: UUID) {
|
||||
cancelSignIn(id)
|
||||
if store.server(id: id) == nil { try? MCPSecretStore().remove(for: id) }
|
||||
}
|
||||
|
||||
func authenticationStatus(
|
||||
_ server: MCPServer, stored: MCPOAuth.Credentials?
|
||||
) -> MCPOAuthManager.Status {
|
||||
core.mcpOAuth.status(for: server, stored: stored)
|
||||
}
|
||||
|
||||
func test(_ server: MCPServer, secrets: MCPSecretStore.Secrets) async -> MCPServerStatus {
|
||||
if server.oauth == true {
|
||||
let stored = MCPSecretStore().secrets(for: server.id).oauth
|
||||
guard stored?.clientID == secrets.oauth?.clientID,
|
||||
stored?.clientSecret == secrets.oauth?.clientSecret else { return .signInRequired }
|
||||
}
|
||||
let connection = MCPServerConnection(server: server, secrets: secrets, oauth: core.mcpOAuth)
|
||||
defer { connection.stop() }
|
||||
await connection.start()
|
||||
return connection.status
|
||||
}
|
||||
|
||||
private func isPermitted(_ server: MCPServer, tool: String, in chat: UUID) async -> Bool {
|
||||
if chatGrants.chat != chat { chatGrants = (chat, []) }
|
||||
switch MCPTrustPolicy.decide(
|
||||
|
||||
@@ -416,6 +416,7 @@ extension View {
|
||||
.environment(core.calendarStore)
|
||||
.environment(core.aiSettings)
|
||||
.environment(core.mcpSettings)
|
||||
.environment(core.mcpCoordinator)
|
||||
.environment(core.quickActionSettings)
|
||||
.environment(core.customQuickActions)
|
||||
.environment(core.chatGPTSubscription)
|
||||
|
||||
@@ -36,6 +36,12 @@ depends on neither, and Quick Actions carries its own route rather than borrowin
|
||||
- **Remote endpoints require HTTPS.** Plain HTTP is accepted only for `localhost`, `127.0.0.1` and
|
||||
`::1`, where a key is optional, and any other scheme is rejected outright — a loopback host does
|
||||
not excuse `ftp://`. `AIEndpointPolicy` is the one place that decides this.
|
||||
- **MCP OAuth is separate from the model provider's login.** The HTTP MCP client can sign into a
|
||||
hosted server from Settings and keep its client credentials and tokens in that server's Keychain
|
||||
item. API chat uses this session without receiving its credentials. A rejected refresh becomes
|
||||
Sign-in required on the server, an unserved one does not end the session, and either way a
|
||||
running tool loop receives an explainable failure result.
|
||||
This does not offer Tinycast's MCP servers to installed CLI or on-device routes. See [MCP](mcp.md).
|
||||
- **The chat model is the routing decision.** It names the on-device model, a model exposed by the
|
||||
installed Codex, Claude, Grok, OpenCode or Cursor command, or one saved API connection and model. Installed
|
||||
routes also carry their reasoning effort when the selected model supports one. A removed route
|
||||
|
||||
+60
-9
@@ -15,13 +15,25 @@ the two meet.
|
||||
only consumer. Both flags and `mcpServers` are excluded from settings backups — a server list is a
|
||||
source of executable code and a destination for chat context, and the flag doubles as consent to
|
||||
run it, so an import can never arrive having connected one.
|
||||
- **Credentials live only in the login Keychain.** `MCPServer` persists the endpoint, the header
|
||||
*name*, the command, its arguments and its environment variable *names* in `UserDefaults`; it never
|
||||
contains a secret. The HTTP header value and every environment value are one JSON item per server
|
||||
under `KeychainSecretStore.mcpSecrets`, and never enter logs, errors or backups.
|
||||
- **Credentials live only in the login Keychain.** `MCPServer` persists the endpoint, authentication
|
||||
mode, the header *name*, the command, its arguments and its environment variable *names* in
|
||||
`UserDefaults`; it never contains a secret. The HTTP header value, environment values, OAuth client
|
||||
registration and tokens are one JSON item per server under `KeychainSecretStore.mcpSecrets`, and
|
||||
never enter logs, errors or backups.
|
||||
- **Remote endpoints require HTTPS**, through the same `AIEndpointPolicy.validate` the AI providers
|
||||
use — plain HTTP only for `localhost`, `127.0.0.1` and `::1`, and no other scheme at all. There is
|
||||
one place that decides this and MCP does not get a second one.
|
||||
- **OAuth tokens belong to one configured MCP endpoint.** Editing its URL cannot lend its token to
|
||||
the new destination. Discovery validates every endpoint; private ephemeral sessions have no URL,
|
||||
cookie or credential cache. OAuth endpoints — discovery, registration, token — refuse every
|
||||
redirect. An MCP endpoint may redirect within its own origin, the `/mcp` to `/mcp/` that Python
|
||||
servers answer with, and the credentials `URLSession` would strip are restored because the origin
|
||||
is unchanged; a redirect to any other origin is refused, so no credential ever reaches a second
|
||||
host. Refresh tokens go only to the token endpoint retained with their client registration.
|
||||
- **OAuth sign-in is explicit.** Settings opens the browser after discovery and PKCE S256 checks.
|
||||
The callback binds only `127.0.0.1:4962`, validates state and any issuer parameter, accepts one
|
||||
response and closes. Cancellation, disabling AI/MCP and the five-minute timeout also close it.
|
||||
An occupied port fails instead of choosing another port. No custom URL scheme is involved.
|
||||
- **A tool call never enters the conversation.** The whole call-and-result round trip lives inside
|
||||
one turn, in `AIToolLoopProvider`, and what `ChatSession` keeps is a `ChatToolUse` render record —
|
||||
exactly what `ChatSearch` already is. That is deliberate: a stored `tool_call` separated from its
|
||||
@@ -54,7 +66,8 @@ the two meet.
|
||||
with a JSON-RPC error. The client advertises no capabilities in `initialize`.
|
||||
- **`Model/` stays Foundation-only.** `mcp-test` compiles the shipped models and pins the framing,
|
||||
handles, tool names, output flattening, trust and addressing; `mcp-stdio-test` drives a real
|
||||
subprocess.
|
||||
subprocess. `mcp-oauth-test` pins OAuth parsing, PKCE, endpoint binding, callback lifetime,
|
||||
dynamic and supplied client registration, refresh coalescing, redirects and bounded 401 recovery.
|
||||
|
||||
## Transports
|
||||
|
||||
@@ -79,6 +92,38 @@ The command is found by `Platform/ExecutableLocator`, which asks a login shell f
|
||||
walks PATH, the usual install prefixes and every nvm Node version — a GUI app inherits Finder's PATH,
|
||||
which has none of `npx`, `uvx` or `node` on it.
|
||||
|
||||
## HTTP OAuth
|
||||
|
||||
`AppCore` owns `MCPOAuthManager`; Settings and server connections use that same Keychain-backed
|
||||
session. `MCPOAuth` and `MCPOAuthRequest` hold Foundation-only protocol decisions, with entropy,
|
||||
hashing and time injected. `MCPOAuthService` performs discovery and exchanges; `MCPOAuthListener`
|
||||
owns the Network.framework loopback callback.
|
||||
|
||||
Sign In probes the MCP endpoint without credentials, reads the Bearer `resource_metadata` challenge,
|
||||
or tries path-specific then root RFC 9728 discovery. The first advertised authorization server is
|
||||
resolved through RFC 8414, with the OIDC discovery locations as fallbacks. Its issuer must match,
|
||||
a trailing slash aside — Google advertises one and publishes none — and it must advertise S256.
|
||||
A supplied client ID and optional secret take precedence, trimmed of the whitespace a paste brings;
|
||||
the secret goes as HTTP Basic, or in the form body when the server advertises
|
||||
`client_secret_post` and not Basic. Otherwise Tinycast uses RFC 7591 dynamic registration with a
|
||||
native public client. CIMD and device flow are not implemented.
|
||||
|
||||
The canonical configured MCP URL is the RFC 8707 `resource` on authorization and token requests.
|
||||
Protected-resource metadata may describe an ancestor path on the same origin, matching current MCP
|
||||
SDK behavior; sibling paths and other origins are rejected. Saved tokens remain bound to the exact
|
||||
configured endpoint. This is deliberately broader than RFC 9728's exact resource-match wording.
|
||||
|
||||
Access tokens refresh within 60 seconds of expiry. Concurrent requests share one refresh, and
|
||||
rotated refresh tokens replace the old token in the same Keychain item. Closing or saving the editor
|
||||
leaves a refresh in flight to finish, because a server that rotates refresh tokens may already have
|
||||
spent the old one; only Sign In and Sign Out discard it. A 401 permits one refresh
|
||||
and retry; a refresh the authorization server rejects (400 or 401) or a repeated 401 clears the live
|
||||
tool catalog and reports **Sign-in required**. A refresh that could not be served — offline, a
|
||||
timeout, a 5xx — is a network failure: the session is kept and the next request refreshes again.
|
||||
A failed tool call remains a tool result the model can explain. Sign Out disconnects the
|
||||
server and deletes its access and refresh tokens, retaining the client registration for the next
|
||||
sign-in; it does not revoke the provider-side grant.
|
||||
|
||||
## Tool names
|
||||
|
||||
`MCPToolName` is the one place a server's handle and a tool's own name become a single identifier the
|
||||
@@ -110,13 +155,19 @@ that must arrive as **one** user turn however many of them there are.
|
||||
|
||||
`MCPSettingsSection` is a section inside Settings → AI, the way `AICommandSection` is. Each row leads
|
||||
with the handle, because that is the half a reader has to type, then the live status and the
|
||||
transport. `MCPServerEditor` is the editor panel: name, HTTP or command, the credential, enabled, trust, and
|
||||
a Test Connection button that runs a real handshake so a typo is caught there rather than in the
|
||||
middle of a conversation.
|
||||
transport. `MCPServerEditor` reaches `MCPCoordinator` through its environment. The editor holds name,
|
||||
HTTP or command, Header or OAuth authentication, optional client ID/secret, one Sign In / Cancel / Sign Out button and live
|
||||
sign-in status, enabled, trust, and a Test Connection button that runs a real handshake so a typo is
|
||||
caught there rather than in the middle of a conversation.
|
||||
|
||||
## Manual sweep
|
||||
|
||||
- An HTTP server with a bearer header reports its tool count from Test Connection and from its row.
|
||||
- An OAuth-only server signs in through the browser with client fields empty when DCR is available;
|
||||
Test Connection and a BYOK chat use the session. Repeat with supplied client credentials.
|
||||
- Relaunch retains the sign-in, refresh retains connectivity, and Sign Out makes Test Connection
|
||||
report Sign-in required. Changing the endpoint never sends the old token to the new endpoint.
|
||||
- Cancelling sign-in or occupying port 4962 leaves no listener or stale successful sign-in behind.
|
||||
- A stdio server (`npx -y @modelcontextprotocol/server-filesystem ~/Desktop`) reaches ready; its
|
||||
process is gone ten minutes after the palette closes, and immediately on Quit.
|
||||
- A question answered with a tool shows the row inline, spinner then glyph, and the reply continues
|
||||
@@ -128,6 +179,6 @@ middle of a conversation.
|
||||
- On Apple Intelligence or a ChatGPT model, no tool is offered and the reply streams as before.
|
||||
- Switching MCP off, then AI off, leaves no server process resident.
|
||||
- A settings backup carries neither a server nor the flag.
|
||||
- Harnesses: `mcp-test` and `mcp-stdio-test`, plus the tool halves of `ai-provider-test`
|
||||
- Harnesses: `mcp-test`, `mcp-stdio-test` and `mcp-oauth-test`, plus the tool halves of `ai-provider-test`
|
||||
(catalog and turn encoding, fragmented argument decoding) and `ai-chat-test` (the loop, its cap,
|
||||
its output bounds, and tool-use persistence).
|
||||
|
||||
+6
-1
@@ -132,14 +132,19 @@ If a change touches anything in the right column, the harness on the left is man
|
||||
| `support-test` | `Support/Model/` — when the support reminder comes due, and a clock moved backwards |
|
||||
| `mcp-test` | `MCP/Model/` and `MCPSettingsStore` — JSON-RPC framing, handles, tool names, output flattening, trust, `@server` addressing |
|
||||
| `mcp-stdio-test` | `MCP/Service/` against a stub server — handshake, listing, calling, and every way one can go away |
|
||||
| `mcp-oauth-test` | OAuth parsing, RFC 7636 PKCE, discovery and resource binding, loopback callback validation/cancellation, dynamic registration, supplied client credentials and their token-endpoint authentication, Keychain token rotation, concurrent refresh, redirects and one-retry 401 handling |
|
||||
|
||||
The two harnesses that need a server to talk to bring their own: `Tests/ai-fixtures/codex-stub.js`
|
||||
The subprocess harnesses bring their own servers: `Tests/ai-fixtures/codex-stub.js`
|
||||
and `mcp-stub.js`, each copied into a scratch directory and put in front of PATH so the locator finds
|
||||
it the way it would find a real one. Both read fd 0 synchronously rather than through a stream —
|
||||
`codex-stub.js` stalls mid-turn on purpose, and an event loop would read the next line while it is
|
||||
still holding — and both write with `fs.writeSync`, so a reply is on the pipe before a mode that
|
||||
exits does.
|
||||
|
||||
`mcp-oauth-test` starts `Tests/ai-fixtures/mcp-oauth-stub.js` on `127.0.0.1:4963` and tests the
|
||||
single-use callback on `127.0.0.1:4962`. Both ports must be free; the harness never chooses another
|
||||
port. Its Keychain scope is unique to each run and removed on completion.
|
||||
|
||||
A harness that passed before a change passes after it. There is no "I'll fix it next commit" and no
|
||||
commenting out a case. If a change genuinely invalidates an assertion, the assertion is rewritten in the
|
||||
same commit with the reason in the message.
|
||||
|
||||
@@ -20,7 +20,9 @@ process runs.
|
||||
| Name | Anything. It becomes the server's **handle**, like `@github`. |
|
||||
| Connection | **HTTP** for a remote server, or **Command** for one that runs on your Mac. |
|
||||
| URL | For HTTP. Remote servers must use HTTPS; plain HTTP only for `localhost`. |
|
||||
| Header | For HTTP. A header name and value, like `Authorization` and `Bearer …`. |
|
||||
| Authentication | For HTTP. **Header** for a static credential, or **OAuth** for browser sign-in. |
|
||||
| Header | With Header authentication. A name and value, like `Authorization` and `Bearer …`. |
|
||||
| Client ID / secret | With OAuth. Leave blank for automatic registration, or enter the credentials supplied by your server provider. |
|
||||
| Command | For a local server, like `npx`. |
|
||||
| Arguments | Like `-y @modelcontextprotocol/server-filesystem ~/Desktop`. |
|
||||
| Environment | `NAME=value`, one per line, like `GITHUB_TOKEN=…`. |
|
||||
@@ -29,11 +31,31 @@ process runs.
|
||||
**Test Connection** runs a real handshake and reports how many tools the server offers, so a typo
|
||||
shows up here instead of halfway through a conversation.
|
||||
|
||||
**Header values and environment values are stored in your login Keychain**, never in preferences,
|
||||
**Header values, environment values and OAuth credentials are stored in your login Keychain**, never in preferences,
|
||||
logs or backups. Removing a server deletes them.
|
||||
|
||||
A local server runs with your own user account, so only add commands you trust.
|
||||
|
||||
### Signing into an OAuth server
|
||||
|
||||
Choose **HTTP**, paste the server's MCP URL, and select **OAuth**. Choose **Sign In**, complete the
|
||||
provider's browser sign-in and review its access request. Return to Tinycast when the browser says
|
||||
you can close the tab. **Signed in** confirms completion; **Test Connection** checks the authenticated
|
||||
session and reports its tool count. Save the server to use it in an API chat.
|
||||
|
||||
Leave Client ID and Client secret blank when the provider supports automatic registration. If it
|
||||
requires your own registered OAuth client, enter its client ID and any required secret. Register
|
||||
`http://127.0.0.1:4962/callback` as its redirect URL. Sign-in expires after five minutes; if another
|
||||
app uses that port, Tinycast reports the conflict so you can free it and retry.
|
||||
|
||||
Tinycast refreshes expiring tokens automatically. If it shows **Sign-in required**, open the server
|
||||
in Settings and sign in again. **Sign Out** disconnects it and removes its access and refresh tokens
|
||||
from this Mac. It keeps the client registration; revoke the app in the provider's settings if you
|
||||
also want to withdraw its account access there.
|
||||
|
||||
OAuth sign-in does not change the server's tool trust setting. **Ask Each Chat** still asks before
|
||||
the first tool call.
|
||||
|
||||
## Using tools in a chat
|
||||
|
||||
Tools from every enabled server are offered to the model. When it calls one, a row shows inline in
|
||||
|
||||
Reference in New Issue
Block a user