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:
notsoshu
2026-09-23 18:45:12 +06:00
committed by GitHub
parent d93a09baac
commit 3463f33299
26 changed files with 1830 additions and 63 deletions
+11
View File
@@ -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 \
+89
View File
@@ -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'));
+443
View File
@@ -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")
}
}
}
+28
View File
@@ -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 */,
+3 -1
View File
@@ -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()
}
+269
View File
@@ -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."
}
}
}
+57 -1
View File
@@ -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)
+6
View File
@@ -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
View File
@@ -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
View File
@@ -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.
+24 -2
View File
@@ -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