* 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>
128 lines
4.6 KiB
TypeScript
128 lines
4.6 KiB
TypeScript
import { CallbackNavigation } from "@inspector/core/auth/index.js";
|
|
import { openUrl } from "./open-url.js";
|
|
import { createStyle, resolveAnsiEnabled } from "./style.js";
|
|
|
|
/**
|
|
* Arms URL print + browser auto-open only for the CLI-owned interactive OAuth
|
|
* flow (the one with a listening callback server). Left disarmed during
|
|
* `inspectorClient.connect()` so an SDK-internal `auth()` cannot print a
|
|
* doomed authorize URL (or open a second browser tab) before CLI gates run.
|
|
*/
|
|
export type CliOAuthAutoOpenControl = {
|
|
armed: boolean;
|
|
};
|
|
|
|
export type CliOAuthNavigationOptions = {
|
|
/**
|
|
* Override stderr TTY detection (tests / programmatic callers). Defaults to
|
|
* `process.stderr.isTTY`. Browser auto-open and OSC 8 stay stderr-only on
|
|
* purpose — `2>&1 | tee` still prints a plain URL into the tee stream;
|
|
* admit-to-flow gating (`stdin || stderr`) lives in
|
|
* `assertInteractiveOAuthAllowed`, not here.
|
|
*/
|
|
isTTY?: boolean;
|
|
/** Override NO_COLOR (tests). */
|
|
noColorEnv?: string | undefined;
|
|
/** Write the prompt line (defaults to stderr). */
|
|
write?: (line: string) => void;
|
|
/** Open the browser (defaults to {@link openUrl}). */
|
|
openBrowser?: (url: string) => Promise<void>;
|
|
/**
|
|
* When set, print + browser open are allowed only while
|
|
* {@link CliOAuthAutoOpenControl.armed} is true. When omitted, navigation is
|
|
* a no-op (SDK-during-connect default).
|
|
*/
|
|
autoOpenControl?: CliOAuthAutoOpenControl;
|
|
/**
|
|
* Suppress print + browser open entirely (e.g. `--stored-auth-only`). A
|
|
* disarmed/disabled navigation is never actionable — no callback server is
|
|
* listening for that authorize URL.
|
|
*/
|
|
disableAutoOpen?: boolean;
|
|
/**
|
|
* Override {@link resolveCliAutoOpenEnabled} (tests). When omitted, uses
|
|
* `MCP_AUTO_OPEN_ENABLED` / `VITEST` the same way the web server does.
|
|
*/
|
|
autoOpenEnabled?: boolean;
|
|
/**
|
|
* When true, open even if stderr is not a TTY. Independent of
|
|
* {@link autoOpenEnabled}. Defaults to {@link isCliAutoOpenForced}
|
|
* (`MCP_AUTO_OPEN_ENABLED=true`).
|
|
*/
|
|
forceAutoOpen?: boolean;
|
|
};
|
|
|
|
/**
|
|
* Mirror of web `resolveAutoOpen` (`clients/web/server/web-server-config.ts`):
|
|
* - `MCP_AUTO_OPEN_ENABLED=true` → always open
|
|
* - `MCP_AUTO_OPEN_ENABLED=false` → never open
|
|
* - otherwise → open unless `VITEST` is set
|
|
*/
|
|
export function resolveCliAutoOpenEnabled(
|
|
env: NodeJS.ProcessEnv = process.env,
|
|
): boolean {
|
|
const flag = env.MCP_AUTO_OPEN_ENABLED;
|
|
if (flag === "true") return true;
|
|
if (flag === "false") return false;
|
|
return !env.VITEST;
|
|
}
|
|
|
|
/** True when `MCP_AUTO_OPEN_ENABLED=true` (explicit force, including non-TTY). */
|
|
export function isCliAutoOpenForced(
|
|
env: NodeJS.ProcessEnv = process.env,
|
|
): boolean {
|
|
return env.MCP_AUTO_OPEN_ENABLED === "true";
|
|
}
|
|
|
|
/**
|
|
* CLI OAuth navigation: print the authorization URL (OSC 8 when TTY allows
|
|
* ANSI) and optionally open the browser — but only when armed for the
|
|
* CLI-owned interactive flow. Disarmed / `--stored-auth-only` navigations are
|
|
* silent no-ops so SDK-internal `auth()` during connect cannot emit an
|
|
* uncompletable "Please navigate to:" line.
|
|
*
|
|
* Browser open: off when env disables it; on a **stderr** TTY when enabled;
|
|
* forced open (TTY optional) when `MCP_AUTO_OPEN_ENABLED=true` /
|
|
* {@link forceAutoOpen}. Stderr-only (not `stdin || stderr`): piping stderr
|
|
* still gets a clickable plain URL; launching a browser from a redirected
|
|
* stderr session would be surprising.
|
|
*/
|
|
export function createCliOAuthNavigation(
|
|
options: CliOAuthNavigationOptions = {},
|
|
): CallbackNavigation {
|
|
return new CallbackNavigation(async (url) => {
|
|
const armed = options.autoOpenControl?.armed === true;
|
|
if (options.disableAutoOpen || !armed) return;
|
|
|
|
const href = url.href;
|
|
// stderr-only — do not widen to stdin; see file-level note above.
|
|
const tty =
|
|
options.isTTY !== undefined
|
|
? options.isTTY
|
|
: process.stderr.isTTY === true;
|
|
const style = createStyle(
|
|
resolveAnsiEnabled({
|
|
isTTY: tty,
|
|
noColorEnv: options.noColorEnv,
|
|
}),
|
|
);
|
|
const write =
|
|
options.write ?? ((line: string) => process.stderr.write(line));
|
|
write(`Please navigate to: ${style.link(href)}\n`);
|
|
|
|
const envAllows =
|
|
options.autoOpenEnabled !== undefined
|
|
? options.autoOpenEnabled
|
|
: resolveCliAutoOpenEnabled();
|
|
if (!envAllows) return;
|
|
|
|
const forced = options.forceAutoOpen ?? isCliAutoOpenForced();
|
|
if (!forced && !tty) return;
|
|
|
|
try {
|
|
await (options.openBrowser ?? openUrl)(href);
|
|
} catch {
|
|
// URL already printed; browser open is best-effort.
|
|
}
|
|
});
|
|
}
|