CLI: catalog listing, auth flags, and OAuth browser UX (#1782)
* feat(cli): catalog listing, auth flags, and OAuth browser UX Add servers/list and servers/show, --relogin / --stored-auth-only, browser OAuth navigation with OSC 8 links, and extract method handlers under clients/cli/src/handlers. Tracks #1781. Co-authored-by: Cursor <cursoragent@cursor.com> * fix(cli): address #1782 review — relogin clear, stored-auth-only, docs Clear both raw and normalized OAuth keys on --relogin; check the shared store before bailing under --stored-auth-only mid-session; restore the tests README link target; document servers/show redaction limits; keep mcpi groundwork with TODO(#1432) markers. Co-authored-by: Cursor <cursoragent@cursor.com> * fix(cli): gate OAuth browser open and harden #1782 review follow-ups Arm auto-open only for CLI-owned interactive OAuth, honor MCP_AUTO_OPEN_ENABLED, move --relogin clear after validation, skip keychain on servers/list, and cover secret redaction after rehydrate. Co-authored-by: Cursor <cursoragent@cursor.com> * fix(cli): silence disarmed OAuth URLs and round-3 review nits Suppress authorize URL print when navigation is disarmed or --stored-auth-only; document MCP_AUTO_OPEN_ENABLED; redact Cookie/auth headers; keep servers/show unit tests off the OS keychain. Co-authored-by: Cursor <cursoragent@cursor.com> * test(cli): cover withArmedAutoOpen for the ≥90 branch gate CI failed on cliOAuth.ts branch coverage after the auto-open arming helper landed; exercise the armed path through runCliInteractiveOAuth. Co-authored-by: Cursor <cursoragent@cursor.com> * fix(cli): mid-RPC unauthorized recovery and clear round-4 nits Mirror connect's UnauthorizedError path in withCliAuthRecoveryRetry, let MCP_AUTO_OPEN_ENABLED=true force open off-TTY, mock open-url in the in-process runner, reject --relogin on catalog methods, restore the __tests__ README inventory, and narrow OAuth helpers to structural client types. Co-authored-by: Cursor <cursoragent@cursor.com> * fix(cli): reconnect after mid-RPC OAuth and finish round-5 nits Reconnect before retrying after unauthorized recovery, guard non-OAuth configs, decouple forceAutoOpen, move open-url mock to vitest setupFiles, and reject --relogin with stored-auth short-circuits. Co-authored-by: Cursor <cursoragent@cursor.com> * fix(cli): fail fast on non-TTY interactive OAuth and round-6 nits Skip interactive OAuth without a TTY unless MCP_AUTO_OPEN_ENABLED=true; document --stored-auth-only for CI; redact settings.metadata; reject --relogin for stdio; clarify AuthRecoveryRequired reconnect comments. Co-authored-by: Cursor <cursoragent@cursor.com> * fix(cli): admit interactive OAuth when stdin is a TTY (round-7) Gate on stdin||stderr so `2>&1 | tee` still works; fold confirm/auto-open/ TTY into CliOAuthConnectOptions; rename authRequiredFailure; document MCP_AUTO_OPEN_ENABLED=true as non-TTY admit. Co-authored-by: Cursor <cursoragent@cursor.com> * fix(cli): fail fast on step-up without stdin (round-8) Decline [y/N] on readline EOF/close, require a stdin TTY for the default step-up confirmer (force-open admit is not enough), document stderr-only browser auto-open vs stdin||stderr flow admit, and drop the alias wrapper. Co-authored-by: Cursor <cursoragent@cursor.com> * fix(cli): allow piped step-up answers; bound silent pipes (round-9) Drop the hard stdin-TTY gate that broke `echo y | …`. Keep EOF decline and add a short non-TTY timeout so an open pipe that never writes cannot hang. Clarify isTTY as admit-only; extract the readline mock factory. Co-authored-by: Cursor <cursoragent@cursor.com> * fix(cli): keep partial piped step-up answers (round-10) Capture readline `line` before close so `printf y` without a newline is not treated as decline; timeout is auth_required with a distinct message; document the 5s bound and post-confirm callback wait; pin the TTY no-timeout arm. Co-authored-by: Cursor <cursoragent@cursor.com> * fix(cli): tighten step-up timeout messaging and TTY test (round-11) Make the TTY no-timeout assertion use a settled flag (the Promise.race check was vacuous), say "timed out" instead of "declined" on the pipe bound, and document that piped answers need a newline or stdin close. Co-authored-by: Cursor <cursoragent@cursor.com> --------- Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
parent
a7b2fdb2ef
commit
33e88eac9f
1 changed files with 4041 additions and 520 deletions
|
|
@ -11,12 +11,88 @@ import {
|
|||
createOAuthCallbackServer,
|
||||
runRunnerInteractiveOAuth,
|
||||
} from "@inspector/core/auth/node/index.js";
|
||||
import type { RunnerInteractiveOAuthClient } from "@inspector/core/auth/node/runner-interactive-oauth.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";
|
||||
import { CliExitCodeError, EXIT_CODES } from "./error-handler.js";
|
||||
import {
|
||||
isCliAutoOpenForced,
|
||||
type CliOAuthAutoOpenControl,
|
||||
} from "./cli-oauth-navigation.js";
|
||||
|
||||
/** Client surface needed for connect + mid-RPC OAuth recovery. */
|
||||
export type CliOAuthClient = RunnerInteractiveOAuthClient & {
|
||||
connect(): Promise<void>;
|
||||
disconnect(): Promise<void>;
|
||||
};
|
||||
|
||||
/** Default bound for a non-TTY step-up [y/N] that never sends a line. */
|
||||
export const STEP_UP_PIPE_TIMEOUT_MS = 5_000;
|
||||
|
||||
export type CliOAuthConnectOptions = {
|
||||
/**
|
||||
* When true, never open interactive OAuth / step-up prompts. Use the shared
|
||||
* store if it can satisfy the challenge; otherwise fail with AUTH_REQUIRED.
|
||||
*/
|
||||
storedAuthOnly?: boolean;
|
||||
/**
|
||||
* Arms browser auto-open only around the CLI-owned interactive OAuth flow
|
||||
* (callback server listening). See {@link createCliOAuthNavigation}.
|
||||
*/
|
||||
autoOpenControl?: CliOAuthAutoOpenControl;
|
||||
/**
|
||||
* Override “human present” detection for interactive-OAuth admit gating
|
||||
* (tests and programmatic callers). When omitted, production uses
|
||||
* `stdin.isTTY || stderr.isTTY` so `2>&1 | tee` still works (stdin remains a
|
||||
* TTY). Does **not** gate the step-up [y/N] confirmer — that accepts piped
|
||||
* answers and bounds silent pipes via {@link stepUpPromptTimeoutMs}.
|
||||
*/
|
||||
isTTY?: boolean;
|
||||
/**
|
||||
* Override the step-up [y/N] confirmer (tests and programmatic callers).
|
||||
* When omitted, prompts on stderr and reads from stdin.
|
||||
*/
|
||||
confirmStepUp?: () => Promise<boolean>;
|
||||
/**
|
||||
* Bound (ms) for the default step-up confirmer when stdin is not a TTY, so a
|
||||
* pipe that never writes cannot hang forever. Ignored on a TTY stdin (humans
|
||||
* may pause). When set explicitly (including in tests), always applied.
|
||||
* Default: {@link STEP_UP_PIPE_TIMEOUT_MS}.
|
||||
*/
|
||||
stepUpPromptTimeoutMs?: number;
|
||||
};
|
||||
|
||||
function authRequiredFailure(message: string): never {
|
||||
throw new CliExitCodeError(EXIT_CODES.AUTH_REQUIRED, message, {
|
||||
code: "auth_required",
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Interactive OAuth waits up to 15 minutes on the loopback callback. Admit the
|
||||
* flow when a human is present (`stdin` or `stderr` is a TTY — so `2>&1 | tee`
|
||||
* still works), or when `MCP_AUTO_OPEN_ENABLED=true` (explicit non-TTY /
|
||||
* automation opt-in, which also force-opens a browser).
|
||||
*
|
||||
* Browser auto-open stays stderr-only — see {@link createCliOAuthNavigation}.
|
||||
* Step-up [y/N] accepts piped stdin (`echo y | …`); EOF and silent pipes are
|
||||
* bounded in the default confirmer (close race + {@link STEP_UP_PIPE_TIMEOUT_MS}).
|
||||
*/
|
||||
export function assertInteractiveOAuthAllowed(
|
||||
options?: Pick<CliOAuthConnectOptions, "isTTY">,
|
||||
): void {
|
||||
const humanPresent =
|
||||
options?.isTTY !== undefined
|
||||
? options.isTTY
|
||||
: process.stdin.isTTY === true || process.stderr.isTTY === true;
|
||||
if (humanPresent || isCliAutoOpenForced()) return;
|
||||
authRequiredFailure(
|
||||
"Interactive OAuth requires a TTY on stdin or stderr (or MCP_AUTO_OPEN_ENABLED=true). For CI/non-interactive runs use --stored-auth-only.",
|
||||
);
|
||||
}
|
||||
|
||||
/** Standard-OAuth step-up (not EMA silent re-mint). */
|
||||
export function isStandardOAuthStepUp(
|
||||
|
|
@ -28,10 +104,70 @@ export function isStandardOAuthStepUp(
|
|||
});
|
||||
}
|
||||
|
||||
async function confirmStepUpFromStdin(): Promise<boolean> {
|
||||
/**
|
||||
* Read [y/N] from stdin. Piped answers work when newline-terminated or when
|
||||
* stdin closes (`echo y | …`, `printf y | …`). On EOF with no line
|
||||
* (`< /dev/null`), decline. A non-TTY stdin that never sends a line within
|
||||
* {@link STEP_UP_PIPE_TIMEOUT_MS} fails with `auth_required` (timed out —
|
||||
* distinct from an explicit **N**).
|
||||
*
|
||||
* Partial last lines without a trailing newline are captured via the `line`
|
||||
* event — on stream end readline emits the buffer then `close` without
|
||||
* settling `question()`, so close alone would otherwise silently decline.
|
||||
* An open pipe that writes `y` without `\n` or EOF never flushes that buffer.
|
||||
*/
|
||||
async function confirmStepUpFromStdin(timeoutMs?: number): Promise<boolean> {
|
||||
const rl = createInterface({ input: process.stdin, output: process.stderr });
|
||||
const applyTimeoutMs =
|
||||
timeoutMs !== undefined
|
||||
? timeoutMs
|
||||
: process.stdin.isTTY === true
|
||||
? undefined
|
||||
: STEP_UP_PIPE_TIMEOUT_MS;
|
||||
try {
|
||||
const answer = await rl.question("");
|
||||
const answer = await new Promise<string>((resolve, reject) => {
|
||||
let settled = false;
|
||||
let lastLine: string | undefined;
|
||||
let timer: ReturnType<typeof setTimeout> | undefined;
|
||||
function cleanup(): void {
|
||||
if (timer !== undefined) clearTimeout(timer);
|
||||
rl.removeListener("close", onClose);
|
||||
rl.removeListener("line", onLine);
|
||||
}
|
||||
function finish(value: string): void {
|
||||
if (settled) return;
|
||||
settled = true;
|
||||
cleanup();
|
||||
resolve(value);
|
||||
}
|
||||
function finishTimeout(): void {
|
||||
if (settled) return;
|
||||
settled = true;
|
||||
cleanup();
|
||||
reject(
|
||||
new CliExitCodeError(
|
||||
EXIT_CODES.AUTH_REQUIRED,
|
||||
`Step-up authorization timed out (no answer on stdin within ${String(applyTimeoutMs)}ms). For CI/non-interactive runs use --stored-auth-only.`,
|
||||
{ code: "auth_required" },
|
||||
),
|
||||
);
|
||||
}
|
||||
function onLine(line: string): void {
|
||||
lastLine = line;
|
||||
}
|
||||
function onClose(): void {
|
||||
finish(lastLine ?? "n");
|
||||
}
|
||||
rl.on("line", onLine);
|
||||
rl.once("close", onClose);
|
||||
if (applyTimeoutMs !== undefined) {
|
||||
timer = setTimeout(finishTimeout, applyTimeoutMs);
|
||||
}
|
||||
void rl.question("").then(
|
||||
(line) => finish(line),
|
||||
() => finish(lastLine ?? "n"),
|
||||
);
|
||||
});
|
||||
const normalized = answer.trim().toLowerCase();
|
||||
return normalized === "y" || normalized === "yes";
|
||||
} finally {
|
||||
|
|
@ -48,23 +184,39 @@ async function promptStepUpConfirm(
|
|||
return confirmStepUp();
|
||||
}
|
||||
|
||||
async function withArmedAutoOpen<T>(
|
||||
control: CliOAuthAutoOpenControl | undefined,
|
||||
fn: () => Promise<T>,
|
||||
): Promise<T> {
|
||||
if (!control) return fn();
|
||||
control.armed = true;
|
||||
try {
|
||||
return await fn();
|
||||
} finally {
|
||||
control.armed = false;
|
||||
}
|
||||
}
|
||||
|
||||
export async function runCliInteractiveOAuth(
|
||||
client: InspectorClient,
|
||||
client: RunnerInteractiveOAuthClient,
|
||||
redirectUrlProvider: MutableRedirectUrlProvider,
|
||||
callbackUrlConfig: RunnerOAuthCallbackConfig,
|
||||
options?: {
|
||||
authorizationUrl?: URL;
|
||||
authChallenge?: AuthChallenge;
|
||||
autoOpenControl?: CliOAuthAutoOpenControl;
|
||||
},
|
||||
): Promise<void> {
|
||||
const result = await runRunnerInteractiveOAuth({
|
||||
client,
|
||||
redirectUrlProvider,
|
||||
callbackListen: callbackUrlConfig,
|
||||
createCallbackServer: createOAuthCallbackServer,
|
||||
authorizationUrl: options?.authorizationUrl,
|
||||
authChallenge: options?.authChallenge,
|
||||
});
|
||||
const result = await withArmedAutoOpen(options?.autoOpenControl, () =>
|
||||
runRunnerInteractiveOAuth({
|
||||
client,
|
||||
redirectUrlProvider,
|
||||
callbackListen: callbackUrlConfig,
|
||||
createCallbackServer: createOAuthCallbackServer,
|
||||
authorizationUrl: options?.authorizationUrl,
|
||||
authChallenge: options?.authChallenge,
|
||||
}),
|
||||
);
|
||||
|
||||
if (result.kind === "insufficient_scope") {
|
||||
throw new Error(stepUpInsufficientScopeMessage(result.challenge));
|
||||
|
|
@ -75,17 +227,21 @@ export async function runCliInteractiveOAuth(
|
|||
}
|
||||
|
||||
export async function handleCliAuthRecoveryRequired(
|
||||
client: InspectorClient,
|
||||
client: RunnerInteractiveOAuthClient,
|
||||
error: AuthRecoveryRequiredError,
|
||||
redirectUrlProvider: MutableRedirectUrlProvider,
|
||||
callbackUrlConfig: RunnerOAuthCallbackConfig,
|
||||
serverSettings?: InspectorServerSettings,
|
||||
confirmStepUp: () => Promise<boolean> = confirmStepUpFromStdin,
|
||||
options?: CliOAuthConnectOptions,
|
||||
): Promise<void> {
|
||||
const confirmStepUp =
|
||||
options?.confirmStepUp ??
|
||||
(() => confirmStepUpFromStdin(options?.stepUpPromptTimeoutMs));
|
||||
if (isStandardOAuthStepUp(error.authChallenge, serverSettings)) {
|
||||
if (await client.checkAuthChallengeSatisfied(error.authChallenge)) {
|
||||
return;
|
||||
}
|
||||
assertInteractiveOAuthAllowed(options);
|
||||
const proceed = await promptStepUpConfirm(
|
||||
error.authChallenge,
|
||||
confirmStepUp,
|
||||
|
|
@ -95,10 +251,13 @@ export async function handleCliAuthRecoveryRequired(
|
|||
}
|
||||
} else if (await client.checkAuthChallengeSatisfied(error.authChallenge)) {
|
||||
return;
|
||||
} else {
|
||||
assertInteractiveOAuthAllowed(options);
|
||||
}
|
||||
|
||||
await runCliInteractiveOAuth(client, redirectUrlProvider, callbackUrlConfig, {
|
||||
authorizationUrl: error.authorizationUrl,
|
||||
autoOpenControl: options?.autoOpenControl,
|
||||
...(error.authChallenge.reason === "insufficient_scope" && {
|
||||
authChallenge: error.authChallenge,
|
||||
}),
|
||||
|
|
@ -106,11 +265,12 @@ export async function handleCliAuthRecoveryRequired(
|
|||
}
|
||||
|
||||
export async function connectInspectorWithOAuth(
|
||||
inspectorClient: InspectorClient,
|
||||
inspectorClient: CliOAuthClient,
|
||||
serverConfig: MCPServerConfig,
|
||||
redirectUrlProvider: MutableRedirectUrlProvider,
|
||||
callbackUrlConfig: RunnerOAuthCallbackConfig,
|
||||
serverSettings?: InspectorServerSettings,
|
||||
options?: CliOAuthConnectOptions,
|
||||
): Promise<void> {
|
||||
try {
|
||||
await inspectorClient.connect();
|
||||
|
|
@ -120,11 +280,19 @@ export async function connectInspectorWithOAuth(
|
|||
}
|
||||
|
||||
if (err instanceof AuthRecoveryRequiredError) {
|
||||
if (
|
||||
await inspectorClient.checkAuthChallengeSatisfied(err.authChallenge)
|
||||
) {
|
||||
await inspectorClient.connect();
|
||||
return;
|
||||
// Under --stored-auth-only, give the store one chance then bail — avoid
|
||||
// calling handle (which would re-check) on the failure path.
|
||||
if (options?.storedAuthOnly) {
|
||||
if (
|
||||
await inspectorClient.checkAuthChallengeSatisfied(err.authChallenge)
|
||||
) {
|
||||
await inspectorClient.connect();
|
||||
return;
|
||||
}
|
||||
authRequiredFailure(
|
||||
err.message ||
|
||||
"Authentication required and --stored-auth-only is set; refusing interactive OAuth.",
|
||||
);
|
||||
}
|
||||
await handleCliAuthRecoveryRequired(
|
||||
inspectorClient,
|
||||
|
|
@ -132,17 +300,30 @@ export async function connectInspectorWithOAuth(
|
|||
redirectUrlProvider,
|
||||
callbackUrlConfig,
|
||||
serverSettings,
|
||||
options,
|
||||
);
|
||||
// Belt-and-braces: this branch never disconnects today, so connect() is
|
||||
// usually a no-op (already connected). Fresh tokens are picked up from
|
||||
// storage per request; keep the call if handle later gains a disconnect.
|
||||
await inspectorClient.connect();
|
||||
return;
|
||||
}
|
||||
|
||||
if (isUnauthorizedError(err)) {
|
||||
if (options?.storedAuthOnly) {
|
||||
authRequiredFailure(
|
||||
err instanceof Error
|
||||
? err.message
|
||||
: "Authentication required and --stored-auth-only is set; refusing interactive OAuth.",
|
||||
);
|
||||
}
|
||||
assertInteractiveOAuthAllowed(options);
|
||||
await inspectorClient.disconnect().catch(() => {});
|
||||
await runCliInteractiveOAuth(
|
||||
inspectorClient,
|
||||
redirectUrlProvider,
|
||||
callbackUrlConfig,
|
||||
{ autoOpenControl: options?.autoOpenControl },
|
||||
);
|
||||
await inspectorClient.connect();
|
||||
return;
|
||||
|
|
@ -153,32 +334,80 @@ export async function connectInspectorWithOAuth(
|
|||
}
|
||||
|
||||
/**
|
||||
* Run `fn` once; on {@link AuthRecoveryRequiredError}, complete interactive OAuth
|
||||
* and retry `fn` a single time (no further recovery attempts).
|
||||
* Run `fn` once; on auth recovery errors, complete interactive OAuth,
|
||||
* reconnect, and retry `fn` a single time. Mirrors
|
||||
* {@link connectInspectorWithOAuth}: handles both
|
||||
* {@link AuthRecoveryRequiredError} and plain unauthorized errors, and skips
|
||||
* OAuth machinery for non-OAuth-capable server configs.
|
||||
*/
|
||||
export async function withCliAuthRecoveryRetry<T>(
|
||||
inspectorClient: InspectorClient,
|
||||
inspectorClient: CliOAuthClient,
|
||||
serverConfig: MCPServerConfig,
|
||||
redirectUrlProvider: MutableRedirectUrlProvider,
|
||||
callbackUrlConfig: RunnerOAuthCallbackConfig,
|
||||
serverSettings: InspectorServerSettings | undefined,
|
||||
fn: () => Promise<T>,
|
||||
confirmStepUp: () => Promise<boolean> = confirmStepUpFromStdin,
|
||||
options?: CliOAuthConnectOptions,
|
||||
): Promise<T> {
|
||||
try {
|
||||
return await fn();
|
||||
} catch (err) {
|
||||
if (!(err instanceof AuthRecoveryRequiredError)) {
|
||||
if (!isOAuthCapableServerConfig(serverConfig)) {
|
||||
throw err;
|
||||
}
|
||||
await handleCliAuthRecoveryRequired(
|
||||
inspectorClient,
|
||||
err,
|
||||
redirectUrlProvider,
|
||||
callbackUrlConfig,
|
||||
serverSettings,
|
||||
confirmStepUp,
|
||||
);
|
||||
process.stderr.write("Authorization complete. Retrying…\n");
|
||||
return await fn();
|
||||
|
||||
if (err instanceof AuthRecoveryRequiredError) {
|
||||
// Satisfied-check lives in handleCliAuthRecoveryRequired for the
|
||||
// interactive path; under --stored-auth-only check once here then bail.
|
||||
if (options?.storedAuthOnly) {
|
||||
if (
|
||||
await inspectorClient.checkAuthChallengeSatisfied(err.authChallenge)
|
||||
) {
|
||||
return await fn();
|
||||
}
|
||||
authRequiredFailure(
|
||||
err.message ||
|
||||
"Authentication required and --stored-auth-only is set; refusing interactive OAuth.",
|
||||
);
|
||||
}
|
||||
await handleCliAuthRecoveryRequired(
|
||||
inspectorClient,
|
||||
err,
|
||||
redirectUrlProvider,
|
||||
callbackUrlConfig,
|
||||
serverSettings,
|
||||
options,
|
||||
);
|
||||
// Belt-and-braces: this branch never disconnects today, so connect() is
|
||||
// usually a no-op (already connected). See connectInspectorWithOAuth.
|
||||
await inspectorClient.connect();
|
||||
process.stderr.write("Authorization complete. Retrying…\n");
|
||||
return await fn();
|
||||
}
|
||||
|
||||
if (isUnauthorizedError(err)) {
|
||||
if (options?.storedAuthOnly) {
|
||||
authRequiredFailure(
|
||||
err instanceof Error
|
||||
? err.message
|
||||
: "Authentication required and --stored-auth-only is set; refusing interactive OAuth.",
|
||||
);
|
||||
}
|
||||
assertInteractiveOAuthAllowed(options);
|
||||
await inspectorClient.disconnect().catch(() => {});
|
||||
await runCliInteractiveOAuth(
|
||||
inspectorClient,
|
||||
redirectUrlProvider,
|
||||
callbackUrlConfig,
|
||||
{ autoOpenControl: options?.autoOpenControl },
|
||||
);
|
||||
// Load-bearing: disconnect() above closed the session.
|
||||
// connect() is a no-op when already connected.
|
||||
await inspectorClient.connect();
|
||||
process.stderr.write("Authorization complete. Retrying…\n");
|
||||
return await fn();
|
||||
}
|
||||
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue