import type { OAuthClientProvider } from "@modelcontextprotocol/sdk/client/auth.js" import type { OAuthClientMetadata, OAuthTokens, OAuthClientInformation, OAuthClientInformationFull, } from "@modelcontextprotocol/sdk/shared/auth.js" import { Effect } from "effect" import { McpAuth } from "./auth" const OAUTH_CALLBACK_PORT = 19876 const OAUTH_CALLBACK_PATH = "/mcp/oauth/callback" export interface McpOAuthConfig { clientId?: string clientSecret?: string scope?: string callbackPort?: number redirectUri?: string } export interface McpOAuthCallbacks { onRedirect: (url: URL) => void | Promise } export class McpOAuthProvider implements OAuthClientProvider { constructor( protected mcpName: string, protected serverUrl: string, protected config: McpOAuthConfig, private callbacks: McpOAuthCallbacks, protected auth: McpAuth.Interface, ) {} get redirectUrl(): string { if (this.config.redirectUri) { return this.config.redirectUri } const port = this.config.callbackPort ?? OAUTH_CALLBACK_PORT return `http://127.0.0.1:${port}${OAUTH_CALLBACK_PATH}` } get clientMetadata(): OAuthClientMetadata { return { redirect_uris: [this.redirectUrl], client_name: "KirinCode", client_uri: "https://kirincode.ai", grant_types: ["authorization_code", "refresh_token"], response_types: ["code"], token_endpoint_auth_method: this.config.clientSecret ? "client_secret_post" : "none", ...(this.config.scope ? { scope: this.config.scope } : {}), } } async clientInformation(): Promise { if (this.config.clientId) { return { client_id: this.config.clientId, client_secret: this.config.clientSecret, } } // Check stored client info (from dynamic registration) // Use getForUrl to validate credentials are for the current server URL const entry = await Effect.runPromise(this.auth.getForUrl(this.mcpName, this.serverUrl)) if (entry?.clientInfo) { // Check if client secret has expired if (entry.clientInfo.clientSecretExpiresAt && entry.clientInfo.clientSecretExpiresAt < Date.now() / 1000) { return undefined } return { client_id: entry.clientInfo.clientId, client_secret: entry.clientInfo.clientSecret, } } // No client info or URL changed - will trigger dynamic registration return undefined } async saveClientInformation(info: OAuthClientInformationFull): Promise { await Effect.runPromise( this.auth.updateClientInfo( this.mcpName, { clientId: info.client_id, clientSecret: info.client_secret, clientIdIssuedAt: info.client_id_issued_at, clientSecretExpiresAt: info.client_secret_expires_at, }, this.serverUrl, ), ) } async tokens(): Promise { // Use getForUrl to validate tokens are for the current server URL const entry = await Effect.runPromise(this.auth.getForUrl(this.mcpName, this.serverUrl)) if (!entry?.tokens) return undefined return { access_token: entry.tokens.accessToken, token_type: "Bearer", refresh_token: entry.tokens.refreshToken, expires_in: entry.tokens.expiresAt ? Math.max(0, Math.floor(entry.tokens.expiresAt - Date.now() / 1000)) : undefined, scope: entry.tokens.scope, } } async saveTokens(tokens: OAuthTokens): Promise { await Effect.runPromise( this.auth.updateTokens( this.mcpName, { accessToken: tokens.access_token, refreshToken: tokens.refresh_token, expiresAt: tokens.expires_in ? Date.now() / 1000 + tokens.expires_in : undefined, scope: tokens.scope, }, this.serverUrl, ), ) } async redirectToAuthorization(authorizationUrl: URL): Promise { await this.callbacks.onRedirect(authorizationUrl) } async saveCodeVerifier(codeVerifier: string): Promise { await Effect.runPromise(this.auth.updateCodeVerifier(this.mcpName, codeVerifier)) } async codeVerifier(): Promise { const entry = await Effect.runPromise(this.auth.get(this.mcpName)) if (!entry?.codeVerifier) { throw new Error(`No code verifier saved for MCP server: ${this.mcpName}`) } return entry.codeVerifier } async saveState(state: string): Promise { await Effect.runPromise(this.auth.updateOAuthState(this.mcpName, state)) } async state(): Promise { const entry = await Effect.runPromise(this.auth.get(this.mcpName)) if (entry?.oauthState) { return entry.oauthState } // Generate a new state if none exists — the SDK calls state() as a // generator, not just a reader, so we need to produce a value even when // startAuth() hasn't pre-saved one (e.g. during automatic auth on first // connect). const newState = Array.from(crypto.getRandomValues(new Uint8Array(32))) .map((b) => b.toString(16).padStart(2, "0")) .join("") await Effect.runPromise(this.auth.updateOAuthState(this.mcpName, newState)) return newState } async invalidateCredentials(type: "all" | "client" | "tokens"): Promise { const entry = await Effect.runPromise(this.auth.get(this.mcpName)) if (!entry) return switch (type) { case "all": await Effect.runPromise(this.auth.remove(this.mcpName)) break case "client": delete entry.clientInfo await Effect.runPromise(this.auth.set(this.mcpName, entry)) break case "tokens": delete entry.tokens await Effect.runPromise(this.auth.set(this.mcpName, entry)) break } } } export class McpOAuthPendingProvider extends McpOAuthProvider { private pendingClientInfo?: OAuthClientInformationFull private pendingTokens?: OAuthTokens override async clientInformation(): Promise { if (!this.config.clientId) return this.pendingClientInfo return { client_id: this.config.clientId, client_secret: this.config.clientSecret, } } override async saveClientInformation(info: OAuthClientInformationFull): Promise { this.pendingClientInfo = info } override async tokens(): Promise { return this.pendingTokens } override async saveTokens(tokens: OAuthTokens): Promise { this.pendingTokens = tokens } override async invalidateCredentials(type: "all" | "client" | "tokens"): Promise { if (type === "all" || type === "client") this.pendingClientInfo = undefined if (type === "all" || type === "tokens") this.pendingTokens = undefined } async commit(): Promise { if (!this.pendingTokens) return await Effect.runPromise( this.auth.set( this.mcpName, { tokens: { accessToken: this.pendingTokens.access_token, refreshToken: this.pendingTokens.refresh_token, expiresAt: this.pendingTokens.expires_in ? Date.now() / 1000 + this.pendingTokens.expires_in : undefined, scope: this.pendingTokens.scope, }, clientInfo: this.pendingClientInfo && !this.config.clientId ? { clientId: this.pendingClientInfo.client_id, clientSecret: this.pendingClientInfo.client_secret, clientIdIssuedAt: this.pendingClientInfo.client_id_issued_at, clientSecretExpiresAt: this.pendingClientInfo.client_secret_expires_at, } : undefined, }, this.serverUrl, ), ) } } export { OAUTH_CALLBACK_PORT, OAUTH_CALLBACK_PATH } /** * Parse a redirect URI to extract port and path for the callback server. * Returns defaults if the URI can't be parsed. */ export function parseRedirectUri(redirectUri?: string): { port: number; path: string } { if (!redirectUri) { return { port: OAUTH_CALLBACK_PORT, path: OAUTH_CALLBACK_PATH } } try { const url = new URL(redirectUri) const port = url.port ? parseInt(url.port, 10) : url.protocol === "https:" ? 443 : 80 const path = url.pathname || OAUTH_CALLBACK_PATH return { port, path } } catch { return { port: OAUTH_CALLBACK_PORT, path: OAUTH_CALLBACK_PATH } } }