All of the mid-session and step-up auth work
This commit is contained in:
parent
1f31e0f1f8
commit
562bdd00f1
6 changed files with 11437 additions and 1212 deletions
|
|
@ -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?
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
230
clients/cli/__tests__/cliOAuth.test.ts
Normal file
230
clients/cli/__tests__/cliOAuth.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
242
clients/cli/__tests__/oauth-interactive.test.ts
Normal file
242
clients/cli/__tests__/oauth-interactive.test.ts
Normal 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);
|
||||
});
|
||||
|
|
@ -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
184
clients/cli/src/cliOAuth.ts
Normal 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();
|
||||
}
|
||||
}
|
||||
Loading…
Reference in a new issue