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
97 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();
}
}

View file

@ -55,6 +55,9 @@ const h = vi.hoisted(() => {
clearOAuthTokens: vi.fn(),
completeOAuthFlow: vi.fn(async (): Promise<void> => {}),
getOAuthState: vi.fn(async () => undefined),
callTool: vi.fn(),
checkAuthChallengeSatisfied: vi.fn(async () => false),
handleAuthChallenge: vi.fn(async () => ({ kind: "satisfied" as const })),
};
// Captured options from the most recent callbackServer.start(), so a test can
// drive the onCallback / onError handlers the OAuth flows register.
@ -95,6 +98,11 @@ const h = vi.hoisted(() => {
completeOAuthFlow = (...a: unknown[]) =>
clientSpies.completeOAuthFlow(...a);
getOAuthState = (...a: unknown[]) => clientSpies.getOAuthState(...a);
callTool = (...a: unknown[]) => clientSpies.callTool(...a);
checkAuthChallengeSatisfied = (...a: unknown[]) =>
clientSpies.checkAuthChallengeSatisfied(...a);
handleAuthChallenge = (...a: unknown[]) =>
clientSpies.handleAuthChallenge(...a);
readResource = vi.fn(async () => ({
result: { contents: [{ uri: "file://x", text: "hello" }] },
}));
@ -185,16 +193,25 @@ vi.mock("@inspector/core/auth/index.js", async (importOriginal) => {
},
};
});
vi.mock("@inspector/core/auth/node/index.js", () => ({
createOAuthCallbackServer: h.createOAuthCallbackServer,
NodeOAuthStorage: class {},
}));
vi.mock("@inspector/core/auth/node/index.js", async (importOriginal) => {
const actual =
await importOriginal<typeof import("@inspector/core/auth/node/index.js")>();
return {
...actual,
createOAuthCallbackServer: h.createOAuthCallbackServer,
NodeOAuthStorage: class {},
};
});
vi.mock("../src/utils/openUrl.js", () => ({
openUrl: h.openUrl,
}));
import App from "../src/App.js";
import type { TuiServer } from "../src/tui-servers.js";
import {
AuthRecoveryRequiredError,
EMA_STEP_UP_PENDING_URL,
} from "@inspector/core/auth/challenge.js";
const tick = () => new Promise((r) => setTimeout(r, 25));
const callbackUrlConfig = { hostname: "127.0.0.1", port: 0, pathname: "/cb" };
@ -234,6 +251,27 @@ function oneHttp(): Record<string, TuiServer> {
};
}
function oneEmaHttp(): Record<string, TuiServer> {
return {
ema: {
config: { type: "streamable-http", url: "http://localhost:8080/mcp" },
settings: {
requestTimeout: 0,
metadata: [],
headers: [],
env: [],
roots: [],
maxFetchRequests: 1000,
taskTtl: 0,
connectionTimeout: 0,
oauthClientId: "client_ema_test",
oauthScopes: "tools:read",
enterpriseManaged: true,
},
} as never,
};
}
// Mixed catalog: an OAuth-capable http server first (auto-selected) followed by
// a stdio server <20> drives per-server tab gating + the tab-switch-away effects.
function httpThenStdio(): Record<string, TuiServer> {
@ -466,6 +504,11 @@ beforeEach(() => {
h.clientSpies.completeOAuthFlow.mockResolvedValue(undefined);
h.clientSpies.getOAuthState.mockReset();
h.clientSpies.getOAuthState.mockResolvedValue(undefined);
h.clientSpies.callTool.mockReset();
h.clientSpies.checkAuthChallengeSatisfied.mockReset();
h.clientSpies.checkAuthChallengeSatisfied.mockResolvedValue(false);
h.clientSpies.handleAuthChallenge.mockReset();
h.clientSpies.handleAuthChallenge.mockResolvedValue({ kind: "satisfied" });
});
afterEach(() => {
@ -936,4 +979,65 @@ describe("App (OAuth flows)", () => {
await h.cb.opts!.onCallback({ code: "x" });
await expectFrame(r, "cb-string");
});
it("shows EMA step-up confirmation on tool auth recovery instead of auto OAuth", async () => {
const challenge = {
reason: "insufficient_scope" as const,
requiredScopes: ["env:read"],
authorizationScopes: ["tools:read", "env:read"],
context: { toolName: "get-env" },
};
h.clientSpies.callTool.mockRejectedValue(
new AuthRecoveryRequiredError(EMA_STEP_UP_PENDING_URL, challenge, {
emaStepUpConfirm: true,
}),
);
h.clientSpies.checkAuthChallengeSatisfied.mockResolvedValue(false);
h.ctrl.status = "connected";
h.ctrl.tools = [sampleTool];
const r = await mount(oneEmaHttp());
await press(r, ["t", TAB, ENTER]);
await expectFrame(r, "MOCK_FORM");
await press(r, [ENTER]);
await waitUntil(() => h.clientSpies.callTool.mock.calls.length > 0);
await expectFrame(r, "organization before it can continue");
const frame = r.lastFrame() ?? "";
expect(frame).toMatch(/organization|get-env/i);
expect(frame).not.toMatch(/opens browser/i);
expect(frame).not.toMatch(/OAuth: authenticating/i);
expect(h.callbackStart).not.toHaveBeenCalled();
});
it("runs confirmed EMA step-up via handleAuthChallenge when user authorizes", async () => {
const challenge = {
reason: "insufficient_scope" as const,
requiredScopes: ["env:read"],
authorizationScopes: ["tools:read", "env:read"],
context: { toolName: "get-env" },
};
h.clientSpies.callTool.mockRejectedValue(
new AuthRecoveryRequiredError(EMA_STEP_UP_PENDING_URL, challenge, {
emaStepUpConfirm: true,
}),
);
h.clientSpies.checkAuthChallengeSatisfied.mockResolvedValue(false);
h.clientSpies.handleAuthChallenge.mockResolvedValue({ kind: "satisfied" });
h.ctrl.status = "connected";
h.ctrl.tools = [sampleTool];
const r = await mount(oneEmaHttp());
await press(r, ["t", TAB, ENTER]);
await expectFrame(r, "MOCK_FORM");
await press(r, [ENTER]);
await waitUntil(() => h.clientSpies.callTool.mock.calls.length > 0);
await expectFrame(r, "organization before it can continue");
await press(r, ["a"]);
await waitUntil(
() => h.clientSpies.handleAuthChallenge.mock.calls.length > 0,
);
expect(h.clientSpies.handleAuthChallenge).toHaveBeenCalledWith(challenge, {
confirmedStepUp: true,
});
expect(h.callbackStart).not.toHaveBeenCalled();
await expectFrame(r, "Step-up authorization succeeded");
});
});

View file

@ -71,6 +71,15 @@ const baseProps = {
connectionStatus: "disconnected" as const,
};
const pendingStepUp = {
challenge: {
reason: "insufficient_scope" as const,
requiredScopes: ["env:read"],
authorizationScopes: ["tools:read", "env:read"],
},
enterpriseManaged: true,
};
describe("AuthTab", () => {
it("renders the placeholder when there is no server", () => {
const { lastFrame } = render(
@ -220,6 +229,86 @@ describe("AuthTab", () => {
expect(lastFrame() ?? "").toContain("OAuth Details");
});
it("navigates step-up choices with arrows and activates with Enter", async () => {
const onAuthorizeStepUp = vi.fn();
const onCancelStepUp = vi.fn();
const { client } = makeClient(sampleOAuthState);
const { stdin } = render(
<AuthTab
{...baseProps}
focused
inspectorClient={client}
oauthStatus="idle"
oauthMessage={null}
pendingStepUp={pendingStepUp}
onAuthorizeStepUp={onAuthorizeStepUp}
onCancelStepUp={onCancelStepUp}
/>,
);
await tick();
stdin.write("\r");
await tick();
expect(onAuthorizeStepUp).toHaveBeenCalledTimes(1);
expect(onCancelStepUp).not.toHaveBeenCalled();
onAuthorizeStepUp.mockClear();
stdin.write(DOWN);
await tick();
stdin.write("\r");
await tick();
expect(onCancelStepUp).toHaveBeenCalledTimes(1);
expect(onAuthorizeStepUp).not.toHaveBeenCalled();
});
it("shows step-up footer with selection hints when focused", async () => {
const { client } = makeClient(sampleOAuthState);
const { lastFrame } = render(
<AuthTab
{...baseProps}
focused
inspectorClient={client}
oauthStatus="idle"
oauthMessage={null}
pendingStepUp={pendingStepUp}
onAuthorizeStepUp={vi.fn()}
onCancelStepUp={vi.fn()}
/>,
);
await tick();
expect(lastFrame() ?? "").toContain("↑/↓ select, Enter confirm");
});
it("refreshes OAuth state when connection becomes connected", async () => {
const { client, getOAuthState } = makeClient(undefined);
const { lastFrame, rerender } = render(
<AuthTab
{...baseProps}
inspectorClient={client}
oauthStatus="idle"
oauthMessage={null}
connectionStatus="disconnected"
/>,
);
await tick();
expect(getOAuthState).toHaveBeenCalledTimes(1);
expect(lastFrame() ?? "").toContain("No OAuth information yet");
getOAuthState.mockResolvedValue(sampleOAuthState);
rerender(
<AuthTab
{...baseProps}
inspectorClient={client}
oauthStatus="idle"
oauthMessage={null}
connectionStatus="connected"
/>,
);
await tick();
expect(getOAuthState).toHaveBeenCalledTimes(2);
expect(lastFrame() ?? "").toContain("Authorized");
expect(lastFrame() ?? "").toContain("OAuth Details");
});
it("refreshes OAuth state when oauthComplete fires", async () => {
const { client, getOAuthState, fire, listeners } =
makeClient(sampleOAuthState);

View file

@ -2,6 +2,7 @@ import React from "react";
import { describe, it, expect, vi, afterEach } from "vitest";
import { render } from "ink-testing-library";
import type { InspectorClient } from "@inspector/core/mcp/index.js";
import { AuthRecoveryRequiredError } from "@inspector/core/auth/challenge.js";
import type { Prompt } from "@modelcontextprotocol/sdk/types.js";
// ScrollView: passthrough so the results JSX actually mounts (and is counted
@ -341,4 +342,34 @@ describe("PromptTestModal", () => {
await tick();
expect(true).toBe(true);
});
it("delegates AuthRecoveryRequiredError to onAuthRecoveryRequired and closes", async () => {
const recovery = new AuthRecoveryRequiredError(
new URL("https://auth.example.com/authorize"),
{ reason: "insufficient_scope", requiredScopes: ["weather:read"] },
);
const getPrompt = vi.fn().mockRejectedValue(recovery);
const onClose = vi.fn();
const onAuthRecoveryRequired = vi.fn();
setFormSubmitValue({ topic: "x" });
const { stdin } = render(
<PromptTestModal
prompt={makePrompt()}
inspectorClient={{ getPrompt } as unknown as InspectorClient}
width={120}
height={30}
onClose={onClose}
onAuthRecoveryRequired={onAuthRecoveryRequired}
/>,
);
await tick();
stdin.write(ENTER);
await tick();
await tick();
expect(onAuthRecoveryRequired).toHaveBeenCalledWith(recovery);
expect(onClose).toHaveBeenCalledTimes(1);
});
});

View file

@ -2,6 +2,7 @@ import React from "react";
import { describe, it, expect, vi, afterEach } from "vitest";
import { render } from "ink-testing-library";
import type { InspectorClient } from "@inspector/core/mcp/index.js";
import { AuthRecoveryRequiredError } from "@inspector/core/auth/challenge.js";
// ScrollView passthrough so the results JSX actually mounts (and is covered).
vi.mock("ink-scroll-view", () => import("./helpers/inkScrollViewMock.js"));
@ -264,4 +265,32 @@ describe("ResourceTestModal", () => {
await tick();
api.unmount();
});
it("delegates AuthRecoveryRequiredError to onAuthRecoveryRequired and closes", async () => {
const recovery = new AuthRecoveryRequiredError(
new URL("https://auth.example.com/authorize"),
{ reason: "insufficient_scope", requiredScopes: ["weather:read"] },
);
const read = vi.fn().mockRejectedValue(recovery);
const onClose = vi.fn();
const onAuthRecoveryRequired = vi.fn();
const api = render(
<ResourceTestModal
template={makeTemplate()}
inspectorClient={fakeClient(read)}
width={80}
height={24}
onClose={onClose}
onAuthRecoveryRequired={onAuthRecoveryRequired}
/>,
);
await tick();
setSubmitValue({ name: "world" });
api.stdin.write("\r");
await tick();
await tick();
expect(onAuthRecoveryRequired).toHaveBeenCalledWith(recovery);
expect(onClose).toHaveBeenCalledTimes(1);
api.unmount();
});
});

View file

@ -0,0 +1,49 @@
import { describe, it, expect } from "vitest";
import {
isStandardOAuthStepUp,
isStepUpConfirmation,
stepUpConfirmMessage,
stepUpInsufficientScopeMessage,
} from "../src/utils/tuiOAuth.js";
describe("tuiOAuth", () => {
it("detects standard OAuth step-up", () => {
expect(
isStandardOAuthStepUp(
{ reason: "insufficient_scope", requiredScopes: ["weather:read"] },
{ enterpriseManaged: false },
),
).toBe(true);
expect(
isStandardOAuthStepUp(
{ reason: "insufficient_scope", requiredScopes: ["weather:read"] },
{ enterpriseManaged: true },
),
).toBe(false);
});
it("detects EMA step-up confirmation", () => {
expect(
isStepUpConfirmation(
{ reason: "insufficient_scope", requiredScopes: ["env:read"] },
{ enterpriseManaged: true },
),
).toBe(true);
expect(
isStepUpConfirmation(
{ reason: "insufficient_scope", requiredScopes: ["weather:read"] },
{ enterpriseManaged: false },
),
).toBe(true);
});
it("formats step-up messages", () => {
const challenge = {
reason: "insufficient_scope" as const,
requiredScopes: ["weather:read"],
context: { toolName: "get_temp" },
};
expect(stepUpConfirmMessage(challenge)).toMatch(/get_temp/);
expect(stepUpInsufficientScopeMessage(challenge)).toMatch(/get_temp/);
});
});

View file

@ -45,7 +45,10 @@ import {
CallbackNavigation,
MutableRedirectUrlProvider,
isUnauthorizedError,
AuthRecoveryRequiredError,
} from "@inspector/core/auth/index.js";
import type { AuthChallenge } from "@inspector/core/auth/challenge.js";
import type { TypedEvent } from "@inspector/core/mcp/inspectorClientEventTarget.js";
import { isEmaClientNotConfiguredError } from "@inspector/core/auth/ema/clientConfigError.js";
import type { ClientConfig } from "@inspector/core/client/types.js";
import {
@ -57,9 +60,15 @@ import {
createOAuthCallbackServer,
type OAuthCallbackServer,
NodeOAuthStorage,
runRunnerInteractiveOAuth,
} from "@inspector/core/auth/node/index.js";
import { getTuiLogger } from "./logger.js";
import { openUrl } from "./utils/openUrl.js";
import {
isStepUpConfirmation,
stepUpInsufficientScopeMessage,
} from "./utils/tuiOAuth.js";
import { emaStepUpFailureMessage } from "@inspector/core/auth/oauthUx.js";
import { Tabs } from "./components/Tabs.js";
import { type TabType, tabs as tabList } from "./components/tabsConfig.js";
import { InfoTab } from "./components/InfoTab.js";
@ -141,14 +150,6 @@ function App({
callbackUrlConfig,
}: AppProps) {
const { exit } = useApp();
const callbackServerBaseOptions = useMemo(
() => ({
port: callbackUrlConfig.port,
hostname: callbackUrlConfig.hostname,
path: callbackUrlConfig.pathname,
}),
[callbackUrlConfig],
);
useEffect(() => {
getTuiLogger().info(
@ -174,9 +175,22 @@ function App({
>("idle");
const [oauthMessage, setOauthMessage] = useState<string | null>(null);
const [oauthRevision, setOauthRevision] = useState(0);
const [pendingStepUp, setPendingStepUp] = useState<{
serverName: string;
challenge: AuthChallenge;
authorizationUrl: URL;
enterpriseManaged?: boolean;
} | null>(null);
const pendingStepUpRef = useRef(pendingStepUp);
useEffect(() => {
pendingStepUpRef.current = pendingStepUp;
}, [pendingStepUp]);
const [connectError, setConnectError] = useState<string | null>(null);
const oauthInProgressRef = useRef(false);
const callbackServerRef = useRef<OAuthCallbackServer | null>(null);
const selectedServerRef = useRef<string | null>(null);
const mcpServersRef = useRef(mcpServers);
const inspectorClientsRef = useRef<Record<string, InspectorClient>>({});
// Tool test modal state
const [toolTestModal, setToolTestModal] = useState<{
@ -419,12 +433,22 @@ function App({
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
// Clear OAuth status when switching servers
// Clear OAuth status when switching servers; drop step-up for other servers.
useEffect(() => {
setOauthStatus("idle");
setOauthMessage(null);
const stepUp = pendingStepUpRef.current;
if (stepUp && selectedServer && stepUp.serverName !== selectedServer) {
setPendingStepUp(null);
}
}, [selectedServer]);
useEffect(() => {
selectedServerRef.current = selectedServer;
mcpServersRef.current = mcpServers;
inspectorClientsRef.current = inspectorClients;
}, [selectedServer, mcpServers, inspectorClients]);
// Switch away from Auth tab when server is not OAuth-capable
useEffect(() => {
if (
@ -534,79 +558,184 @@ function App({
selectedManagedPromptsState,
);
// Shared ref for OAuth callback server; stop before starting new (avoids EADDRINUSE when prior auth failed without redirect)
// Connect — on 401 or mid-session auth recovery, run OAuth then retry.
const runOAuthAuthentication = useCallback(
async (options?: {
challenge?: AuthChallenge;
authorizationUrl?: URL;
/** When set, run OAuth for this server (may differ from the selected server). */
serverName?: string;
}) => {
const serverName = options?.serverName ?? selectedServer;
if (!serverName) {
return;
}
const client = inspectorClientsRef.current[serverName];
const serverEntry = mcpServersRef.current[serverName];
const serverConfig = serverEntry?.config;
if (
!client ||
!serverConfig ||
!isOAuthCapableServerConfig(serverConfig)
) {
return;
}
if (oauthInProgressRef.current) return;
oauthInProgressRef.current = true;
getTuiLogger().info(
{ server: serverName },
"OAuth authentication started",
);
const existing = callbackServerRef.current;
if (existing) {
await existing.stop();
callbackServerRef.current = null;
}
const redirectUrlProvider = redirectUrlProvidersRef.current[serverName];
if (!redirectUrlProvider) {
oauthInProgressRef.current = false;
return;
}
try {
const result = await runRunnerInteractiveOAuth({
client,
redirectUrlProvider,
callbackListen: callbackUrlConfig,
createCallbackServer: createOAuthCallbackServer,
onCallbackServer: (server) => {
callbackServerRef.current = server;
},
authorizationUrl: options?.authorizationUrl,
authChallenge: options?.challenge,
});
// Connect — on 401, run OAuth then retry (same pattern as web App.tsx).
const runOAuthAuthentication = useCallback(async () => {
if (
!selectedServer ||
!selectedInspectorClient ||
!selectedServerConfig ||
!isOAuthCapableServerConfig(selectedServerConfig)
) {
return;
}
if (oauthInProgressRef.current) return;
oauthInProgressRef.current = true;
getTuiLogger().info(
{ server: selectedServer },
"OAuth authentication started",
);
const existing = callbackServerRef.current;
if (existing) {
await existing.stop();
callbackServerRef.current = null;
}
const callbackServer = createOAuthCallbackServer();
callbackServerRef.current = callbackServer;
let flowResolve: () => void;
let flowReject: (err: Error) => void;
const flowDone = new Promise<void>((resolve, reject) => {
flowResolve = resolve;
flowReject = reject;
});
try {
const { redirectUrl } = await callbackServer.start({
...callbackServerBaseOptions,
onCallback: async (params) => {
try {
await selectedInspectorClient!.completeOAuthFlow(params.code);
flowResolve!();
} catch (err) {
flowReject!(err instanceof Error ? err : new Error(String(err)));
} finally {
callbackServerRef.current = null;
}
},
onError: (params) => {
flowReject!(
new Error(
params.error_description ?? params.error ?? "OAuth error",
),
);
void callbackServer.stop();
callbackServerRef.current = null;
},
if (result.kind === "insufficient_scope") {
setOauthStatus("error");
setOauthMessage(stepUpInsufficientScopeMessage(result.challenge));
return;
}
if (result.kind === "success" || result.kind === "already_authorized") {
setOauthRevision((n) => n + 1);
}
} finally {
oauthInProgressRef.current = false;
callbackServerRef.current = null;
}
},
[selectedServer, callbackUrlConfig],
);
const presentStepUpForServer = useCallback(
(
serverName: string,
challenge: AuthChallenge,
authorizationUrl: URL,
enterpriseManaged?: boolean,
) => {
const pending = pendingStepUpRef.current;
if (pending && pending.serverName !== serverName) {
setOauthMessage(
"A step-up prompt is already open. Complete or decline it before continuing.",
);
return;
}
if (pending?.serverName === serverName) {
setOauthMessage(
"Updated step-up request — review the scopes on the Auth tab.",
);
} else {
setOauthMessage(null);
}
setSelectedServer(serverName);
setPendingStepUp({
serverName,
challenge,
authorizationUrl,
enterpriseManaged,
});
const redirectUrlProvider =
redirectUrlProvidersRef.current[selectedServer];
if (redirectUrlProvider) {
redirectUrlProvider.redirectUrl = redirectUrl;
setActiveTab("auth");
setOauthStatus("idle");
setFocus("tabContentList");
},
[],
);
const handleAuthRecoveryRequired = useCallback(
(serverName: string, error: AuthRecoveryRequiredError) => {
const serverEntry = mcpServersRef.current[serverName];
const settings = serverEntry?.settings;
const client = inspectorClientsRef.current[serverName];
const needsStepUpConfirm =
error.emaStepUpConfirm ||
isStepUpConfirmation(error.authChallenge, settings);
if (needsStepUpConfirm) {
void (async () => {
if (
client &&
(await client.checkAuthChallengeSatisfied(error.authChallenge))
) {
setOauthStatus("idle");
setOauthMessage("Authorization updated. Retry your action.");
setOauthRevision((n) => n + 1);
return;
}
presentStepUpForServer(
serverName,
error.authChallenge,
error.authorizationUrl,
settings?.enterpriseManaged,
);
})();
return;
}
const authUrl = await selectedInspectorClient.authenticate();
if (authUrl !== undefined) {
await flowDone;
void (async () => {
if (
client &&
(await client.checkAuthChallengeSatisfied(error.authChallenge))
) {
setOauthStatus("idle");
setOauthMessage("Authorization updated. Retry your action.");
setOauthRevision((n) => n + 1);
return;
}
const needsSwitch = selectedServerRef.current !== serverName;
if (needsSwitch) {
setSelectedServer(serverName);
setActiveTab("auth");
setOauthMessage(
`Authentication required for "${serverName}". Re-authenticating…`,
);
} else {
setOauthMessage(null);
}
setOauthStatus("authenticating");
try {
await runOAuthAuthentication({
challenge: error.authChallenge,
authorizationUrl: error.authorizationUrl,
serverName,
});
setOauthStatus("idle");
setOauthMessage("Authorization updated. Retry your action.");
} catch (authErr) {
const authMsg =
authErr instanceof Error ? authErr.message : String(authErr);
setOauthStatus("error");
setOauthMessage(authMsg);
}
})();
},
[presentStepUpForServer, runOAuthAuthentication],
);
const onAuthRecoveryRequired = useCallback(
(error: AuthRecoveryRequiredError) => {
if (selectedServer) {
handleAuthRecoveryRequired(selectedServer, error);
}
setOauthRevision((n) => n + 1);
} finally {
oauthInProgressRef.current = false;
}
}, [
selectedServer,
selectedInspectorClient,
selectedServerConfig,
callbackServerBaseOptions,
]);
},
[selectedServer, handleAuthRecoveryRequired],
);
const handleConnect = useCallback(async () => {
if (!selectedServer || !selectedInspectorClient || !selectedServerConfig) {
@ -618,6 +747,7 @@ function App({
setConnectError(null);
setOauthStatus("idle");
setOauthMessage(null);
setOauthRevision((n) => n + 1);
};
try {
@ -639,13 +769,14 @@ function App({
try {
setOauthStatus("authenticating");
setOauthMessage(null);
// Tear down the failed handshake transport so the post-OAuth connect
// creates a fresh transport with tokens from storage (same as web,
// which reloads the client after redirect).
await disconnectInspector();
await runOAuthAuthentication();
await finishConnect();
} catch (authErr) {
if (authErr instanceof AuthRecoveryRequiredError) {
handleAuthRecoveryRequired(selectedServer, authErr);
return;
}
const authMsg =
authErr instanceof Error ? authErr.message : String(authErr);
setConnectError(authMsg);
@ -659,6 +790,11 @@ function App({
}
return;
}
if (err instanceof AuthRecoveryRequiredError && selectedServer) {
handleAuthRecoveryRequired(selectedServer, err);
return;
}
}
}, [
selectedServer,
@ -667,8 +803,63 @@ function App({
connectInspector,
disconnectInspector,
runOAuthAuthentication,
handleAuthRecoveryRequired,
]);
useEffect(() => {
const cleanups: Array<() => void> = [];
for (const [serverName, client] of Object.entries(inspectorClients)) {
const onAmbient = (): void => {
if (selectedServerRef.current !== serverName) return;
setOauthStatus("idle");
setOauthMessage("Refreshing authorization…");
};
const onRecovered = (): void => {
if (selectedServerRef.current !== serverName) return;
setOauthMessage(null);
setOauthRevision((n) => n + 1);
};
const onInteractive = (
event: TypedEvent<"authChallengeInteractive">,
): void => {
handleAuthRecoveryRequired(
serverName,
new AuthRecoveryRequiredError(
event.detail.authorizationUrl,
event.detail.challenge,
),
);
};
const onOAuthError = (event: TypedEvent<"oauthError">): void => {
if (selectedServerRef.current !== serverName) return;
const message =
event.detail.error instanceof Error
? event.detail.error.message
: String(event.detail.error);
setOauthStatus("error");
setOauthMessage(message);
};
client.addEventListener("authChallengeAmbient", onAmbient);
client.addEventListener("authChallengeRecovered", onRecovered);
client.addEventListener("authChallengeInteractive", onInteractive);
client.addEventListener("oauthError", onOAuthError);
cleanups.push(() => {
client.removeEventListener("authChallengeAmbient", onAmbient);
client.removeEventListener("authChallengeRecovered", onRecovered);
client.removeEventListener("authChallengeInteractive", onInteractive);
client.removeEventListener("oauthError", onOAuthError);
});
}
return () => {
for (const cleanup of cleanups) {
cleanup();
}
};
}, [inspectorClients, handleAuthRecoveryRequired]);
// Disconnect handler
const handleDisconnect = useCallback(async () => {
if (!selectedServer) return;
@ -1110,13 +1301,6 @@ function App({
const nextTab = tabAccelerators[input.toLowerCase()]!;
setActiveTab(nextTab);
setFocus(nextTab === "auth" ? "tabContentList" : "tabs");
} else if (
activeTab === "auth" &&
showAuthTab &&
input.toLowerCase() === "s"
) {
void handleClearOAuth();
setFocus("tabContentList");
} else if (key.tab && !key.shift) {
// Flat focus order: servers -> tabs -> list -> details -> wrap to servers
const focusOrder: FocusArea[] =
@ -1470,6 +1654,86 @@ function App({
oauthStatus={oauthStatus}
oauthMessage={oauthMessage}
oauthRevision={oauthRevision}
pendingStepUp={
pendingStepUp?.serverName === selectedServer
? {
challenge: pendingStepUp.challenge,
authorizationScopes:
pendingStepUp.challenge.authorizationScopes,
enterpriseManaged: pendingStepUp.enterpriseManaged,
}
: null
}
onAuthorizeStepUp={() => {
if (!pendingStepUp || !selectedInspectorClient) return;
const { challenge, authorizationUrl, enterpriseManaged } =
pendingStepUp;
setPendingStepUp(null);
setOauthStatus("authenticating");
void (async () => {
try {
if (enterpriseManaged) {
const outcome =
await selectedInspectorClient.handleAuthChallenge(
challenge,
{ confirmedStepUp: true },
);
if (outcome.kind === "satisfied") {
await disconnectInspector().catch(() => {});
await connectInspector();
setOauthStatus("idle");
setOauthMessage(
"Step-up authorization succeeded. Retry your action.",
);
setOauthRevision((n) => n + 1);
return;
}
if (outcome.kind === "interactive") {
await runOAuthAuthentication({
challenge: outcome.challenge,
authorizationUrl: outcome.authorizationUrl,
});
setOauthStatus("idle");
setOauthMessage(
"Step-up authorization succeeded. Retry your action.",
);
return;
}
if (outcome.kind === "failed") {
setOauthStatus("error");
setOauthMessage(
emaStepUpFailureMessage(outcome.error.message),
);
return;
}
setOauthStatus("idle");
setOauthMessage(
"Step-up authorization succeeded. Retry your action.",
);
return;
}
await runOAuthAuthentication({
challenge,
authorizationUrl,
});
setOauthStatus("idle");
setOauthMessage(
"Step-up authorization succeeded. Retry your action.",
);
} catch (authErr) {
const authMsg =
authErr instanceof Error
? authErr.message
: String(authErr);
setOauthStatus("error");
setOauthMessage(authMsg);
}
})();
}}
onCancelStepUp={() => {
setPendingStepUp(null);
setOauthMessage("Authorization cancelled.");
}}
width={contentWidth}
height={contentHeight}
focused={
@ -1517,8 +1781,14 @@ function App({
inspectorClient: selectedInspectorClient,
});
}}
onAuthRecoveryRequired={onAuthRecoveryRequired}
modalOpen={
!!(toolTestModal || resourceTestModal || detailsModal)
!!(
toolTestModal ||
resourceTestModal ||
promptTestModal ||
detailsModal
)
}
/>
) : activeTab === "prompts" &&
@ -1552,6 +1822,7 @@ function App({
inspectorClient: selectedInspectorClient,
});
}}
onAuthRecoveryRequired={onAuthRecoveryRequired}
modalOpen={
!!(
toolTestModal ||
@ -1680,6 +1951,10 @@ function App({
width={dimensions.width}
height={dimensions.height}
onClose={() => setToolTestModal(null)}
onAuthRecoveryRequired={(error) => {
onAuthRecoveryRequired(error);
setToolTestModal(null);
}}
/>
)}
@ -1691,6 +1966,10 @@ function App({
width={dimensions.width}
height={dimensions.height}
onClose={() => setResourceTestModal(null)}
onAuthRecoveryRequired={(error) => {
onAuthRecoveryRequired(error);
setResourceTestModal(null);
}}
/>
)}
@ -1701,6 +1980,10 @@ function App({
width={dimensions.width}
height={dimensions.height}
onClose={() => setPromptTestModal(null)}
onAuthRecoveryRequired={(error) => {
onAuthRecoveryRequired(error);
setPromptTestModal(null);
}}
/>
)}

View file

@ -8,12 +8,19 @@ import type {
ConnectionStatus,
} from "@inspector/core/mcp/index.js";
import type { OAuthConnectionState } from "@inspector/core/auth/types.js";
import type { AuthChallenge } from "@inspector/core/auth/challenge.js";
import {
formatAuthProtocol,
formatClientRegistrationKind,
formatIdpSession,
formatScopes,
} from "../utils/oauthDisplay.js";
import {
stepUpAuthorizeActionLabel,
stepUpConfirmMessage,
stepUpFollowUpMessage,
stepUpModalTitle,
} from "../utils/tuiOAuth.js";
interface AuthTabProps {
serverName: string | null;
@ -22,6 +29,13 @@ interface AuthTabProps {
oauthStatus: "idle" | "authenticating" | "error";
oauthMessage: string | null;
oauthRevision: number;
pendingStepUp?: {
challenge: AuthChallenge;
authorizationScopes?: string[];
enterpriseManaged?: boolean;
} | null;
onAuthorizeStepUp?: () => void;
onCancelStepUp?: () => void;
width: number;
height: number;
focused?: boolean;
@ -44,6 +58,9 @@ export function AuthTab({
oauthStatus,
oauthMessage,
oauthRevision,
pendingStepUp,
onAuthorizeStepUp,
onCancelStepUp,
width,
height,
focused = false,
@ -58,6 +75,7 @@ export function AuthTab({
>(undefined);
const [clearedConfirmation, setClearedConfirmation] = useState(false);
const [lastClearDisconnected, setLastClearDisconnected] = useState(false);
const [stepUpChoiceIndex, setStepUpChoiceIndex] = useState(0);
const refreshOAuthState = useCallback(async () => {
if (!inspectorClient) {
@ -70,13 +88,17 @@ export function AuthTab({
useEffect(() => {
void refreshOAuthState();
}, [refreshOAuthState, oauthRevision]);
}, [refreshOAuthState, oauthRevision, connectionStatus]);
useEffect(() => {
setClearedConfirmation(false);
setLastClearDisconnected(false);
}, [oauthRevision]);
useEffect(() => {
setStepUpChoiceIndex(0);
}, [pendingStepUp]);
useEffect(() => {
if (!inspectorClient) return;
@ -93,6 +115,34 @@ export function AuthTab({
(input: string, key: Key) => {
if (!focused) return;
if (pendingStepUp) {
if (key.upArrow) {
setStepUpChoiceIndex((i) => Math.max(0, i - 1));
return;
}
if (key.downArrow) {
setStepUpChoiceIndex((i) => Math.min(1, i + 1));
return;
}
if (key.return) {
if (stepUpChoiceIndex === 0) {
onAuthorizeStepUp?.();
} else {
onCancelStepUp?.();
}
return;
}
if (input.toLowerCase() === "a") {
onAuthorizeStepUp?.();
return;
}
if (input.toLowerCase() === "c") {
onCancelStepUp?.();
return;
}
return;
}
if (key.upArrow && scrollViewRef.current) {
scrollViewRef.current.scrollBy(-1);
} else if (key.downArrow && scrollViewRef.current) {
@ -139,6 +189,66 @@ export function AuthTab({
{oauthStatus === "error" && oauthMessage && (
<Text color="red">{oauthMessage}</Text>
)}
{oauthStatus === "idle" && oauthMessage && (
<Text color="cyan">{oauthMessage}</Text>
)}
{pendingStepUp ? (
<Box marginTop={1} flexDirection="column" gap={0}>
<Text bold color="yellow">
{stepUpModalTitle({
enterpriseManaged: pendingStepUp.enterpriseManaged,
})}
</Text>
<Text>
{stepUpConfirmMessage(pendingStepUp.challenge, {
enterpriseManaged: pendingStepUp.enterpriseManaged,
})}{" "}
{stepUpFollowUpMessage({
enterpriseManaged: pendingStepUp.enterpriseManaged,
})}
</Text>
{(() => {
const scopes =
pendingStepUp.authorizationScopes ??
pendingStepUp.challenge.requiredScopes;
if (!scopes?.length) return null;
return (
<Box marginTop={1} flexDirection="column">
<Text dimColor>
{pendingStepUp.enterpriseManaged
? "Permissions requested:"
: "Scopes requested:"}
</Text>
{scopes.map((scope) => (
<Text key={scope} dimColor>
{" "}
• {scope}
</Text>
))}
</Box>
);
})()}
<Box marginTop={1} flexDirection="column">
<SelectableItem isSelected={stepUpChoiceIndex === 0} bold>
{(() => {
const label = stepUpAuthorizeActionLabel({
enterpriseManaged: pendingStepUp.enterpriseManaged,
});
return (
<>
<Text underline>{label[0]}</Text>
{label.slice(1)}
</>
);
})()}
</SelectableItem>
<SelectableItem isSelected={stepUpChoiceIndex === 1}>
<Text underline>C</Text>ancel
</SelectableItem>
</Box>
</Box>
) : null}
{oauthState ? (
<Box flexDirection="column" marginTop={1} gap={0}>
@ -229,7 +339,9 @@ export function AuthTab({
backgroundColor="gray"
>
<Text bold color="white">
S {isLiveConnection ? "clear+disconnect" : "clear"}, ↑/↓ scroll
{pendingStepUp
? "↑/↓ select, Enter confirm, A authorize, C cancel"
: `S ${isLiveConnection ? "clear+disconnect" : "clear"}, ↑/↓ scroll`}
</Text>
</Box>
)}

View file

@ -2,6 +2,7 @@ import React, { useState } from "react";
import { Box, Text, useInput, type Key } from "ink";
import { Form } from "ink-form";
import { InspectorClient } from "@inspector/core/mcp/index.js";
import { AuthRecoveryRequiredError } from "@inspector/core/auth/challenge.js";
import type {
Prompt,
GetPromptResult,
@ -29,6 +30,7 @@ interface PromptTestModalProps {
width: number;
height: number;
onClose: () => void;
onAuthRecoveryRequired?: (error: AuthRecoveryRequiredError) => void;
}
type ModalState = "form" | "loading" | "results";
@ -47,6 +49,7 @@ export function PromptTestModal({
width,
height,
onClose,
onAuthRecoveryRequired,
}: PromptTestModalProps) {
const [state, setState] = useState<ModalState>("form");
const [result, setResult] = useState<PromptResult | null>(null);
@ -144,6 +147,11 @@ export function PromptTestModal({
});
setState("results");
} catch (error) {
if (error instanceof AuthRecoveryRequiredError) {
onAuthRecoveryRequired?.(error);
onClose();
return;
}
const duration = Date.now() - startTime;
const errorMessage = getErrorMessage(error);

View file

@ -2,6 +2,7 @@ import React, { useState, useEffect, useRef } from "react";
import { Box, Text, useInput, type Key } from "ink";
import { ScrollView, type ScrollViewRef } from "ink-scroll-view";
import type { InspectorClient } from "@inspector/core/mcp/index.js";
import { AuthRecoveryRequiredError } from "@inspector/core/auth/challenge.js";
import type {
Prompt,
PromptArgument,
@ -18,6 +19,7 @@ interface PromptsTabProps {
focusedPane?: "list" | "details" | null;
onViewDetails?: (prompt: Prompt & { result?: GetPromptResult }) => void;
onFetchPrompt?: (prompt: Prompt) => void;
onAuthRecoveryRequired?: (error: AuthRecoveryRequiredError) => void;
modalOpen?: boolean;
}
@ -29,6 +31,7 @@ export function PromptsTab({
focusedPane = null,
onViewDetails,
onFetchPrompt,
onAuthRecoveryRequired,
modalOpen = false,
}: PromptsTabProps) {
const visibleCount = Math.max(1, height - 7);
@ -64,6 +67,10 @@ export function PromptsTab({
});
}
} catch (error) {
if (error instanceof AuthRecoveryRequiredError) {
onAuthRecoveryRequired?.(error);
return;
}
setError(
error instanceof Error ? error.message : "Failed to get prompt",
);

View file

@ -2,6 +2,7 @@ import React, { useState } from "react";
import { Box, Text, useInput, type Key } from "ink";
import { Form } from "ink-form";
import { InspectorClient } from "@inspector/core/mcp/index.js";
import { AuthRecoveryRequiredError } from "@inspector/core/auth/challenge.js";
import type { ReadResourceResult } from "@modelcontextprotocol/sdk/types.js";
import { uriTemplateToForm } from "../utils/uriTemplateToForm.js";
import { ScrollView, type ScrollViewRef } from "ink-scroll-view";
@ -30,6 +31,7 @@ interface ResourceTestModalProps {
width: number;
height: number;
onClose: () => void;
onAuthRecoveryRequired?: (error: AuthRecoveryRequiredError) => void;
}
type ModalState = "form" | "loading" | "results";
@ -49,6 +51,7 @@ export function ResourceTestModal({
width,
height,
onClose,
onAuthRecoveryRequired,
}: ResourceTestModalProps) {
const [state, setState] = useState<ModalState>("form");
const [result, setResult] = useState<ResourceResult | null>(null);
@ -150,6 +153,11 @@ export function ResourceTestModal({
});
setState("results");
} catch (error) {
if (error instanceof AuthRecoveryRequiredError) {
onAuthRecoveryRequired?.(error);
onClose();
return;
}
const duration = Date.now() - startTime;
const errorMessage = getErrorMessage(error);

View file

@ -2,6 +2,7 @@ import React, { useState, useEffect, useRef, useMemo } from "react";
import { Box, Text, useInput, type Key } from "ink";
import { ScrollView, type ScrollViewRef } from "ink-scroll-view";
import type { InspectorClient } from "@inspector/core/mcp/index.js";
import { AuthRecoveryRequiredError } from "@inspector/core/auth/challenge.js";
import type {
Resource,
ReadResourceResult,
@ -27,6 +28,7 @@ interface ResourcesTabProps {
) => void;
onFetchResource?: (resource: Resource) => void;
onFetchTemplate?: (template: ResourceTemplate) => void;
onAuthRecoveryRequired?: (error: AuthRecoveryRequiredError) => void;
modalOpen?: boolean;
}
@ -41,6 +43,7 @@ export function ResourcesTab({
onViewDetails,
onFetchResource,
onFetchTemplate,
onAuthRecoveryRequired,
modalOpen = false,
}: ResourcesTabProps) {
const [error, setError] = useState<string | null>(null);
@ -169,6 +172,10 @@ export function ResourcesTab({
await inspectorClient.readResource(shouldFetchResource);
setResourceContent(invocation.result);
} catch (err) {
if (err instanceof AuthRecoveryRequiredError) {
onAuthRecoveryRequired?.(err);
return;
}
setError(
err instanceof Error ? err.message : "Failed to read resource",
);
@ -180,7 +187,7 @@ export function ResourcesTab({
};
fetchContent();
}, [shouldFetchResource, inspectorClient]);
}, [shouldFetchResource, inspectorClient, onAuthRecoveryRequired]);
const listWidth = Math.floor(width * 0.4);
const detailWidth = width - listWidth;

View file

@ -2,6 +2,7 @@ import React, { useState } from "react";
import { Box, Text, useInput, type Key } from "ink";
import { Form } from "ink-form";
import { InspectorClient } from "@inspector/core/mcp/index.js";
import { AuthRecoveryRequiredError } from "@inspector/core/auth/challenge.js";
import type { Tool, CallToolResult } from "@modelcontextprotocol/sdk/types.js";
import type { JsonValue } from "@inspector/core/mcp/index.js";
import { schemaToForm } from "../utils/schemaToForm.js";
@ -13,6 +14,7 @@ interface ToolTestModalProps {
width: number;
height: number;
onClose: () => void;
onAuthRecoveryRequired?: (error: AuthRecoveryRequiredError) => void;
}
type ModalState = "form" | "loading" | "results";
@ -31,6 +33,7 @@ export function ToolTestModal({
width,
height,
onClose,
onAuthRecoveryRequired,
}: ToolTestModalProps) {
const [state, setState] = useState<ModalState>("form");
const [result, setResult] = useState<ToolResult | null>(null);
@ -150,6 +153,11 @@ export function ToolTestModal({
}
setState("results");
} catch (error) {
if (error instanceof AuthRecoveryRequiredError) {
onAuthRecoveryRequired?.(error);
onClose();
return;
}
const duration = Date.now() - startTime;
const errorObj =
error instanceof Error

View file

@ -0,0 +1,36 @@
import type { AuthChallenge } from "@inspector/core/auth/challenge.js";
import {
isStandardOAuthStepUp as isCoreStandardOAuthStepUp,
isStepUpConfirmation as isCoreStepUpConfirmation,
stepUpConfirmMessage,
stepUpInsufficientScopeMessage,
} from "@inspector/core/auth/oauthUx.js";
import type { InspectorServerSettings } from "@inspector/core/mcp/types.js";
export { stepUpConfirmMessage, stepUpInsufficientScopeMessage };
export {
stepUpAuthorizeActionLabel,
stepUpFollowUpMessage,
stepUpModalTitle,
} from "@inspector/core/auth/oauthUx.js";
/** Standard-OAuth step-up (not EMA silent re-mint). */
export function isStandardOAuthStepUp(
challenge: AuthChallenge,
settings?: InspectorServerSettings,
): boolean {
return isCoreStandardOAuthStepUp(challenge, {
enterpriseManaged: settings?.enterpriseManaged,
});
}
/** Standard or EMA step-up that requires in-app confirmation before OAuth. */
export function isStepUpConfirmation(
challenge: AuthChallenge,
settings?: InspectorServerSettings,
): boolean {
return isCoreStepUpConfirmation(challenge, {
enterpriseManaged: settings?.enterpriseManaged,
});
}

View file

@ -75,6 +75,8 @@ vi.mock("@inspector/core/mcp/index.js", async (importOriginal) => {
getRoots = vi.fn().mockReturnValue([]);
setRoots = vi.fn().mockResolvedValue(undefined);
setServerSettings = vi.fn();
resumeAfterOAuth = vi.fn().mockResolvedValue(undefined);
checkAuthChallengeSatisfied = vi.fn().mockResolvedValue(true);
}
const instances: FakeInspectorClient[] = [];
return {
@ -257,6 +259,8 @@ vi.mock("./components/views/InspectorView/InspectorView", () => ({
getPromptState?: { status?: string };
readResourceState?: { status?: string };
currentLogLevel?: string;
activeTab?: string;
onActiveTabChange: (tab: string) => void;
onToggleConnection: (id: string) => void;
onToolsUiChange: (next: {
selectedToolName?: string;
@ -314,6 +318,10 @@ vi.mock("./components/views/InspectorView/InspectorView", () => ({
{props.readResourceState?.status ?? "none"}
</span>
<span data-testid="log-level">{props.currentLogLevel}</span>
<span data-testid="active-tab">{props.activeTab ?? "none"}</span>
<button onClick={() => props.onActiveTabChange("Servers")}>
switch-servers-tab
</button>
<button onClick={() => props.onToggleConnection("A")}>connect</button>
<button
onClick={() =>
@ -404,6 +412,13 @@ vi.mock("./components/views/InspectorView/InspectorView", () => ({
}));
import App from "./App";
import { OAUTH_CALLBACK_PATH } from "./utils/oauthFlow.js";
import { INSPECTOR_SERVERS_TAB } from "./utils/inspectorTabs.js";
import {
readOAuthResumeSnapshot,
writeOAuthResumeSnapshot,
type OAuthResumeSnapshot,
} from "./utils/oauthResume.js";
import * as McpIndex from "@inspector/core/mcp/index.js";
import * as FetchLogModule from "@inspector/core/mcp/state/fetchRequestLogState.js";
import { useManagedRequestorTasks } from "@inspector/core/react/useManagedRequestorTasks.js";
@ -1589,3 +1604,153 @@ describe("App history pin/replay", () => {
);
});
});
describe("App OAuth resume lifecycle", () => {
const storage = new Map<string, string>();
const writeTestOAuthSnapshot = (
overrides?: Partial<OAuthResumeSnapshot>,
): void => {
writeOAuthResumeSnapshot({
version: 1,
serverId: "A",
activeTab: "Tools",
authKind: "reauth",
tabUi: {},
...overrides,
});
};
beforeEach(() => {
clientInstances.length = 0;
storage.clear();
notificationsMock.show.mockClear();
vi.stubGlobal("sessionStorage", {
getItem: (key: string) => storage.get(key) ?? null,
setItem: (key: string, value: string) => {
storage.set(key, value);
},
removeItem: (key: string) => {
storage.delete(key);
},
});
window.history.replaceState({}, "", "/");
});
afterEach(() => {
vi.unstubAllGlobals();
window.history.replaceState({}, "", "/");
});
it("preserves the OAuth resume snapshot when the transport disconnects", async () => {
const user = userEvent.setup();
renderWithMantine(<App />);
await user.click(screen.getByText("connect"));
await waitFor(() => expect(clientInstances).toHaveLength(1));
writeTestOAuthSnapshot();
await user.click(screen.getByText("select-tool"));
await waitFor(() =>
expect(screen.getByTestId("selected-tool")).toHaveTextContent("get_acts"),
);
act(() => {
clientInstances[0].dispatchEvent(new Event("disconnect"));
});
await waitFor(() =>
expect(screen.getByTestId("selected-tool")).toHaveTextContent("none"),
);
expect(readOAuthResumeSnapshot()?.serverId).toBe("A");
});
it("preserves the OAuth resume snapshot when reconnect rebuilds the client", async () => {
const user = userEvent.setup();
renderWithMantine(<App />);
await user.click(screen.getByText("connect"));
await waitFor(() => expect(clientInstances).toHaveLength(1));
writeTestOAuthSnapshot();
act(() => {
clientInstances[0].dispatchEvent(new Event("disconnect"));
});
expect(readOAuthResumeSnapshot()?.serverId).toBe("A");
await user.click(screen.getByText("connect"));
await waitFor(() => expect(clientInstances).toHaveLength(2));
expect(readOAuthResumeSnapshot()?.serverId).toBe("A");
});
it("clears the OAuth resume snapshot on explicit disconnect toggle", async () => {
const user = userEvent.setup();
renderWithMantine(<App />);
await user.click(screen.getByText("connect"));
await waitFor(() => expect(clientInstances).toHaveLength(1));
writeTestOAuthSnapshot();
expect(readOAuthResumeSnapshot()?.serverId).toBe("A");
await user.click(screen.getByText("connect"));
await waitFor(() => expect(readOAuthResumeSnapshot()).toBeUndefined());
});
it("matches the OAuth callback to the pending server when a resume snapshot exists", async () => {
writeTestOAuthSnapshot();
window.history.replaceState({}, "", `${OAUTH_CALLBACK_PATH}?code=test`);
renderWithMantine(<App />);
await waitFor(() => expect(clientInstances).toHaveLength(1));
const client = clientInstances[0] as unknown as {
resumeAfterOAuth: ReturnType<typeof vi.fn>;
};
await waitFor(() =>
expect(client.resumeAfterOAuth).toHaveBeenCalledWith(
"test",
expect.any(Object),
),
);
expect(readOAuthResumeSnapshot()).toBeUndefined();
expect(
notificationsMock.show.mock.calls.some(
([args]) => args.title === "OAuth callback could not be matched",
),
).toBe(false);
});
it("does not restore a stale tab after callback consume and reconnect", async () => {
writeTestOAuthSnapshot({ activeTab: "Tools" });
window.history.replaceState({}, "", `${OAUTH_CALLBACK_PATH}?code=test`);
renderWithMantine(<App />);
await waitFor(() =>
expect(screen.getByTestId("active-tab")).toHaveTextContent("Tools"),
);
expect(readOAuthResumeSnapshot()).toBeUndefined();
window.history.replaceState({}, "", "/");
const user = userEvent.setup();
// Explicit disconnect while still on Tools (InspectorView clamps to Servers
// visually, but App must reset activeTab so reconnect does not pop back).
await user.click(screen.getByText("connect"));
await waitFor(() =>
expect(screen.getByTestId("active-tab")).toHaveTextContent(
INSPECTOR_SERVERS_TAB,
),
);
expect(readOAuthResumeSnapshot()).toBeUndefined();
await user.click(screen.getByText("connect"));
await waitFor(() =>
expect(screen.getByTestId("active-tab")).toHaveTextContent(
INSPECTOR_SERVERS_TAB,
),
);
});
});

Some files were not shown because too many files have changed in this diff Show more