diff --git a/clients/cli/README.md b/clients/cli/README.md index 3563e870e..83dbe53d6 100644 --- a/clients/cli/README.md +++ b/clients/cli/README.md @@ -193,14 +193,16 @@ For the common case where OAuth was already completed in the **web inspector on | Option | Description | | ----------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `--use-stored-auth` | Read the stored access token for `--server-url` and inject it as `Authorization: Bearer`. Exits `3` (`no_stored_token`) — listing the stored keys — when no token matches. Requires `--server-url`. | -| `--wait-for-auth ` | Poll the OAuth state file (500 ms interval) until a token for `--server-url` appears, then proceed as if `--use-stored-auth` were set. Times out at `` with exit `3` (`auth_wait_timeout`). Use after handing off to a human to complete OAuth in a browser. | +| `--use-stored-auth` | Read the stored auth for `--server-url` and inject `Authorization: Bearer`. When a `refresh_token` is stored, the CLI runs the OAuth refresh grant first and injects the **fresh** access token (persisting the rotation); otherwise it injects the stored access token. Exits `3` (`no_stored_token`) — listing the stored keys — when nothing matches. Requires `--server-url`. | +| `--wait-for-auth ` | Poll the OAuth state file (500 ms interval) until an access token for `--server-url` appears, then inject it. Times out at `` with exit `3` (`auth_wait_timeout`). Use after handing off to a human to complete OAuth in a browser. Unlike `--use-stored-auth`, this injects the freshly-landed access token directly (a token that just completed the browser flow is not expired), so it does **not** run the refresh grant. | | `--list-stored-auth` | Print `{ oauthStatePath, storedServerUrls }` (the server keys that currently have a token) and exit. No server connection is made. | | `--print-handoff` | Print a JSON handoff block (`deepLink`, `portForwardCmd`, `oauthStatePath`, `apiToken`) for `--server-url` and exit — everything a script/remote VM needs to drive the browser-side OAuth dance. Requires `--server-url`. | **State-file resolution** follows `MCP_INSPECTOR_OAUTH_STATE_PATH` → `/oauth.json` → `~/.mcp-inspector/storage/oauth.json` — the same precedence the rest of the Inspector uses, so the CLI and web backend agree on the file. Server keys are canonicalised with `new URL().href` (the scheme the web store writes), so a trailing-slash or case mismatch between the URL a human opened and the one the agent passed still resolves. -**Blind injection.** The token is injected as-is; the CLI does not validate or refresh it (a stored `refresh_token` is not yet used — a natural follow-up). A stale/expired access token surfaces as an HTTP `401` on the first request → exit `3` (`auth_required`). Re-complete the flow in the web inspector (or use `--wait-for-auth`) and retry. +**Token refresh (#1665).** When the stored server state carries a `refresh_token` (plus the `clientInformation` the web inspector persists after a completed flow), `--use-stored-auth` runs the SDK's OAuth `refresh_token` grant to mint a fresh access token before connecting, then writes the rotated tokens back to the state file (owner-only `0o600`, via the shared store writer) so web and CLI stay consistent. The auth-server metadata is reused from the stored state, or discovered from `--server-url` when absent. This covers both an absent and an expired stored access token — the persisted blob carries no expiry, so the refresh token is treated as the durable credential. If the refresh fails (revoked token, transient auth-server error) **and** a stored access token is also present, the CLI falls back to injecting that token rather than hard-failing; only when there is nothing to fall back on does it exit `3` (`auth_required`, envelope `refresh_failed`). Without a stored `refresh_token` the access token is injected as-is, and a stale one surfaces as an HTTP `401` → exit `3`. + +Because there is no stored expiry, a `refresh_token` is refreshed on every `--use-stored-auth` run. Two consequences with rotating refresh tokens: two concurrent invocations against the same state file race the single-use token (one wins), and a crash between a successful grant and the write-back leaves the rotated token unsaved. Both are narrow; re-authorize in the web inspector to recover. **Short-circuit modes.** `--list-stored-auth` and `--print-handoff` each print their output and exit without connecting to a server; they ignore the method/target flags. They are mutually exclusive — if both are passed, `--list-stored-auth` takes precedence. diff --git a/clients/cli/__tests__/stored-auth.test.ts b/clients/cli/__tests__/stored-auth.test.ts index 69be24f59..0661ac061 100644 --- a/clients/cli/__tests__/stored-auth.test.ts +++ b/clients/cli/__tests__/stored-auth.test.ts @@ -1,10 +1,11 @@ -import { describe, it, expect, beforeAll, afterAll } from "vitest"; -import { mkdtempSync, writeFileSync, rmSync } from "node:fs"; +import { describe, it, expect, beforeAll, afterAll, vi } from "vitest"; +import { mkdtempSync, writeFileSync, readFileSync, rmSync } from "node:fs"; +import { createServer, type Server } from "node:http"; import { join, dirname } from "node:path"; import { tmpdir } from "node:os"; import { runCli } from "./helpers/cli-runner.js"; import { expectCliFailure, expectCliSuccess } from "./helpers/assertions.js"; -import { normalizeServerUrl } from "../src/cli.js"; +import { normalizeServerUrl, refreshStoredAuthToken } from "../src/cli.js"; import { createTestServerHttp, createEchoTool, @@ -36,6 +37,177 @@ describe("normalizeServerUrl", () => { }); }); +describe("refreshStoredAuthToken", () => { + const SERVER = "https://api.example/mcp"; + const freshTokens = { + access_token: "refreshed-access-token", + token_type: "Bearer", + refresh_token: "rotated-refresh-token", + expires_in: 3600, + }; + + it("runs the refresh grant, injects the fresh token, and persists the rotation", async () => { + const path = writeOAuthFixture({ + [SERVER]: { + tokens: { refresh_token: "old-refresh", token_type: "Bearer" }, + clientInformation: { client_id: "cid", client_secret: "sec" }, + serverMetadata: { + issuer: "https://auth.example", + token_endpoint: "https://auth.example/token", + }, + }, + }); + try { + const refresh = vi.fn().mockResolvedValue(freshTokens); + const discover = vi.fn(); + const token = await refreshStoredAuthToken(SERVER, path, { + refresh, + discover, + }); + expect(token).toBe("refreshed-access-token"); + // Stored metadata present → no discovery needed. + expect(discover).not.toHaveBeenCalled(); + // Refresh called with the stored refresh token + client information. + expect(refresh).toHaveBeenCalledTimes(1); + const [, opts] = refresh.mock.calls[0]!; + expect(opts.refreshToken).toBe("old-refresh"); + expect(opts.clientInformation).toEqual({ + client_id: "cid", + client_secret: "sec", + }); + // Rotation persisted back under the same key. + const persisted = JSON.parse(readFileSync(path, "utf8")) as { + servers: Record; + }; + expect(persisted.servers[SERVER]?.tokens?.refresh_token).toBe( + "rotated-refresh-token", + ); + } finally { + rmSync(path, { force: true }); + } + }); + + it("discovers the auth-server metadata when it was not persisted", async () => { + const path = writeOAuthFixture({ + [SERVER]: { + tokens: { refresh_token: "old-refresh", token_type: "Bearer" }, + clientInformation: { client_id: "cid" }, + }, + }); + try { + const refresh = vi.fn().mockResolvedValue(freshTokens); + const discover = vi.fn().mockResolvedValue({ + issuer: "https://api.example", + token_endpoint: "https://api.example/token", + }); + const token = await refreshStoredAuthToken(SERVER, path, { + refresh, + discover, + }); + expect(token).toBe("refreshed-access-token"); + expect(discover).toHaveBeenCalledTimes(1); + } finally { + rmSync(path, { force: true }); + } + }); + + it("passes metadata=undefined to the refresh grant when discovery returns nothing", async () => { + const path = writeOAuthFixture({ + [SERVER]: { + tokens: { refresh_token: "old-refresh", token_type: "Bearer" }, + clientInformation: { client_id: "cid" }, + }, + }); + try { + const refresh = vi.fn().mockResolvedValue(freshTokens); + const discover = vi.fn().mockResolvedValue(undefined); + const token = await refreshStoredAuthToken(SERVER, path, { + refresh, + discover, + }); + expect(token).toBe("refreshed-access-token"); + const [, opts] = refresh.mock.calls[0]!; + expect(opts.metadata).toBeUndefined(); + } finally { + rmSync(path, { force: true }); + } + }); + + it("uses the distinct no_client_information code when client info is missing", async () => { + const path = writeOAuthFixture({ + [SERVER]: { + tokens: { refresh_token: "old-refresh", token_type: "Bearer" }, + }, + }); + try { + await expect( + refreshStoredAuthToken(SERVER, path, { refresh: vi.fn() }), + ).rejects.toMatchObject({ + exitCode: 3, + envelope: { code: "no_client_information" }, + }); + } finally { + rmSync(path, { force: true }); + } + }); + + it("throws AUTH_REQUIRED when no refresh token is stored", async () => { + const path = writeOAuthFixture({ + [SERVER]: { + tokens: { access_token: "only-access", token_type: "Bearer" }, + }, + }); + try { + await expect( + refreshStoredAuthToken(SERVER, path, { refresh: vi.fn() }), + ).rejects.toMatchObject({ exitCode: 3 }); + } finally { + rmSync(path, { force: true }); + } + }); + + it("throws AUTH_REQUIRED when a refresh token is present but client information is missing", async () => { + const path = writeOAuthFixture({ + [SERVER]: { + tokens: { refresh_token: "old-refresh", token_type: "Bearer" }, + }, + }); + try { + await expect( + refreshStoredAuthToken(SERVER, path, { refresh: vi.fn() }), + ).rejects.toMatchObject({ exitCode: 3 }); + } finally { + rmSync(path, { force: true }); + } + }); + + it("surfaces a refresh-grant failure as AUTH_REQUIRED (refresh_failed)", async () => { + const path = writeOAuthFixture({ + [SERVER]: { + tokens: { refresh_token: "old-refresh", token_type: "Bearer" }, + clientInformation: { client_id: "cid" }, + serverMetadata: { + issuer: "https://auth.example", + token_endpoint: "https://auth.example/token", + }, + }, + }); + try { + const refresh = vi + .fn() + .mockRejectedValue(new Error("invalid_grant: token revoked")); + await expect( + refreshStoredAuthToken(SERVER, path, { refresh }), + ).rejects.toMatchObject({ + exitCode: 3, + envelope: { code: "refresh_failed" }, + }); + } finally { + rmSync(path, { force: true }); + } + }); +}); + describe("--use-stored-auth", () => { let server: ReturnType; let serverUrl: string; @@ -197,6 +369,121 @@ describe("--use-stored-auth", () => { const last = server.getRecordedRequests().at(-1)!; expect(last.headers?.authorization).toBe(`Bearer ${TOKEN}`); }); + + it("refreshes a stored refresh_token end-to-end and injects the fresh access token (#1665)", async () => { + // Minimal OAuth token endpoint: honors the refresh_token grant with a + // rotated token pair, so the CLI's real SDK refresh path has something to + // talk to (the MCP test server accepts any bearer, so a successful connect + // proves the refreshed token was injected). + let tokenRequests = 0; + const tokenServer: Server = createServer((req, res) => { + tokenRequests += 1; + res.writeHead(200, { "content-type": "application/json" }); + res.end( + JSON.stringify({ + access_token: "refreshed-access-token", + token_type: "Bearer", + refresh_token: "rotated-refresh-token", + expires_in: 3600, + }), + ); + }); + await new Promise((resolve) => tokenServer.listen(0, resolve)); + const addr = tokenServer.address(); + const tokenBase = + typeof addr === "object" && addr ? `http://127.0.0.1:${addr.port}` : ""; + const fixture = writeOAuthFixture({ + [serverUrl]: { + tokens: { refresh_token: "old-refresh", token_type: "Bearer" }, + clientInformation: { client_id: "cid", client_secret: "sec" }, + serverMetadata: { + issuer: tokenBase, + token_endpoint: `${tokenBase}/token`, + response_types_supported: ["code"], + grant_types_supported: ["authorization_code", "refresh_token"], + token_endpoint_auth_methods_supported: ["client_secret_post"], + }, + }, + }); + try { + const result = await runCli( + [ + "--transport", + "http", + "--server-url", + serverUrl, + "--use-stored-auth", + "--method", + "tools/list", + ], + { env: { MCP_INSPECTOR_OAUTH_STATE_PATH: fixture } }, + ); + expectCliSuccess(result); + expect(tokenRequests).toBeGreaterThan(0); + const last = server.getRecordedRequests().at(-1)!; + expect(last.headers?.authorization).toBe("Bearer refreshed-access-token"); + // Rotation persisted so a subsequent run reuses the new refresh token. + const persisted = JSON.parse(readFileSync(fixture, "utf8")) as { + servers: Record; + }; + expect(persisted.servers[serverUrl]?.tokens?.refresh_token).toBe( + "rotated-refresh-token", + ); + } finally { + rmSync(fixture, { force: true }); + await new Promise((resolve) => tokenServer.close(() => resolve())); + } + }); + + it("falls back to the stored access token when the refresh grant fails (#1665 review #1)", async () => { + // Token endpoint that rejects the refresh grant, so the CLI must fall back + // to the still-present stored access token instead of hard-failing. + const tokenServer: Server = createServer((_req, res) => { + res.writeHead(400, { "content-type": "application/json" }); + res.end(JSON.stringify({ error: "invalid_grant" })); + }); + await new Promise((resolve) => tokenServer.listen(0, resolve)); + const addr = tokenServer.address(); + const tokenBase = + typeof addr === "object" && addr ? `http://127.0.0.1:${addr.port}` : ""; + const fixture = writeOAuthFixture({ + [serverUrl]: { + tokens: { + access_token: "still-usable-access", + refresh_token: "old-refresh", + token_type: "Bearer", + }, + clientInformation: { client_id: "cid", client_secret: "sec" }, + serverMetadata: { + issuer: tokenBase, + token_endpoint: `${tokenBase}/token`, + response_types_supported: ["code"], + grant_types_supported: ["authorization_code", "refresh_token"], + token_endpoint_auth_methods_supported: ["client_secret_post"], + }, + }, + }); + try { + const result = await runCli( + [ + "--transport", + "http", + "--server-url", + serverUrl, + "--use-stored-auth", + "--method", + "tools/list", + ], + { env: { MCP_INSPECTOR_OAUTH_STATE_PATH: fixture } }, + ); + expectCliSuccess(result); + const last = server.getRecordedRequests().at(-1)!; + expect(last.headers?.authorization).toBe("Bearer still-usable-access"); + } finally { + rmSync(fixture, { force: true }); + await new Promise((resolve) => tokenServer.close(() => resolve())); + } + }); }); describe("--list-stored-auth", () => { diff --git a/clients/cli/src/cli.ts b/clients/cli/src/cli.ts index 059338db5..f9bafd5ea 100644 --- a/clients/cli/src/cli.ts +++ b/clients/cli/src/cli.ts @@ -28,7 +28,22 @@ import type { JsonValue } from "@inspector/core/mcp/index.js"; import { extractAppInfo } from "@inspector/core/mcp/apps.js"; import type { AppInfo } from "@inspector/core/mcp/apps.js"; import { getStateFilePath } from "@inspector/core/auth/node/storage-node.js"; -import { parseOAuthPersistBlob } from "@inspector/core/auth/oauth-persist.js"; +import { + parseOAuthPersistBlob, + serializeOAuthPersistBlob, + type OAuthPersistSnapshot, +} from "@inspector/core/auth/oauth-persist.js"; +import { getAuthorizationServerUrl } from "@inspector/core/auth/discovery.js"; +import { writeStoreFile } from "@inspector/core/storage/store-io.js"; +import { + refreshAuthorization, + discoverAuthorizationServerMetadata, +} from "@modelcontextprotocol/sdk/client/auth.js"; +import type { + OAuthClientInformation, + OAuthMetadata, + OAuthTokens, +} from "@modelcontextprotocol/sdk/shared/auth.js"; import { CliExitCodeError, EXIT_CODES } from "./error-handler.js"; import { ConsoleNavigation, @@ -469,26 +484,59 @@ export function normalizeServerUrl(serverUrl: string): string { } } +/** The subset of a stored server's OAuth state the CLI reads/refreshes. */ +type StoredServerState = { + tokens?: OAuthTokens; + clientInformation?: OAuthClientInformation; + serverMetadata?: OAuthMetadata; +}; /** The stored-server map shape the CLI reads out of the OAuth state file. */ -type StoredServers = Record; +type StoredServers = Record; /** * Read the OAuth state file directly (bypassing the Zustand store cache) so * each call sees the current on-disk state — required for `--wait-for-auth` - * polling. Returns the `servers` map, or an empty object when the file is - * absent or unreadable. Uses the shared {@link parseOAuthPersistBlob} so both - * the plain `{servers,idpSessions}` and legacy `{state,version}` layouts are + * polling. Returns the full snapshot, or an empty one when the file is absent + * or unreadable. Uses the shared {@link parseOAuthPersistBlob} so both the + * plain `{servers,idpSessions}` and legacy `{state,version}` layouts are * accepted, matching whatever the web backend wrote. */ -async function readOAuthServers(statePath: string): Promise { +async function readOAuthSnapshot( + statePath: string, +): Promise { const { readFile } = await import("node:fs/promises"); try { const text = await readFile(statePath, "utf8"); const snapshot = parseOAuthPersistBlob(text); - return (snapshot?.servers as StoredServers | undefined) ?? {}; + if (snapshot) return snapshot; } catch { - return {}; + // Absent/unreadable/malformed → fall through to the empty snapshot below. } + return { servers: {}, idpSessions: {} }; +} + +/** + * Read just the `servers` map. Thin wrapper over {@link readOAuthSnapshot} for + * the read-only lookups (`findStoredToken`, `--wait-for-auth`, key listing). + */ +async function readOAuthServers(statePath: string): Promise { + return (await readOAuthSnapshot(statePath)).servers as StoredServers; +} + +/** + * Look up a stored server's OAuth state, trying the URL-normalised key first + * (how the web store writes it) and the raw string second. Returns the matched + * key so a write-back updates the same entry. + */ +function findStoredServerState( + servers: StoredServers, + serverUrl: string, +): { key: string; state: StoredServerState } | undefined { + const normalized = normalizeServerUrl(serverUrl); + if (servers[normalized]) + return { key: normalized, state: servers[normalized] }; + if (servers[serverUrl]) return { key: serverUrl, state: servers[serverUrl] }; + return undefined; } /** @@ -499,11 +547,91 @@ function findStoredToken( servers: StoredServers, serverUrl: string, ): string | undefined { - const key = normalizeServerUrl(serverUrl); - return ( - servers[key]?.tokens?.access_token ?? - servers[serverUrl]?.tokens?.access_token - ); + return findStoredServerState(servers, serverUrl)?.state.tokens?.access_token; +} + +/** + * Injectable dependencies for {@link refreshStoredAuthToken}, so the refresh + * grant + auth-server discovery can be faked in unit tests without standing up + * a real OAuth token endpoint. Both default to the SDK implementations. + */ +export interface RefreshStoredAuthDeps { + refresh?: typeof refreshAuthorization; + discover?: typeof discoverAuthorizationServerMetadata; +} + +/** + * Run the OAuth `refresh_token` grant for a stored server and persist the + * rotated tokens back to `statePath` (same `{servers,idpSessions}` shape the + * web backend writes), returning the fresh access token. + * + * Reuses the SDK's {@link refreshAuthorization} (not a hand-rolled token + * request) with the stored `clientInformation` + `serverMetadata`; when the + * metadata wasn't persisted it is discovered from the resolved authorization + * server. A missing refresh token or client information, or a failed grant, + * throws {@link CliExitCodeError} with {@link EXIT_CODES.AUTH_REQUIRED} so the + * caller exits with the documented code and a clear message. + */ +export async function refreshStoredAuthToken( + serverUrl: string, + statePath: string, + deps: RefreshStoredAuthDeps = {}, +): Promise { + const refresh = deps.refresh ?? refreshAuthorization; + const discover = deps.discover ?? discoverAuthorizationServerMetadata; + + const snapshot = await readOAuthSnapshot(statePath); + const servers = snapshot.servers as StoredServers; + const found = findStoredServerState(servers, serverUrl); + const refreshToken = found?.state.tokens?.refresh_token; + const clientInformation = found?.state.clientInformation; + if (!found || !refreshToken) { + throw new CliExitCodeError( + EXIT_CODES.AUTH_REQUIRED, + `No stored refresh token for ${normalizeServerUrl(serverUrl)} in ${statePath}. Complete the OAuth flow in the web inspector first.`, + { code: "no_stored_token", url: serverUrl }, + ); + } + if (!clientInformation) { + throw new CliExitCodeError( + EXIT_CODES.AUTH_REQUIRED, + `Stored auth for ${normalizeServerUrl(serverUrl)} has a refresh token but no client information; cannot refresh. Re-authorize in the web inspector.`, + { code: "no_client_information", url: serverUrl }, + ); + } + + const authServerUrl = found.state.serverMetadata?.issuer + ? new URL(found.state.serverMetadata.issuer) + : getAuthorizationServerUrl(serverUrl); + const metadata = + found.state.serverMetadata ?? (await discover(authServerUrl)) ?? undefined; + + let tokens: OAuthTokens; + try { + tokens = await refresh(authServerUrl, { + metadata, + clientInformation, + refreshToken, + resource: new URL(serverUrl), + }); + } catch (err) { + const message = err instanceof Error ? err.message : String(err); + throw new CliExitCodeError( + EXIT_CODES.AUTH_REQUIRED, + `Failed to refresh the stored OAuth token for ${normalizeServerUrl(serverUrl)}: ${message}. Re-authorize in the web inspector.`, + { code: "refresh_failed", url: serverUrl }, + ); + } + + // Persist the rotated tokens back under the same key, preserving every other + // server entry and the idpSessions block, so web and CLI stay consistent. + // Route through the shared `writeStoreFile` (not a raw `writeFile`) so the + // secrets file keeps its owner-only `0o600` mode + `mkdir -p`, identical to + // how the web backend's OAuth persist backend writes it. + servers[found.key] = { ...found.state, tokens }; + await writeStoreFile(statePath, serializeOAuthPersistBlob(snapshot)); + + return tokens.access_token; } /** @@ -934,11 +1062,13 @@ async function parseArgs(argv?: string[]): Promise { } // Read the OAuth state file directly so the lookup is normalised the same // way the web inspector wrote it (`new URL().href`), and so `--wait-for- - // auth` sees fresh on-disk state on each poll. Header injection is the - // prototype path — only the access token is injected, blindly; a stale one - // surfaces as HTTP 401 → exit 3 (auth_required). Follow-up: when the stored - // `tokens.refresh_token` is present and the access token is expired, mint a - // fresh one instead of injecting the stale token. + // auth` sees fresh on-disk state on each poll. When a `refresh_token` is + // stored, the CLI runs the SDK refresh grant and injects the fresh access + // token (persisting the rotation) rather than blindly injecting a possibly- + // stale stored access token (#1665) — the stored blob carries no expiry, so + // the refresh token is the durable credential. Without a refresh token it + // falls back to injecting the stored access token; a stale one surfaces as + // HTTP 401 → exit 3 (auth_required). let token: string; if (options.waitForAuth !== undefined) { token = await waitForStoredToken( @@ -948,18 +1078,40 @@ async function parseArgs(argv?: string[]): Promise { ); } else { const servers = await readOAuthServers(oauthStatePath); - const found = findStoredToken(servers, options.serverUrl); - if (!found) { - const key = normalizeServerUrl(options.serverUrl); - const stored = Object.keys(servers); - throw new CliExitCodeError( - EXIT_CODES.AUTH_REQUIRED, - `No stored OAuth token for ${key} in ${oauthStatePath}. Complete the OAuth flow in the web inspector first.` + - (stored.length > 0 ? ` Stored keys: ${stored.join(", ")}.` : ""), - { code: "no_stored_token", url: options.serverUrl }, - ); + const stored = findStoredServerState(servers, options.serverUrl); + if (stored?.state.tokens?.refresh_token) { + const storedAccess = stored.state.tokens.access_token; + try { + token = await refreshStoredAuthToken( + options.serverUrl, + oauthStatePath, + ); + } catch (err) { + // A failed refresh (transient auth-server hiccup, missing client + // info) shouldn't turn a previously-working invocation into a hard + // failure when a still-usable access token is also on disk — fall + // back to injecting it (a genuinely stale one surfaces as HTTP 401 → + // exit 3, the same as without a refresh token). With no stored access + // token to fall back on, the refresh error stands. + if (!storedAccess) throw err; + token = storedAccess; + } + } else { + const found = findStoredToken(servers, options.serverUrl); + if (!found) { + const key = normalizeServerUrl(options.serverUrl); + const storedKeys = Object.keys(servers); + throw new CliExitCodeError( + EXIT_CODES.AUTH_REQUIRED, + `No stored OAuth token for ${key} in ${oauthStatePath}. Complete the OAuth flow in the web inspector first.` + + (storedKeys.length > 0 + ? ` Stored keys: ${storedKeys.join(", ")}.` + : ""), + { code: "no_stored_token", url: options.serverUrl }, + ); + } + token = found; } - token = found; } serverOptions.headers = { ...(serverOptions.headers ?? {}),