All of the mid-session and step-up auth work

This commit is contained in:
Bob Dickinson 2026-07-02 16:35:31 -07:00
commit 562bdd00f1
6 changed files with 11437 additions and 1212 deletions

View file

@ -102,9 +102,15 @@ Options that specify the MCP server (catalog/config file, ad-hoc command/URL, en
### CLI-specific (OAuth for HTTP servers)
The CLI **reuses** OAuth tokens from `~/.mcp-inspector/storage/oauth.json` (same file as the TUI). Complete first-time authorization in the **web** or **TUI** client, then run one-shot CLI commands against HTTP/SSE servers without signing in again.
The CLI runs the same loopback callback server as the TUI (`http://127.0.0.1:6276/oauth/callback` by default). On connect **401** or mid-session interactive auth (re-login / step-up), it:
The CLI does **not** start a local callback server or retry connect on 401. If tokens are missing or expired, connect fails; `ConsoleNavigation` may print an authorize URL to stdout, but the CLI cannot finish the redirect flow. Use the TUI for interactive runner OAuth until Phase 4 adds a CLI callback server.
1. Starts the callback listener on `--callback-url` (or `MCP_OAUTH_CALLBACK_URL`)
2. Prints the authorization URL to the console (`ConsoleNavigation`)
3. Waits for the browser redirect, exchanges the code, and retries connect or the failed RPC
**Step-up (standard OAuth):** when an RPC needs extra scopes, the CLI prompts on stderr: `Proceed with step-up authorization? [y/N]`. **y** continues; **N** exits with an error. EMA step-up re-mints silently (no prompt).
**Shared OAuth storage:** the CLI **reuses** tokens from `~/.mcp-inspector/storage/oauth.json` when they already exist (same file as other Inspector clients). That is passive file sharing, not launching another app.
**Shared with TUI** (config only, not interactive login):
@ -119,9 +125,9 @@ The CLI does **not** start a local callback server or retry connect on 401. If t
| ------- | ---------------- |
| **Web** | `http://localhost:6274/oauth/callback` |
| **TUI** | `http://127.0.0.1:6276/oauth/callback` (interactive — callback server) |
| **CLI** | `http://127.0.0.1:6276/oauth/callback` (redirect URI in OAuth metadata only; no listener) |
| **CLI** | `http://127.0.0.1:6276/oauth/callback` (interactive — same callback server as TUI) |
Register `http://127.0.0.1:6276/oauth/callback` on static or enterprise IdPs that require pre-registered redirect URIs before using the **TUI** (or when your OAuth app expects that URI). Override with `--callback-url` or `MCP_OAUTH_CALLBACK_URL`. The CLI passes this value as `redirect_uri` when an OAuth flow runs, but does not listen on the port.
Register `http://127.0.0.1:6276/oauth/callback` on static or enterprise IdPs that require pre-registered redirect URIs before using the **TUI** or **CLI**. Override with `--callback-url` or `MCP_OAUTH_CALLBACK_URL`. Only one process should bind the default port at a time.
#### Flags
@ -141,7 +147,7 @@ npx @modelcontextprotocol/inspector --cli --catalog mcp.json --server my-http-se
--method tools/list
```
See [EMA / enterprise-managed auth](../../specification/v2_auth_ema.md) and [OAuth smoke testing](../../specification/v2_auth_smoke_testing.md) for configuration details and staging servers.
See [EMA / enterprise-managed auth](../../specification/v2_auth_ema.md) and [OAuth smoke testing](../../specification/v2_auth_smoke_testing.md) (§3 Stytch/CIMD; [§5 mid-session manual validation](v2_auth_smoke_testing.md#5-mid-session-auth--step-up--manual-validation) — CLI **C1–C2**).
## Why use the CLI?

View file

@ -17,6 +17,7 @@ npm run test:cli # cli.test.ts
npm run test:cli-tools # tools.test.ts
npm run test:cli-headers # headers.test.ts
npm run test:cli-metadata # metadata.test.ts
npx vitest run oauth-interactive.test.ts cliOAuth.test.ts # OAuth interactive smoke parity
```
## How the CLI is exercised
@ -42,7 +43,10 @@ root provides a further end-to-end check of the binary.)
- `metadata.test.ts` - Metadata functionality: General metadata, tool-specific metadata, parsing, merging, validation
- `methods.test.ts` - Method/option-validation paths not covered elsewhere (resource templates, missing/invalid options, the `--` target separator)
- `error-handler.test.ts` - The binary's `handleError` error sink, exercised in-process with `process.exit` stubbed
- `e2e.test.ts` - Out-of-process spawn of the built binary (exit codes + boot)
- `oauth-runner.test.ts` - OAuth flag wiring (`--client-config`, `--callback-url`, CIMD overrides)
- `cliOAuth.test.ts` - Unit tests for `cliOAuth.ts` (step-up confirm, helper wiring, retry)
- `oauth-interactive.test.ts` - **Integration** smoke parity for CLI interactive OAuth: connect-time callback server + step-up **y/N** against composable `TestServerHttp` (auto-completes authorize URL programmatically; not a subprocess binary e2e)
- `e2e.test.ts` - Out-of-process spawn of the built binary (exit codes + boot; no OAuth)
## Helpers

View file

@ -0,0 +1,230 @@
import { describe, it, expect, vi, afterEach } from "vitest";
import { AuthRecoveryRequiredError } from "@inspector/core/auth/challenge.js";
import { MutableRedirectUrlProvider } from "@inspector/core/auth/index.js";
import * as runnerInteractive from "@inspector/core/auth/node/runner-interactive-oauth.js";
import {
handleCliAuthRecoveryRequired,
isStandardOAuthStepUp,
runCliInteractiveOAuth,
withCliAuthRecoveryRetry,
} from "../src/cliOAuth.js";
describe("cliOAuth", () => {
afterEach(() => {
vi.restoreAllMocks();
});
describe("isStandardOAuthStepUp", () => {
it("returns true for insufficient_scope on non-EMA servers", () => {
expect(
isStandardOAuthStepUp(
{ reason: "insufficient_scope", requiredScopes: ["weather:read"] },
{},
),
).toBe(true);
});
it("returns false for EMA servers", () => {
expect(
isStandardOAuthStepUp(
{ reason: "insufficient_scope", requiredScopes: ["weather:read"] },
{ enterpriseManaged: true },
),
).toBe(false);
});
});
it("runCliInteractiveOAuth writes success to stderr", async () => {
vi.spyOn(runnerInteractive, "runRunnerInteractiveOAuth").mockResolvedValue({
kind: "success",
});
const client = {
authenticate: vi.fn(),
beginInteractiveAuthorization: vi.fn(),
completeOAuthFlow: vi.fn(),
checkAuthChallengeSatisfied: vi.fn(),
};
const redirectUrlProvider = new MutableRedirectUrlProvider();
const stderrSpy = vi
.spyOn(process.stderr, "write")
.mockImplementation(() => true);
await runCliInteractiveOAuth(client, redirectUrlProvider, {
hostname: "127.0.0.1",
port: 6276,
pathname: "/oauth/callback",
});
expect(stderrSpy).toHaveBeenCalledWith("Authorization complete.\n");
});
it("runCliInteractiveOAuth throws when scopes remain insufficient", async () => {
const challenge = {
reason: "insufficient_scope" as const,
requiredScopes: ["weather:read"],
};
vi.spyOn(runnerInteractive, "runRunnerInteractiveOAuth").mockResolvedValue({
kind: "insufficient_scope",
challenge,
});
const client = {
authenticate: vi.fn(),
beginInteractiveAuthorization: vi.fn(),
completeOAuthFlow: vi.fn(),
checkAuthChallengeSatisfied: vi.fn(),
};
await expect(
runCliInteractiveOAuth(
client,
new MutableRedirectUrlProvider(),
{ hostname: "127.0.0.1", port: 6276, pathname: "/oauth/callback" },
{ authChallenge: challenge },
),
).rejects.toThrow(/required scopes were not granted/);
});
it("handleCliAuthRecoveryRequired declines standard step-up when user says no", async () => {
const runSpy = vi
.spyOn(runnerInteractive, "runRunnerInteractiveOAuth")
.mockResolvedValue({ kind: "success" });
const client = {
authenticate: vi.fn(),
beginInteractiveAuthorization: vi.fn(),
completeOAuthFlow: vi.fn(),
checkAuthChallengeSatisfied: vi.fn(),
};
const error = new AuthRecoveryRequiredError(
new URL("https://as.example/authorize"),
{ reason: "insufficient_scope", requiredScopes: ["weather:read"] },
);
await expect(
handleCliAuthRecoveryRequired(
client,
error,
new MutableRedirectUrlProvider(),
{ hostname: "127.0.0.1", port: 6276, pathname: "/oauth/callback" },
{},
async () => false,
),
).rejects.toThrow("Step-up authorization declined.");
expect(runSpy).not.toHaveBeenCalled();
});
it("handleCliAuthRecoveryRequired runs OAuth after step-up confirm", async () => {
const runSpy = vi
.spyOn(runnerInteractive, "runRunnerInteractiveOAuth")
.mockResolvedValue({ kind: "success" });
const client = {
authenticate: vi.fn(),
beginInteractiveAuthorization: vi.fn(),
completeOAuthFlow: vi.fn(),
checkAuthChallengeSatisfied: vi.fn(),
};
const authorizationUrl = new URL("https://as.example/authorize");
const challenge = {
reason: "insufficient_scope" as const,
requiredScopes: ["weather:read"],
};
const error = new AuthRecoveryRequiredError(authorizationUrl, challenge);
await handleCliAuthRecoveryRequired(
client,
error,
new MutableRedirectUrlProvider(),
{ hostname: "127.0.0.1", port: 6276, pathname: "/oauth/callback" },
{},
async () => true,
);
expect(runSpy).toHaveBeenCalledWith(
expect.objectContaining({
authorizationUrl,
authChallenge: challenge,
}),
);
});
it("handleCliAuthRecoveryRequired skips step-up when storage already satisfies", async () => {
const runSpy = vi.spyOn(runnerInteractive, "runRunnerInteractiveOAuth");
const client = {
authenticate: vi.fn(),
beginInteractiveAuthorization: vi.fn(),
completeOAuthFlow: vi.fn(),
checkAuthChallengeSatisfied: vi.fn().mockResolvedValue(true),
};
const error = new AuthRecoveryRequiredError(
new URL("https://as.example/authorize"),
{
reason: "insufficient_scope",
requiredScopes: ["weather:read"],
},
);
await handleCliAuthRecoveryRequired(
client,
error,
new MutableRedirectUrlProvider(),
{ hostname: "127.0.0.1", port: 6276, pathname: "/oauth/callback" },
);
expect(runSpy).not.toHaveBeenCalled();
});
it("handleCliAuthRecoveryRequired skips OAuth when storage already satisfies reauth", async () => {
const runSpy = vi.spyOn(runnerInteractive, "runRunnerInteractiveOAuth");
const client = {
authenticate: vi.fn(),
beginInteractiveAuthorization: vi.fn(),
completeOAuthFlow: vi.fn(),
checkAuthChallengeSatisfied: vi.fn().mockResolvedValue(true),
};
const error = new AuthRecoveryRequiredError(
new URL("https://as.example/authorize"),
{ reason: "token_expired" },
);
await handleCliAuthRecoveryRequired(
client,
error,
new MutableRedirectUrlProvider(),
{ hostname: "127.0.0.1", port: 6276, pathname: "/oauth/callback" },
);
expect(runSpy).not.toHaveBeenCalled();
});
it("withCliAuthRecoveryRetry reruns the operation after interactive recovery", async () => {
vi.spyOn(runnerInteractive, "runRunnerInteractiveOAuth").mockResolvedValue({
kind: "success",
});
const client = {
authenticate: vi.fn(),
beginInteractiveAuthorization: vi.fn(),
completeOAuthFlow: vi.fn(),
checkAuthChallengeSatisfied: vi.fn(),
};
const fn = vi
.fn()
.mockRejectedValueOnce(
new AuthRecoveryRequiredError(new URL("https://as.example/authorize"), {
reason: "unauthorized",
}),
)
.mockResolvedValueOnce("ok");
const result = await withCliAuthRecoveryRetry(
client,
new MutableRedirectUrlProvider(),
{ hostname: "127.0.0.1", port: 6276, pathname: "/oauth/callback" },
{ enterpriseManaged: true },
fn,
async () => true,
);
expect(result).toBe("ok");
expect(fn).toHaveBeenCalledTimes(2);
});
});

View file

@ -0,0 +1,242 @@
import { describe, it, expect, afterEach } from "vitest";
import { rmSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import {
TestServerHttp,
waitForOAuthWellKnown,
getDefaultServerConfig,
createOAuthTestServerConfig,
clearOAuthTestData,
} from "@modelcontextprotocol/inspector-test-server";
import { InspectorClient } from "@inspector/core/mcp/index.js";
import { createTransportNode } from "@inspector/core/mcp/node/index.js";
import {
CallbackNavigation,
MutableRedirectUrlProvider,
} from "@inspector/core/auth/index.js";
import { NodeOAuthStorage } from "@inspector/core/auth/node/index.js";
import {
connectInspectorWithOAuth,
withCliAuthRecoveryRetry,
} from "../src/cliOAuth.js";
import type { MCPServerConfig } from "@inspector/core/mcp/types.js";
const oauthTestStatePath = join(
tmpdir(),
`mcp-oauth-${process.pid}-cli-interactive.json`,
);
async function completeOAuthAuthorization(
authorizationUrl: URL,
): Promise<string> {
let response = await fetch(authorizationUrl.toString(), {
redirect: "manual",
});
// Local composable test AS shows an HTML consent page on GET; approve via POST.
if (response.status === 200) {
const body = new URLSearchParams(authorizationUrl.searchParams);
response = await fetch(
`${authorizationUrl.origin}${authorizationUrl.pathname}`,
{
method: "POST",
headers: { "Content-Type": "application/x-www-form-urlencoded" },
body,
redirect: "manual",
},
);
}
if (response.status !== 302 && response.status !== 301) {
throw new Error(
`Expected redirect (302/301), got ${response.status}: ${await response.text()}`,
);
}
const location = response.headers.get("location");
if (!location) {
throw new Error("Missing Location header");
}
const redirect = new URL(location, authorizationUrl.origin);
const code = redirect.searchParams.get("code");
if (!code) {
throw new Error("Missing authorization code");
}
return code;
}
function createAutoCompleteNavigation(
redirectUrlProvider: MutableRedirectUrlProvider,
) {
return new CallbackNavigation(async (url) => {
const code = await completeOAuthAuthorization(url);
const redirect = redirectUrlProvider.redirectUrl;
if (!redirect) {
throw new Error("redirectUrl not set");
}
await fetch(`${redirect}?code=${encodeURIComponent(code)}`);
});
}
const callbackUrlConfig = {
hostname: "127.0.0.1",
port: 6276,
pathname: "/oauth/callback",
};
const presetRedirectUrl = "http://127.0.0.1:6276/oauth/callback";
describe("CLI interactive OAuth (integration)", () => {
let mcpServer: TestServerHttp | null = null;
afterEach(async () => {
if (mcpServer) {
await mcpServer.stop();
mcpServer = null;
}
clearOAuthTestData();
try {
rmSync(oauthTestStatePath, { force: true });
} catch {
// ignore
}
}, 30_000);
it("connects to an OAuth-protected server via the loopback callback server", async () => {
const serverConfig = {
...getDefaultServerConfig(),
serverType: "streamable-http" as const,
...createOAuthTestServerConfig({
requireAuth: true,
supportDCR: true,
}),
};
mcpServer = new TestServerHttp(serverConfig);
const port = await mcpServer.start();
const serverUrl = `http://localhost:${port}`;
await waitForOAuthWellKnown(serverUrl);
const redirectUrlProvider = new MutableRedirectUrlProvider();
redirectUrlProvider.redirectUrl = presetRedirectUrl;
const navigation = createAutoCompleteNavigation(redirectUrlProvider);
const client = new InspectorClient(
{
type: "streamable-http",
url: `${serverUrl}/mcp`,
} as MCPServerConfig,
{
environment: {
transport: createTransportNode,
oauth: {
storage: new NodeOAuthStorage(oauthTestStatePath),
navigation,
redirectUrlProvider,
},
},
directAuthRecovery: true,
oauth: {},
},
);
await connectInspectorWithOAuth(
client,
{ type: "streamable-http", url: `${serverUrl}/mcp` },
redirectUrlProvider,
callbackUrlConfig,
);
const tools = await client.listTools();
expect(tools.tools.length).toBeGreaterThan(0);
await client.disconnect();
}, 30_000);
it("retries an RPC after step-up authorization when the user confirms", async () => {
const baseConfig = getDefaultServerConfig();
const serverConfig = {
...baseConfig,
serverType: "streamable-http" as const,
tools: baseConfig.tools!.map((tool) =>
tool.name === "get_temp"
? { ...tool, requiredScopes: ["weather:read"] }
: tool,
),
...createOAuthTestServerConfig({
requireAuth: true,
supportRefreshTokens: true,
supportDCR: true,
}),
};
mcpServer = new TestServerHttp(serverConfig);
const port = await mcpServer.start();
const serverUrl = `http://localhost:${port}`;
await waitForOAuthWellKnown(serverUrl);
const redirectUrlProvider = new MutableRedirectUrlProvider();
redirectUrlProvider.redirectUrl = presetRedirectUrl;
const navigation = createAutoCompleteNavigation(redirectUrlProvider);
const client = new InspectorClient(
{
type: "streamable-http",
url: `${serverUrl}/mcp`,
} as MCPServerConfig,
{
environment: {
transport: createTransportNode,
oauth: {
storage: new NodeOAuthStorage(oauthTestStatePath),
navigation,
redirectUrlProvider,
},
},
directAuthRecovery: true,
oauth: {
scope: "mcp tools:read",
},
},
);
await connectInspectorWithOAuth(
client,
{ type: "streamable-http", url: `${serverUrl}/mcp` },
redirectUrlProvider,
callbackUrlConfig,
);
const tools = await client.listTools();
const getTempTool = tools.tools.find((tool) => tool.name === "get_temp");
expect(getTempTool).toBeDefined();
let stepUpPrompted = false;
const originalWrite = process.stderr.write.bind(process.stderr);
process.stderr.write = ((chunk, ...rest) => {
const text = typeof chunk === "string" ? chunk : String(chunk);
if (text.includes("Proceed with step-up authorization?")) {
stepUpPrompted = true;
}
return originalWrite(chunk, ...rest);
}) as typeof process.stderr.write;
try {
const result = await withCliAuthRecoveryRetry(
client,
redirectUrlProvider,
callbackUrlConfig,
{},
() =>
client.callTool(getTempTool!, {
city: "NYC",
units: "C",
}),
async () => true,
);
expect(stepUpPrompted).toBe(true);
expect(result.success).toBe(true);
} finally {
process.stderr.write = originalWrite;
await client.disconnect();
}
}, 30_000);
});

View file

@ -28,6 +28,10 @@ import {
MutableRedirectUrlProvider,
} from "@inspector/core/auth/index.js";
import { NodeOAuthStorage } from "@inspector/core/auth/node/index.js";
import {
connectInspectorWithOAuth,
withCliAuthRecoveryRetry,
} from "./cliOAuth.js";
import {
DEFAULT_RUNNER_OAUTH_CALLBACK_URL,
formatRunnerOAuthRedirectUrl,
@ -122,9 +126,7 @@ async function callMethod(
null;
let managedPromptsState: ManagedPromptsState | null = null;
try {
await inspectorClient.connect();
const runMethod = async (): Promise<McpResponse> => {
let result: McpResponse;
if (args.method === "tools/list" || args.method === "tools/call") {
@ -248,6 +250,26 @@ async function callMethod(
);
}
return result;
};
try {
await connectInspectorWithOAuth(
inspectorClient,
serverConfig,
redirectUrlProvider,
callbackUrlConfig!,
serverSettings,
);
const result = await withCliAuthRecoveryRetry(
inspectorClient,
redirectUrlProvider,
callbackUrlConfig!,
serverSettings,
runMethod,
);
await awaitableLog(JSON.stringify(result, null, 2));
} finally {
managedToolsState?.destroy();

184
clients/cli/src/cliOAuth.ts Normal file
View file

@ -0,0 +1,184 @@
import type { AuthChallenge } from "@inspector/core/auth/challenge.js";
import {
AuthRecoveryRequiredError,
isStandardOAuthStepUp as isCoreStandardOAuthStepUp,
isUnauthorizedError,
stepUpConfirmMessage,
stepUpInsufficientScopeMessage,
MutableRedirectUrlProvider,
} from "@inspector/core/auth/index.js";
import {
createOAuthCallbackServer,
runRunnerInteractiveOAuth,
} from "@inspector/core/auth/node/index.js";
import type { RunnerOAuthCallbackConfig } from "@inspector/core/auth/node/runner-oauth-callback.js";
import type { InspectorClient } from "@inspector/core/mcp/index.js";
import type { InspectorServerSettings } from "@inspector/core/mcp/types.js";
import { isOAuthCapableServerConfig } from "@inspector/core/client/runner.js";
import type { MCPServerConfig } from "@inspector/core/mcp/types.js";
import { createInterface } from "node:readline/promises";
/** Standard-OAuth step-up (not EMA silent re-mint). */
export function isStandardOAuthStepUp(
challenge: AuthChallenge,
settings?: InspectorServerSettings,
): boolean {
return isCoreStandardOAuthStepUp(challenge, {
enterpriseManaged: settings?.enterpriseManaged,
});
}
async function confirmStepUpFromStdin(): Promise<boolean> {
const rl = createInterface({ input: process.stdin, output: process.stderr });
try {
const answer = await rl.question("");
const normalized = answer.trim().toLowerCase();
return normalized === "y" || normalized === "yes";
} finally {
rl.close();
}
}
async function promptStepUpConfirm(
challenge: AuthChallenge,
confirmStepUp: () => Promise<boolean>,
): Promise<boolean> {
process.stderr.write(`${stepUpConfirmMessage(challenge)}\n`);
process.stderr.write("Proceed with step-up authorization? [y/N] ");
return confirmStepUp();
}
export async function runCliInteractiveOAuth(
client: InspectorClient,
redirectUrlProvider: MutableRedirectUrlProvider,
callbackUrlConfig: RunnerOAuthCallbackConfig,
options?: {
authorizationUrl?: URL;
authChallenge?: AuthChallenge;
},
): Promise<void> {
const result = await runRunnerInteractiveOAuth({
client,
redirectUrlProvider,
callbackListen: callbackUrlConfig,
createCallbackServer: createOAuthCallbackServer,
authorizationUrl: options?.authorizationUrl,
authChallenge: options?.authChallenge,
});
if (result.kind === "insufficient_scope") {
throw new Error(stepUpInsufficientScopeMessage(result.challenge));
}
if (result.kind === "success") {
process.stderr.write("Authorization complete.\n");
}
}
export async function handleCliAuthRecoveryRequired(
client: InspectorClient,
error: AuthRecoveryRequiredError,
redirectUrlProvider: MutableRedirectUrlProvider,
callbackUrlConfig: RunnerOAuthCallbackConfig,
serverSettings?: InspectorServerSettings,
confirmStepUp: () => Promise<boolean> = confirmStepUpFromStdin,
): Promise<void> {
if (isStandardOAuthStepUp(error.authChallenge, serverSettings)) {
if (await client.checkAuthChallengeSatisfied(error.authChallenge)) {
return;
}
const proceed = await promptStepUpConfirm(
error.authChallenge,
confirmStepUp,
);
if (!proceed) {
throw new Error("Step-up authorization declined.");
}
} else if (await client.checkAuthChallengeSatisfied(error.authChallenge)) {
return;
}
await runCliInteractiveOAuth(client, redirectUrlProvider, callbackUrlConfig, {
authorizationUrl: error.authorizationUrl,
...(error.authChallenge.reason === "insufficient_scope" && {
authChallenge: error.authChallenge,
}),
});
}
export async function connectInspectorWithOAuth(
inspectorClient: InspectorClient,
serverConfig: MCPServerConfig,
redirectUrlProvider: MutableRedirectUrlProvider,
callbackUrlConfig: RunnerOAuthCallbackConfig,
serverSettings?: InspectorServerSettings,
): Promise<void> {
try {
await inspectorClient.connect();
} catch (err) {
if (!isOAuthCapableServerConfig(serverConfig)) {
throw err;
}
if (err instanceof AuthRecoveryRequiredError) {
if (
await inspectorClient.checkAuthChallengeSatisfied(err.authChallenge)
) {
await inspectorClient.connect();
return;
}
await handleCliAuthRecoveryRequired(
inspectorClient,
err,
redirectUrlProvider,
callbackUrlConfig,
serverSettings,
);
await inspectorClient.connect();
return;
}
if (isUnauthorizedError(err)) {
await inspectorClient.disconnect().catch(() => {});
await runCliInteractiveOAuth(
inspectorClient,
redirectUrlProvider,
callbackUrlConfig,
);
await inspectorClient.connect();
return;
}
throw err;
}
}
/**
* Run `fn` once; on {@link AuthRecoveryRequiredError}, complete interactive OAuth
* and retry `fn` a single time (no further recovery attempts).
*/
export async function withCliAuthRecoveryRetry<T>(
inspectorClient: InspectorClient,
redirectUrlProvider: MutableRedirectUrlProvider,
callbackUrlConfig: RunnerOAuthCallbackConfig,
serverSettings: InspectorServerSettings | undefined,
fn: () => Promise<T>,
confirmStepUp: () => Promise<boolean> = confirmStepUpFromStdin,
): Promise<T> {
try {
return await fn();
} catch (err) {
if (!(err instanceof AuthRecoveryRequiredError)) {
throw err;
}
await handleCliAuthRecoveryRequired(
inspectorClient,
err,
redirectUrlProvider,
callbackUrlConfig,
serverSettings,
confirmStepUp,
);
process.stderr.write("Authorization complete. Retrying…\n");
return await fn();
}
}