| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259 |
- 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<void>
- }
- 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<OAuthClientInformation | undefined> {
- 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<void> {
- 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<OAuthTokens | undefined> {
- // 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<void> {
- 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<void> {
- await this.callbacks.onRedirect(authorizationUrl)
- }
- async saveCodeVerifier(codeVerifier: string): Promise<void> {
- await Effect.runPromise(this.auth.updateCodeVerifier(this.mcpName, codeVerifier))
- }
- async codeVerifier(): Promise<string> {
- 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<void> {
- await Effect.runPromise(this.auth.updateOAuthState(this.mcpName, state))
- }
- async state(): Promise<string> {
- 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<void> {
- 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<OAuthClientInformation | undefined> {
- if (!this.config.clientId) return this.pendingClientInfo
- return {
- client_id: this.config.clientId,
- client_secret: this.config.clientSecret,
- }
- }
- override async saveClientInformation(info: OAuthClientInformationFull): Promise<void> {
- this.pendingClientInfo = info
- }
- override async tokens(): Promise<OAuthTokens | undefined> {
- return this.pendingTokens
- }
- override async saveTokens(tokens: OAuthTokens): Promise<void> {
- this.pendingTokens = tokens
- }
- override async invalidateCredentials(type: "all" | "client" | "tokens"): Promise<void> {
- if (type === "all" || type === "client") this.pendingClientInfo = undefined
- if (type === "all" || type === "tokens") this.pendingTokens = undefined
- }
- async commit(): Promise<void> {
- 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 }
- }
- }
|