{"version":3,"file":"oauth.d.ts","sourceRoot":"","sources":["../../src/mcp/oauth.ts"],"names":[],"mappings":"AAGA,OAAO,KAAK,EAAyC,sBAAsB,EAAE,MAAM,yBAAyB,CAAC;AAgC7G,MAAM,WAAW,cAAc;IAC9B,2DAA2D;IAC3D,MAAM,EAAE,MAAM,CAAC;IACf,oEAAoE;IACpE,KAAK,CAAC,EAAE,MAAM,CAAC;IACf,uFAAuF;IACvF,GAAG,EAAE,MAAM,CAAC;IACZ,kEAAkE;IAClE,QAAQ,CAAC,EAAE,MAAM,CAAC;IAClB,0EAA0E;IAC1E,MAAM,CAAC,EAAE,MAAM,CAAC;CAChB;AAwWD,wBAAgB,sBAAsB,CAAC,MAAM,EAAE,cAAc,GAAG,sBAAsB,CAgLrF","sourcesContent":["import type { Server } from \"node:http\";\nimport { oauthErrorHtml, oauthSuccessHtml } from \"../utils/oauth/oauth-page.js\";\nimport { generatePKCE } from \"../utils/oauth/pkce.js\";\nimport type { OAuthCredentials, OAuthLoginCallbacks, OAuthProviderInterface } from \"../utils/oauth/types.js\";\n\nconst CALLBACK_HOST = process.env.PI_OAUTH_CALLBACK_HOST || \"127.0.0.1\";\n// A range (not one port) so a leaked/concurrent login can't wedge all logins with EADDRINUSE.\n// Distinct from the Anthropic callback port (53692). All candidates are registered as redirect URIs.\nconst CALLBACK_PORT_BASE = Number(process.env.PI_MCP_OAUTH_CALLBACK_PORT || 53700);\nconst CALLBACK_PORT_COUNT = 10;\nconst CALLBACK_PATH = \"/callback\";\nconst CALLBACK_PORTS = Array.from({ length: CALLBACK_PORT_COUNT }, (_, i) => CALLBACK_PORT_BASE + i);\nconst redirectUriFor = (port: number) => `http://localhost:${port}${CALLBACK_PATH}`;\nconst ALL_REDIRECT_URIS = CALLBACK_PORTS.map(redirectUriFor);\nconst TOKEN_EXPIRY_BUFFER_MS = 5 * 60 * 1000;\n\ninterface AuthServerMetadata {\n\tissuer: string;\n\tauthorization_endpoint: string;\n\ttoken_endpoint: string;\n\tregistration_endpoint?: string;\n\tscopes_supported?: string[];\n}\n\ninterface ProtectedResourceMetadata {\n\tresource: string;\n\tauthorization_servers: string[];\n}\n\ninterface Discovery {\n\tmetadata: AuthServerMetadata;\n\tresource?: string;\n\tissuer?: string;\n}\n\nexport interface McpOAuthConfig {\n\t/** MCP server name; provider id becomes `mcp:<server>`. */\n\tserver: string;\n\t/** Human-readable label shown in OAuth UI; defaults to `server`. */\n\tlabel?: string;\n\t/** MCP resource URL used for protected-resource and authorization-server discovery. */\n\turl: string;\n\t/** Pre-registered client id (servers without DCR, e.g. Slack). */\n\tclientId?: string;\n\t/** Requested OAuth scopes; defaults to the server's advertised scopes. */\n\tscopes?: string;\n}\n\ninterface McpCredentials extends OAuthCredentials {\n\ttokenEndpoint?: string;\n\tclientId?: string;\n\t/** MCP endpoint the token was issued for; consumers refuse to send it elsewhere. */\n\tendpoint?: string;\n\t/** RFC 9728 resource indicator. Its presence marks a PRM-based login. */\n\tresource?: string;\n\t/** RFC 8414/OIDC issuer selected by the protected-resource metadata. */\n\tissuer?: string;\n}\n\nfunction validatedHttpsUrl(value: string, name: string): URL {\n\tlet url: URL;\n\ttry {\n\t\turl = new URL(value);\n\t} catch {\n\t\tthrow new Error(`${name} must be an absolute HTTPS URL`);\n\t}\n\tif (url.protocol !== \"https:\" || url.username || url.password || url.hash) {\n\t\tthrow new Error(`${name} must be an absolute HTTPS URL without credentials or a fragment`);\n\t}\n\treturn url;\n}\n\nfunction canonicalResource(url: URL): string {\n\tif (url.pathname === \"/\" && !url.search) return url.origin;\n\treturn `${url.origin}${url.pathname}${url.search}`;\n}\n\nfunction authorizationServerMetadataUrls(issuer: string): string[] {\n\tconst url = validatedHttpsUrl(issuer, \"Authorization server issuer\");\n\tif (url.search) throw new Error(\"Authorization server issuer must not contain a query string\");\n\tconst path = url.pathname === \"/\" ? \"\" : url.pathname.replace(/\\/$/, \"\");\n\treturn [\n\t\tnew URL(`/.well-known/oauth-authorization-server${path}`, url.origin).toString(),\n\t\tnew URL(`${path}/.well-known/openid-configuration`, url.origin).toString(),\n\t];\n}\n\nasync function fetchResponse(url: string, init?: RequestInit): Promise<Response> {\n\treturn fetch(url, { ...init, redirect: \"error\" });\n}\n\nasync function fetchJson(url: string, init?: RequestInit): Promise<unknown> {\n\tconst res = await fetchResponse(url, init);\n\tif (!res.ok) {\n\t\tthrow new Error(`${init?.method ?? \"GET\"} ${url} failed: ${res.status}`);\n\t}\n\treturn res.json();\n}\n\nfunction authorizationServerMetadata(value: unknown, issuer: string, requireExactIssuer: boolean): AuthServerMetadata {\n\tif (!value || typeof value !== \"object\") throw new Error(`Authorization server metadata for ${issuer} is invalid`);\n\tconst metadata = value as Partial<AuthServerMetadata>;\n\tif (typeof metadata.issuer !== \"string\") {\n\t\tthrow new Error(`Authorization server metadata for ${issuer} is missing its issuer`);\n\t}\n\tif (requireExactIssuer) {\n\t\tif (metadata.issuer !== issuer) {\n\t\t\tthrow new Error(`Authorization server metadata issuer does not exactly match ${issuer}`);\n\t\t}\n\t} else {\n\t\tconst advertisedIssuer = validatedHttpsUrl(metadata.issuer, \"Authorization server metadata issuer\");\n\t\tif (advertisedIssuer.origin !== new URL(issuer).origin || advertisedIssuer.search) {\n\t\t\tthrow new Error(`Origin authorization server metadata issuer must stay on ${new URL(issuer).origin}`);\n\t\t}\n\t}\n\tif (typeof metadata.authorization_endpoint !== \"string\" || typeof metadata.token_endpoint !== \"string\") {\n\t\tthrow new Error(`Authorization server metadata for ${issuer} is missing required endpoints`);\n\t}\n\tvalidatedHttpsUrl(metadata.authorization_endpoint, \"Authorization endpoint\");\n\tvalidatedHttpsUrl(metadata.token_endpoint, \"Token endpoint\");\n\tif (metadata.registration_endpoint) validatedHttpsUrl(metadata.registration_endpoint, \"Registration endpoint\");\n\treturn metadata as AuthServerMetadata;\n}\n\nasync function jsonMetadata(response: Response, url: string): Promise<unknown> {\n\tif (response.status !== 200) throw new Error(`GET ${url} failed: ${response.status}`);\n\tconst contentType = response.headers.get(\"content-type\")?.split(\";\", 1)[0].trim().toLowerCase();\n\tif (contentType !== \"application/json\") throw new Error(`GET ${url} did not return application/json`);\n\treturn response.json();\n}\n\nasync function discoverAuthorizationServer(issuer: string, requireExactIssuer: boolean): Promise<AuthServerMetadata> {\n\tconst candidates = authorizationServerMetadataUrls(issuer);\n\tlet lastError: unknown;\n\tfor (const candidate of candidates) {\n\t\ttry {\n\t\t\tconst response = await fetchResponse(candidate);\n\t\t\tif (response.status === 404) continue;\n\t\t\treturn authorizationServerMetadata(await jsonMetadata(response, candidate), issuer, requireExactIssuer);\n\t\t} catch (error) {\n\t\t\tlastError = error;\n\t\t}\n\t}\n\tthrow new Error(\n\t\t`Could not discover OAuth metadata for ${issuer}. Tried ${candidates.join(\", \")}. Last error: ${String(lastError)}`,\n\t);\n}\n\n/** Random, URL-safe CSRF `state` value, independent of the PKCE verifier. */\nfunction randomState(): string {\n\tconst bytes = new Uint8Array(32);\n\tcrypto.getRandomValues(bytes);\n\treturn btoa(String.fromCharCode(...bytes))\n\t\t.replace(/\\+/g, \"-\")\n\t\t.replace(/\\//g, \"_\")\n\t\t.replace(/=/g, \"\");\n}\n\nfunction resourceMetadata(value: unknown, resource: string): ProtectedResourceMetadata {\n\tif (!value || typeof value !== \"object\") throw new Error(\"Protected-resource metadata is invalid\");\n\tconst metadata = value as Partial<ProtectedResourceMetadata>;\n\tif (metadata.resource !== resource)\n\t\tthrow new Error(`Protected-resource metadata resource does not exactly match ${resource}`);\n\tif (!Array.isArray(metadata.authorization_servers) || metadata.authorization_servers.length === 0) {\n\t\tthrow new Error(\"Protected-resource metadata has no authorization_servers\");\n\t}\n\tfor (const issuer of metadata.authorization_servers) {\n\t\tif (typeof issuer !== \"string\")\n\t\t\tthrow new Error(\"Protected-resource metadata has an invalid authorization server\");\n\t\tvalidatedHttpsUrl(issuer, \"Authorization server issuer\");\n\t}\n\treturn metadata as ProtectedResourceMetadata;\n}\n\nfunction resourceMetadataUrl(resource: URL): string {\n\tconst path = resource.pathname === \"/\" ? \"\" : resource.pathname;\n\treturn `${resource.origin}/.well-known/oauth-protected-resource${path}${resource.search}`;\n}\n\nfunction headerResourceMetadata(value: string | null): string | undefined {\n\tconst match = value?.match(/(?:^|[,\\s])resource_metadata\\s*=\\s*\"((?:[^\"\\\\]|\\\\.)*)\"/i);\n\tif (!match) return undefined;\n\treturn match[1].replace(/\\\\(.)/g, \"$1\");\n}\n\nasync function tryProtectedResourceMetadata(url: string): Promise<ProtectedResourceMetadata | undefined> {\n\tconst resource = validatedHttpsUrl(url, \"MCP endpoint\");\n\tlet headerUrl: string | undefined;\n\ttry {\n\t\t// This probe deliberately has no Authorization header. It must not leak an existing token.\n\t\tconst response = await fetchResponse(resource.toString());\n\t\theaderUrl = headerResourceMetadata(response.headers.get(\"www-authenticate\"));\n\t\tawait response.body?.cancel();\n\t} catch {\n\t\t// The server need not support a GET probe; use the RFC well-known locations below.\n\t}\n\n\tconst candidate = headerUrl\n\t\t? validatedHttpsUrl(headerUrl, \"resource_metadata\").toString()\n\t\t: resourceMetadataUrl(resource);\n\tconst response = await fetchResponse(candidate);\n\tif (response.status === 404 && !headerUrl) return undefined;\n\treturn resourceMetadata(await jsonMetadata(response, candidate), canonicalResource(resource));\n}\n\n/** Discover RFC 9728 protected-resource metadata before the origin-level authorization server fallback. */\nasync function discover(url: string): Promise<Discovery> {\n\tconst protectedResource = await tryProtectedResourceMetadata(url);\n\tif (protectedResource) {\n\t\tconst issuer = protectedResource.authorization_servers[0];\n\t\treturn {\n\t\t\tmetadata: await discoverAuthorizationServer(issuer, true),\n\t\t\tresource: protectedResource.resource,\n\t\t\tissuer,\n\t\t};\n\t}\n\tconst issuer = validatedHttpsUrl(url, \"MCP endpoint\").origin;\n\treturn { metadata: await discoverAuthorizationServer(issuer, false) };\n}\n\nasync function registerClient(registrationEndpoint: string, label: string): Promise<string> {\n\tvalidatedHttpsUrl(registrationEndpoint, \"Registration endpoint\");\n\tconst body = {\n\t\tclient_name: label,\n\t\tredirect_uris: ALL_REDIRECT_URIS,\n\t\tgrant_types: [\"authorization_code\", \"refresh_token\"],\n\t\tresponse_types: [\"code\"],\n\t\ttoken_endpoint_auth_method: \"none\",\n\t};\n\tconst data = (await fetchJson(registrationEndpoint, {\n\t\tmethod: \"POST\",\n\t\theaders: { \"Content-Type\": \"application/json\" },\n\t\tbody: JSON.stringify(body),\n\t})) as { client_id?: unknown };\n\tif (typeof data.client_id !== \"string\" || !data.client_id) {\n\t\tthrow new Error(`Dynamic client registration at ${registrationEndpoint} returned no client_id`);\n\t}\n\treturn data.client_id;\n}\n\ntype CallbackResult = { code: string; state: string } | null;\n\nasync function startCallbackServer(label: string): Promise<{\n\tserver: Server;\n\tredirectUri: string;\n\tcancel: () => void;\n\twaitForCode: () => Promise<CallbackResult>;\n}> {\n\tconst { createServer } = await import(\"node:http\");\n\tlet settle: ((value: CallbackResult) => void) | undefined;\n\tconst waitPromise = new Promise<CallbackResult>((resolve) => {\n\t\tlet settled = false;\n\t\tsettle = (value) => {\n\t\t\tif (!settled) {\n\t\t\t\tsettled = true;\n\t\t\t\tresolve(value);\n\t\t\t}\n\t\t};\n\t});\n\n\tconst handler = (req: import(\"node:http\").IncomingMessage, res: import(\"node:http\").ServerResponse) => {\n\t\tconst url = new URL(req.url || \"\", \"http://localhost\");\n\t\tif (url.pathname !== CALLBACK_PATH) {\n\t\t\tres.writeHead(404, { \"Content-Type\": \"text/html; charset=utf-8\" });\n\t\t\tres.end(oauthErrorHtml(\"Callback route not found.\"));\n\t\t\treturn;\n\t\t}\n\t\tconst error = url.searchParams.get(\"error\");\n\t\tconst code = url.searchParams.get(\"code\");\n\t\tconst state = url.searchParams.get(\"state\");\n\t\tres.writeHead(error || !code ? 400 : 200, { \"Content-Type\": \"text/html; charset=utf-8\" });\n\t\tif (error) {\n\t\t\tres.end(oauthErrorHtml(`${label} authentication failed.`, `Error: ${error}`));\n\t\t\tsettle?.(null);\n\t\t\treturn;\n\t\t}\n\t\tif (!code || !state) {\n\t\t\tres.end(oauthErrorHtml(\"Missing code or state parameter.\"));\n\t\t\tsettle?.(null);\n\t\t\treturn;\n\t\t}\n\t\tres.end(oauthSuccessHtml(`${label} authentication completed. You can close this window.`));\n\t\tsettle?.({ code, state });\n\t};\n\n\t// Try each candidate port with a FRESH server (a server that failed to listen\n\t// can't be reused), so a leaked/concurrent login can't block us with EADDRINUSE.\n\tlet lastError: unknown;\n\tfor (const port of CALLBACK_PORTS) {\n\t\tconst server = createServer(handler);\n\t\t// Persistent handler so a post-bind 'error' is never an unhandled crash.\n\t\tlet bindErr: ((err: unknown) => void) | undefined;\n\t\tserver.on(\"error\", (err) => bindErr?.(err));\n\t\ttry {\n\t\t\tconst bound = await new Promise<boolean>((resolve) => {\n\t\t\t\tbindErr = () => resolve(false);\n\t\t\t\tserver.listen(port, CALLBACK_HOST, () => {\n\t\t\t\t\tbindErr = undefined;\n\t\t\t\t\tresolve(true);\n\t\t\t\t});\n\t\t\t});\n\t\t\tif (bound) {\n\t\t\t\treturn {\n\t\t\t\t\tserver,\n\t\t\t\t\tredirectUri: redirectUriFor(port),\n\t\t\t\t\tcancel: () => settle?.(null),\n\t\t\t\t\twaitForCode: () => waitPromise,\n\t\t\t\t};\n\t\t\t}\n\t\t\tlastError = `port ${port} in use`;\n\t\t\tserver.close();\n\t\t} catch (err) {\n\t\t\tlastError = err;\n\t\t\tserver.close();\n\t\t}\n\t}\n\tthrow new Error(\n\t\t`Could not start the OAuth callback server: ports ${CALLBACK_PORT_BASE}-${\n\t\t\tCALLBACK_PORT_BASE + CALLBACK_PORT_COUNT - 1\n\t\t} are all in use. Close other login attempts and retry. (${String(lastError)})`,\n\t);\n}\n\nfunction parseRedirectInput(input: string, expectedState: string): { code: string; state: string } {\n\tconst value = input.trim();\n\tlet code: string | undefined;\n\tlet state: string | undefined;\n\ttry {\n\t\tconst url = new URL(value);\n\t\tcode = url.searchParams.get(\"code\") ?? undefined;\n\t\tstate = url.searchParams.get(\"state\") ?? undefined;\n\t} catch {\n\t\tconst params = new URLSearchParams(value);\n\t\tcode = params.get(\"code\") ?? value;\n\t\tstate = params.get(\"state\") ?? undefined;\n\t}\n\tif (state && state !== expectedState) {\n\t\tthrow new Error(\"OAuth state mismatch\");\n\t}\n\tif (!code) {\n\t\tthrow new Error(\"Missing authorization code\");\n\t}\n\treturn { code, state: state ?? expectedState };\n}\n\nasync function exchangeToken(\n\ttokenEndpoint: string,\n\tparams: Record<string, string>,\n): Promise<{ access_token: string; refresh_token?: string; expires_in?: number }> {\n\tvalidatedHttpsUrl(tokenEndpoint, \"Token endpoint\");\n\tconst res = await fetchResponse(tokenEndpoint, {\n\t\tmethod: \"POST\",\n\t\theaders: { \"Content-Type\": \"application/x-www-form-urlencoded\" },\n\t\tbody: new URLSearchParams(params).toString(),\n\t});\n\tconst text = await res.text();\n\tif (!res.ok) {\n\t\tthrow new Error(`Token request to ${tokenEndpoint} failed: ${res.status}`);\n\t}\n\tlet token: unknown;\n\ttry {\n\t\ttoken = JSON.parse(text);\n\t} catch {\n\t\tthrow new Error(`Token request to ${tokenEndpoint} returned invalid JSON`);\n\t}\n\tif (!token || typeof token !== \"object\" || typeof (token as { access_token?: unknown }).access_token !== \"string\") {\n\t\tthrow new Error(`Token request to ${tokenEndpoint} returned no access_token`);\n\t}\n\tconst result = token as { access_token: string; refresh_token?: unknown; expires_in?: unknown };\n\tif (!result.access_token) throw new Error(`Token request to ${tokenEndpoint} returned no access_token`);\n\tif (result.refresh_token !== undefined && typeof result.refresh_token !== \"string\") {\n\t\tthrow new Error(`Token request to ${tokenEndpoint} returned an invalid refresh_token`);\n\t}\n\tif (\n\t\tresult.expires_in !== undefined &&\n\t\t(typeof result.expires_in !== \"number\" || !Number.isFinite(result.expires_in))\n\t) {\n\t\tthrow new Error(`Token request to ${tokenEndpoint} returned an invalid expires_in`);\n\t}\n\treturn result as { access_token: string; refresh_token?: string; expires_in?: number };\n}\n\nfunction toCredentials(\n\ttoken: { access_token: string; refresh_token?: string; expires_in?: number },\n\ttokenEndpoint: string,\n\tclientId: string,\n\tendpoint: string | undefined,\n\tresource: string | undefined,\n\tissuer: string | undefined,\n\tpreviousRefresh?: string,\n): McpCredentials {\n\treturn {\n\t\taccess: token.access_token,\n\t\t// Some servers omit refresh_token on refresh; keep the prior one.\n\t\trefresh: token.refresh_token ?? previousRefresh ?? \"\",\n\t\texpires: token.expires_in\n\t\t\t? Date.now() + token.expires_in * 1000 - TOKEN_EXPIRY_BUFFER_MS\n\t\t\t: Date.now() + 3600 * 1000 - TOKEN_EXPIRY_BUFFER_MS,\n\t\ttokenEndpoint,\n\t\tclientId,\n\t\tendpoint,\n\t\tresource,\n\t\tissuer,\n\t};\n}\n\nexport function createMcpOAuthProvider(config: McpOAuthConfig): OAuthProviderInterface {\n\tconst label = config.label ?? config.server;\n\n\tasync function login(callbacks: OAuthLoginCallbacks): Promise<OAuthCredentials> {\n\t\tconst discovery = await discover(config.url);\n\t\tconst { metadata: meta } = discovery;\n\t\tcallbacks.onProgress?.(`Discovered ${discovery.issuer ?? meta.issuer}`);\n\n\t\tlet clientId = config.clientId;\n\t\tif (!clientId) {\n\t\t\tif (!meta.registration_endpoint) {\n\t\t\t\tthrow new Error(\n\t\t\t\t\t`${label} does not support dynamic client registration and no clientId was configured. ` +\n\t\t\t\t\t\t`Set a pre-registered client id for this server.`,\n\t\t\t\t);\n\t\t\t}\n\t\t\tcallbacks.onProgress?.(\"Registering OAuth client…\");\n\t\t\tclientId = await registerClient(meta.registration_endpoint, `ASM Agent (${label})`);\n\t\t}\n\n\t\tconst { verifier, challenge } = await generatePKCE();\n\t\t// `state` must be independent of the PKCE verifier — the verifier is the\n\t\t// secret used at token exchange, while `state` is echoed on the redirect URL.\n\t\tconst state = randomState();\n\t\tconst scope = config.scopes ?? meta.scopes_supported?.join(\" \");\n\t\tconst cb = await startCallbackServer(label);\n\t\ttry {\n\t\t\tconst authParams = new URLSearchParams({\n\t\t\t\tclient_id: clientId,\n\t\t\t\tresponse_type: \"code\",\n\t\t\t\tredirect_uri: cb.redirectUri,\n\t\t\t\tcode_challenge: challenge,\n\t\t\t\tcode_challenge_method: \"S256\",\n\t\t\t\tstate,\n\t\t\t});\n\t\t\tif (scope) authParams.set(\"scope\", scope);\n\t\t\tif (discovery.resource) authParams.set(\"resource\", discovery.resource);\n\n\t\t\tconst authorizationUrl = new URL(meta.authorization_endpoint);\n\t\t\tfor (const [name, value] of authParams) authorizationUrl.searchParams.set(name, value);\n\t\t\tcallbacks.onAuth({\n\t\t\t\turl: authorizationUrl.toString(),\n\t\t\t\tinstructions:\n\t\t\t\t\t\"Complete login in your browser. If the browser is on another machine, paste the final redirect URL here.\",\n\t\t\t});\n\n\t\t\t// Race the local callback server against a manual paste (browser on\n\t\t\t// another machine). The login dialog supplies onManualCodeInput; when\n\t\t\t// absent we fall back to a blocking prompt after the callback resolves.\n\t\t\tlet result: { code: string; state: string } | null;\n\t\t\tlet manualCancelled = false;\n\t\t\tlet manualError: Error | undefined;\n\t\t\tif (callbacks.onManualCodeInput) {\n\t\t\t\t// Manual paste races the browser callback. A real paste cancels the\n\t\t\t\t// callback waiter (we're done). On manual cancellation we still settle\n\t\t\t\t// the waiter to avoid hanging when no redirect arrives — but only after\n\t\t\t\t// a short grace period so an in-flight browser redirect can win first.\n\t\t\t\tconst manual = callbacks\n\t\t\t\t\t.onManualCodeInput()\n\t\t\t\t\t.then((input) => {\n\t\t\t\t\t\tconst parsed = parseRedirectInput(input, state); // may throw a validation error\n\t\t\t\t\t\tcb.cancel();\n\t\t\t\t\t\treturn parsed;\n\t\t\t\t\t})\n\t\t\t\t\t.catch(async (err) => {\n\t\t\t\t\t\t// A validation error on a real paste (bad state / no code) is a genuine\n\t\t\t\t\t\t// failure to surface; a UI cancellation is not. .catch also prevents an\n\t\t\t\t\t\t// unhandled rejection when the callback wins the race.\n\t\t\t\t\t\tif (err instanceof Error && /state mismatch|authorization code/i.test(err.message)) {\n\t\t\t\t\t\t\tmanualError = err;\n\t\t\t\t\t\t} else {\n\t\t\t\t\t\t\tmanualCancelled = true;\n\t\t\t\t\t\t}\n\t\t\t\t\t\tawait new Promise((r) => setTimeout(r, 500));\n\t\t\t\t\t\tcb.cancel();\n\t\t\t\t\t\treturn null;\n\t\t\t\t\t});\n\t\t\t\tconst fromCallback = await cb.waitForCode();\n\t\t\t\tresult = fromCallback ?? (await manual);\n\t\t\t\tif (!result && manualError) throw manualError;\n\t\t\t} else {\n\t\t\t\tresult = await cb.waitForCode();\n\t\t\t\tif (!result) {\n\t\t\t\t\tconst input = await callbacks.onPrompt({\n\t\t\t\t\t\tmessage: \"Paste the authorization code or full redirect URL:\",\n\t\t\t\t\t\tplaceholder: cb.redirectUri,\n\t\t\t\t\t});\n\t\t\t\t\tresult = parseRedirectInput(input, state);\n\t\t\t\t}\n\t\t\t}\n\t\t\tif (!result) {\n\t\t\t\tthrow new Error(manualCancelled ? \"Login cancelled\" : \"Missing authorization code\");\n\t\t\t}\n\t\t\tif (result.state !== state) {\n\t\t\t\tthrow new Error(\"OAuth state mismatch\");\n\t\t\t}\n\n\t\t\tcallbacks.onProgress?.(\"Exchanging authorization code for tokens…\");\n\t\t\tconst token = await exchangeToken(meta.token_endpoint, {\n\t\t\t\tgrant_type: \"authorization_code\",\n\t\t\t\tcode: result.code,\n\t\t\t\tredirect_uri: cb.redirectUri,\n\t\t\t\tclient_id: clientId,\n\t\t\t\tcode_verifier: verifier,\n\t\t\t\t...(discovery.resource ? { resource: discovery.resource } : {}),\n\t\t\t});\n\t\t\treturn toCredentials(token, meta.token_endpoint, clientId, config.url, discovery.resource, discovery.issuer);\n\t\t} finally {\n\t\t\tcb.server.close();\n\t\t}\n\t}\n\n\tasync function refreshToken(credentials: OAuthCredentials): Promise<OAuthCredentials> {\n\t\tconst creds = credentials as McpCredentials;\n\t\tif (creds.endpoint !== config.url) {\n\t\t\tthrow new Error(`Stored OAuth credentials are not bound to ${config.url}; re-run /mcp login ${config.server}`);\n\t\t}\n\t\tconst configuredResource = canonicalResource(validatedHttpsUrl(config.url, \"MCP endpoint\"));\n\t\tif (creds.resource !== undefined && creds.resource !== configuredResource) {\n\t\t\tthrow new Error(\n\t\t\t\t`Stored OAuth credentials are not bound to ${configuredResource}; re-run /mcp login ${config.server}`,\n\t\t\t);\n\t\t}\n\t\tif ((creds.resource === undefined) !== (creds.issuer === undefined)) {\n\t\t\tthrow new Error(\n\t\t\t\t`Stored OAuth credentials for ${label} have incomplete resource binding; re-run /mcp login ${config.server}`,\n\t\t\t);\n\t\t}\n\t\tif (creds.issuer !== undefined) validatedHttpsUrl(creds.issuer, \"Stored authorization server issuer\");\n\t\tif (!creds.refresh) {\n\t\t\tthrow new Error(`No refresh token stored for ${label}; re-run /mcp login ${config.server}`);\n\t\t}\n\t\tconst discovery = await discover(config.url);\n\t\tif ((creds.resource === undefined) !== (discovery.resource === undefined)) {\n\t\t\tthrow new Error(`OAuth discovery mode changed for ${config.url}; re-run /mcp login ${config.server}`);\n\t\t}\n\t\tif (creds.resource) {\n\t\t\tif (discovery.resource !== creds.resource || discovery.issuer !== creds.issuer) {\n\t\t\t\tthrow new Error(\n\t\t\t\t\t`Stored OAuth credentials do not match current protected-resource metadata for ${config.url}`,\n\t\t\t\t);\n\t\t\t}\n\t\t}\n\t\tconst tokenEndpoint = creds.tokenEndpoint ?? discovery.metadata.token_endpoint;\n\t\tif (creds.tokenEndpoint && discovery.metadata.token_endpoint !== creds.tokenEndpoint) {\n\t\t\tthrow new Error(\n\t\t\t\t`Stored OAuth token endpoint does not match current authorization-server metadata for ${config.url}`,\n\t\t\t);\n\t\t}\n\t\tconst clientId = creds.clientId ?? config.clientId;\n\t\tif (!tokenEndpoint) throw new Error(`No token endpoint stored for ${label}; re-run /mcp login ${config.server}`);\n\t\tconst token = await exchangeToken(tokenEndpoint, {\n\t\t\tgrant_type: \"refresh_token\",\n\t\t\trefresh_token: creds.refresh,\n\t\t\t...(clientId ? { client_id: clientId } : {}),\n\t\t\t...(creds.resource ? { resource: creds.resource } : {}),\n\t\t});\n\t\treturn toCredentials(\n\t\t\ttoken,\n\t\t\ttokenEndpoint,\n\t\t\tclientId ?? \"\",\n\t\t\tcreds.endpoint,\n\t\t\tcreds.resource,\n\t\t\tcreds.issuer,\n\t\t\tcreds.refresh,\n\t\t);\n\t}\n\n\treturn {\n\t\tid: `mcp:${config.server}`,\n\t\tname: label,\n\t\tusesCallbackServer: true,\n\t\tlogin,\n\t\trefreshToken,\n\t\tgetApiKey: (credentials) => credentials.access,\n\t};\n}\n"]}