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:
Bob Dickinson 2026-07-26 13:55:25 -07:00 • committed by GitHub
commit 33e88eac9f
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
14 changed files with 4041 additions and 520 deletions

View file

@ -0,0 +1,39 @@
import {
NodeOAuthStorage,
resetNodeOAuthStorageCache,
} from "@inspector/core/auth/node/storage-node.js";
/** Same canonicalisation as CLI `normalizeServerUrl` (avoid cycles). */
function normalizeServerUrl(serverUrl: string): string {
try {
return new URL(serverUrl).href;
} catch {
return serverUrl;
}
}
/**
* Delete stored OAuth state for an HTTP(S) server URL from the shared store
* (`--relogin`) so the next connect cannot silently reuse tokens. This removes
* the store entry (not a per-run ignore). No-op when `serverUrl` is missing.
*
* Clears **both** the raw URL and the `new URL().href`-normalised form. Runtime
* OAuth storage is keyed by the transport's raw `url` string, while some writers
* (and earlier clear paths) use the normalised key — mirroring
* `findStoredServerState` in `cli.ts`, which already tries both on read.
*/
export async function clearStoredAuthForRelogin(
serverUrl: string | undefined,
): Promise<void> {
if (!serverUrl?.trim()) return;
const raw = serverUrl.trim();
const normalized = normalizeServerUrl(raw);
const storage = new NodeOAuthStorage();
await storage.clear(raw);
if (normalized !== raw) {
await storage.clear(normalized);
}
// Drop the in-process singleton so the next connect cannot reuse a cleared
// entry from the NodeOAuthStorage cache.
resetNodeOAuthStorageCache();
}

View file

@ -0,0 +1,128 @@
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.
}
});
}

View file

@ -6,18 +6,15 @@ import type {
MCPServerConfig,
InspectorClientEnvironment,
} from "@inspector/core/mcp/types.js";
import { eraToVersionNegotiation } from "@inspector/core/mcp/types.js";
import {
DEFAULT_MAX_FETCH_REQUESTS,
DEFAULT_TASK_TTL_MS,
eraToVersionNegotiation,
} from "@inspector/core/mcp/types.js";
DEFAULT_CONNECT_TIMEOUT_MS,
withConnectTimeout,
} from "./handlers/connect-timeout.js";
import { listServerEntries, showServerEntry } from "./handlers/servers-list.js";
import { writeFormattedResult } from "./handlers/format-output.js";
import { clearStoredAuthForRelogin } from "./clear-stored-auth-for-relogin.js";
import { InspectorClient } from "@inspector/core/mcp/index.js";
import {
ManagedToolsState,
ManagedResourcesState,
ManagedResourceTemplatesState,
ManagedPromptsState,
} from "@inspector/core/mcp/state/index.js";
import {
createTransportNode,
loadServerEntries,
@ -26,9 +23,18 @@ import {
parseHeaderPair,
} from "@inspector/core/mcp/node/index.js";
import type { JsonValue } from "@inspector/core/mcp/index.js";
import { extractAppInfo } from "@inspector/core/mcp/apps.js";
import type { AppInfo } from "@inspector/core/mcp/apps.js";
import { getStateFilePath } from "@inspector/core/auth/node/storage-node.js";
import { consumeMethodOutcome } from "./handlers/consume-outcome.js";
import { runMethod } from "./handlers/run-method.js";
import {
isOneShotMethod,
metaValueToString,
ONE_SHOT_METHODS,
type MethodArgs,
} from "./handlers/method-types.js";
export type { CliAppInfo } from "./handlers/method-types.js";
export { emitResult } from "./handlers/emit-result.js";
export { collectAppInfo } from "./handlers/collect-app-info.js";
import {
parseOAuthPersistBlob,
serializeOAuthPersistBlob,
@ -46,11 +52,9 @@ import type {
OAuthTokens,
} from "@modelcontextprotocol/client";
import { CliExitCodeError, EXIT_CODES } from "./error-handler.js";
import {
ConsoleNavigation,
MutableRedirectUrlProvider,
} from "@inspector/core/auth/index.js";
import { MutableRedirectUrlProvider } from "@inspector/core/auth/index.js";
import { NodeOAuthStorage } from "@inspector/core/auth/node/index.js";
import { createCliOAuthNavigation } from "./cli-oauth-navigation.js";
import {
connectInspectorWithOAuth,
withCliAuthRecoveryRetry,
@ -79,61 +83,10 @@ export const validLogLevels: LoggingLevel[] = Object.values(
/** Client identity name the CLI reports to servers. */
const CLI_CLIENT_NAME = "inspector-cli";
/**
* Default connect timeout (ms) for ad-hoc server invocations. Without this an
* unreachable server (e.g. a partner edge that drops the SYN) hangs the CLI
* indefinitely; the value is generous enough for cold-start OAuth discovery
* round-trips while still failing fast on a black-holed host.
*/
export const DEFAULT_CONNECT_TIMEOUT_MS = 15000;
export { DEFAULT_CONNECT_TIMEOUT_MS, withConnectTimeout };
type OutputFormat = "text" | "json";
type MethodArgs = {
method?: string;
promptName?: string;
promptArgs?: Record<string, JsonValue>;
uri?: string;
logLevel?: LoggingLevel;
toolName?: string;
toolArg?: Record<string, JsonValue>;
toolMeta?: Record<string, string>;
metadata?: Record<string, string>;
appInfo?: boolean;
format?: OutputFormat;
};
/**
* {@link AppInfo} plus a CLI-only `resourceError` so a `resources/read` failure
* during the probe is reported instead of being silently swallowed (which would
* make "no CSP declared" indistinguishable from "resource unreadable").
*/
export type CliAppInfo = AppInfo & { resourceError?: string };
/**
* Discriminated outcome from {@link callMethod}'s per-method runner. Most
* methods return a `result` (with optional collected `appInfo`) for
* {@link emitResult} to format; the `tools/list --app-info` NDJSON path writes
* its lines itself and reports `emitted` so the caller skips a second write.
*/
type MethodOutcome =
| { kind: "result"; result: McpResponse; appInfo?: CliAppInfo }
| { kind: "emitted" };
/**
* Tear down a managed list state if it was created for this method call.
*
* The `managed*State` locals below are assigned inside the `runMethod` closure,
* so TypeScript's control-flow analysis keeps them narrowed to `null` at the
* outer `finally` block (a closure "might not have run"). Routing the teardown
* through this helper — whose parameter type is the shared `destroy()` shape —
* both documents that and calls `destroy()` on whatever the closure actually
* assigned at runtime, with no `as` cast.
*/
function destroyManagedState(state: { destroy(): void } | null): void {
state?.destroy();
}
async function callMethod(
serverConfig: MCPServerConfig,
serverSettings: InspectorServerSettings | undefined,
@ -141,7 +94,20 @@ async function callMethod(
clientConfig: ClientConfig,
cliAuthOverrides: RunnerClientConfigOverrides,
callbackUrlConfig: RunnerOAuthCallbackConfig,
storedAuthOnly: boolean,
relogin: boolean,
): Promise<void> {
// Clear after parse-time validation so a bad flag combo never deletes store
// entries. Deletes the shared URL-keyed OAuth entry (not "ignore for this run").
if (relogin) {
if (!("url" in serverConfig && serverConfig.url)) {
throw new Error(
"--relogin requires an HTTP/SSE server URL (no OAuth store entry for stdio)",
);
}
await clearStoredAuthForRelogin(serverConfig.url);
}
// Version comes from the single source of truth — the root package.json —
// via the shared core reader, not the CLI's own manifest.
const clientIdentity = {
@ -153,12 +119,18 @@ async function callMethod(
transport: createTransportNode,
};
const redirectUrlProvider = new MutableRedirectUrlProvider();
// Disarmed until the CLI-owned interactive OAuth flow runs — SDK `auth()`
// during connect must not open a browser before `--stored-auth-only` / gates.
const autoOpenControl = { armed: false };
if (isOAuthCapableServerConfig(serverConfig)) {
redirectUrlProvider.redirectUrl =
formatRunnerOAuthRedirectUrl(callbackUrlConfig);
environment.oauth = {
storage: new NodeOAuthStorage(),
navigation: new ConsoleNavigation(),
navigation: createCliOAuthNavigation({
autoOpenControl,
disableAutoOpen: storedAuthOnly,
}),
redirectUrlProvider,
};
}
@ -185,182 +157,6 @@ async function callMethod(
...clientAuthOptions,
});
let managedToolsState: ManagedToolsState | null = null;
let managedResourcesState: ManagedResourcesState | null = null;
let managedResourceTemplatesState: ManagedResourceTemplatesState | null =
null;
let managedPromptsState: ManagedPromptsState | null = null;
const runMethod = async (): Promise<MethodOutcome> => {
let result: McpResponse;
let appInfo: CliAppInfo | undefined;
if (args.method === "tools/list" || args.method === "tools/call") {
managedToolsState = new ManagedToolsState(inspectorClient);
managedToolsState.setMetadata(args.metadata);
await managedToolsState.refresh();
}
if (args.method === "resources/list") {
managedResourcesState = new ManagedResourcesState(inspectorClient);
managedResourcesState.setMetadata(args.metadata);
await managedResourcesState.refresh();
} else if (args.method === "resources/templates/list") {
managedResourceTemplatesState = new ManagedResourceTemplatesState(
inspectorClient,
);
managedResourceTemplatesState.setMetadata(args.metadata);
await managedResourceTemplatesState.refresh();
} else if (args.method === "prompts/list") {
managedPromptsState = new ManagedPromptsState(inspectorClient);
managedPromptsState.setMetadata(args.metadata);
await managedPromptsState.refresh();
}
if (args.method === "tools/list") {
const tools = managedToolsState!.getTools();
if (args.appInfo) {
// NDJSON: one app-info line per tool, all on a single connection. A
// caller that wants only the App tools can `| jq -c 'select(.hasApp)'`.
// collectAppInfo never throws — a tool with a malformed `_meta.ui`
// surfaces as `{hasApp:false, resourceError}` — so one bad tool can't
// abort the whole listing. Emitted verbatim as NDJSON regardless of
// --format (the list-probe shape is fixed; --format json only reshapes
// the single-result paths).
for (const tool of tools) {
const info = await collectAppInfo(
inspectorClient,
tool,
args.metadata,
);
await awaitableLog(JSON.stringify(info) + "\n");
}
return { kind: "emitted" };
}
result = { tools };
} else if (args.method === "tools/call") {
if (!args.toolName) {
throw new Error(
"Tool name is required for tools/call method. Use --tool-name to specify the tool name.",
);
}
const tool = managedToolsState!
.getTools()
.find((t) => t.name === args.toolName);
if (!tool) {
// Distinct from `isError:true` and (for --app-info) from "tool has no
// app": the named tool does not exist on the server. Exit TOOL_ERROR
// with `code: "tool_not_found"` so a caller can tell a typo/rename
// apart from a real tool failure or a no-app probe result.
throw new CliExitCodeError(
EXIT_CODES.TOOL_ERROR,
`Tool '${args.toolName}' not found on server.`,
{ code: "tool_not_found" },
);
}
// Only collect app-info when the caller asked for it (`--app-info` or
// `--format json`); a plain text-mode `tools/call` shouldn't fail just
// because the tool's `_meta.ui.resourceUri` is malformed or its resource
// is unreadable.
if (args.appInfo || args.format === "json") {
appInfo = await collectAppInfo(inspectorClient, tool, args.metadata);
}
if (args.appInfo) {
// --app-info: probe-only — emit the app metadata and skip the tool
// call entirely. The no-app exit code is handled in emitResult.
result = { ...appInfo };
} else {
const invocation = await inspectorClient.callTool(
tool,
args.toolArg || {},
args.metadata,
args.toolMeta,
);
if (invocation.result !== null) {
result = invocation.result;
} else {
/* v8 ignore next 9 -- unreachable: InspectorClient.callTool either
throws on a tool-execution/transport error (caught by the outer
try) or returns an invocation whose `result` is guaranteed
non-null (attemptToolCall throws if finalResult is falsy). This
else is defensive only. */
result = {
content: [
{
type: "text" as const,
text: invocation.error || "Tool call failed",
},
],
isError: true,
};
}
}
} else if (args.method === "resources/list") {
result = {
resources: managedResourcesState!.getResources(),
};
} else if (args.method === "resources/read") {
if (!args.uri) {
throw new Error(
"URI is required for resources/read method. Use --uri to specify the resource URI.",
);
}
const invocation = await inspectorClient.readResource(
args.uri,
args.metadata,
);
result = invocation.result;
} else if (args.method === "resources/templates/list") {
result = {
resourceTemplates:
managedResourceTemplatesState!.getResourceTemplates(),
};
} else if (args.method === "prompts/list") {
result = { prompts: managedPromptsState!.getPrompts() };
} else if (args.method === "prompts/get") {
if (!args.promptName) {
throw new Error(
"Prompt name is required for prompts/get method. Use --prompt-name to specify the prompt name.",
);
}
const invocation = await inspectorClient.getPrompt(
args.promptName,
args.promptArgs || {},
args.metadata,
);
result = invocation.result;
} else if (args.method === "initialize") {
// Connect-only probe: emit the cached InitializeResult fields so a
// caller can read serverInfo / protocolVersion / capabilities /
// instructions without picking a list method.
result = {
serverInfo: inspectorClient.getServerInfo(),
protocolVersion: inspectorClient.getProtocolVersion(),
capabilities: inspectorClient.getCapabilities(),
instructions: inspectorClient.getInstructions(),
};
} else if (args.method === "logging/setLevel") {
if (!args.logLevel) {
throw new Error(
"Log level is required for logging/setLevel method. Use --log-level to specify the log level.",
);
}
await inspectorClient.setLoggingLevel(args.logLevel);
result = {};
} else {
throw new Error(
`Unsupported method: ${args.method}. Supported methods include: initialize, tools/list, tools/call, resources/list, resources/read, resources/templates/list, prompts/list, prompts/get, logging/setLevel`,
);
}
return { kind: "result", result, appInfo };
};
try {
await connectInspectorWithOAuth(
inspectorClient,
@ -368,124 +164,25 @@ async function callMethod(
redirectUrlProvider,
callbackUrlConfig,
serverSettings,
{ storedAuthOnly, autoOpenControl },
);
const outcome = await withCliAuthRecoveryRetry(
inspectorClient,
serverConfig,
redirectUrlProvider,
callbackUrlConfig,
serverSettings,
runMethod,
() => runMethod(inspectorClient, args),
{ storedAuthOnly, autoOpenControl },
);
// The NDJSON `tools/list --app-info` path already wrote its lines; every
// other method hands its result to emitResult for format/exit handling.
if (outcome.kind === "result") {
await emitResult(outcome.result, outcome.appInfo, args);
}
await consumeMethodOutcome(outcome, args);
} finally {
destroyManagedState(managedToolsState);
destroyManagedState(managedResourcesState);
destroyManagedState(managedResourceTemplatesState);
destroyManagedState(managedPromptsState);
await inspectorClient.disconnect();
}
}
/**
* Write the method result (and any app-info) to stdout, honouring `--format`
* and `--app-info`, then map `isError`/no-app outcomes onto the exit-code map.
* Extracted from `callMethod` so the format/exit handling is in one place.
*/
export async function emitResult(
result: McpResponse,
appInfo: CliAppInfo | undefined,
args: MethodArgs,
): Promise<void> {
const json = args.format === "json";
if (args.appInfo) {
const info: CliAppInfo = appInfo ?? {
hasApp: false,
toolName: args.toolName ?? "",
};
// Single-line JSON either way; --format json wraps it under an `appInfo`
// key so the envelope shape is uniform with the non-probe path.
await awaitableLog(JSON.stringify(json ? { appInfo: info } : info) + "\n");
if (!info.hasApp) {
throw new CliExitCodeError(
EXIT_CODES.NO_APP,
`Tool '${args.toolName}' has no MCP App UI resource (_meta.ui.resourceUri).`,
);
}
return;
}
if (json) {
// One JSON object on stdout — `result` plus, when present, `appInfo` as a
// sibling key. No `--- MCP App Info ---` banner, so `| jq` works for App
// tools as well as plain ones.
const envelope: Record<string, unknown> = { result };
if (appInfo?.hasApp) envelope.appInfo = appInfo;
await awaitableLog(JSON.stringify(envelope) + "\n");
} else {
// Text mode emits the result only; app-info is not collected on this path
// (use `--format json` or `--app-info` to get it).
await awaitableLog(JSON.stringify(result, null, 2) + "\n");
}
// A tool that returned `isError:true` (or whose call failed) is still printed
// above so the caller sees the payload, but the process exits TOOL_ERROR so
// `&&` chains don't proceed on a failed call.
if ((result as { isError?: unknown }).isError === true) {
throw new CliExitCodeError(
EXIT_CODES.TOOL_ERROR,
`Tool '${args.toolName}' returned isError:true.`,
{ code: "tool_is_error" },
);
}
}
/**
* Build the CLI's app-info for a tool: extract the tool-side `_meta.ui` and,
* when the tool advertises a UI resource, follow it with a `resources/read` so
* the resource-side csp/permissions/domain are included.
*
* Never throws — the two failure modes both fold into a `{hasApp:false,
* resourceError}` result rather than propagating:
* - a malformed `_meta.ui.resourceUri` (extractAppInfo throws), so the
* `tools/list --app-info` NDJSON loop stays per-tool tolerant (one bad tool
* can't abort the whole listing);
* - a `resources/read` failure, since "tool says it has an app but the
* resource is unreadable" is itself a useful probe result.
*/
export async function collectAppInfo(
client: Pick<InspectorClient, "readResource">,
tool: Parameters<typeof extractAppInfo>[0],
metadata: Record<string, string> | undefined,
): Promise<CliAppInfo> {
let base: AppInfo;
try {
base = extractAppInfo(tool);
} catch (e) {
return {
hasApp: false,
toolName: tool.name,
resourceError: e instanceof Error ? e.message : String(e),
};
}
if (!base.hasApp || base.resourceUri === undefined) return base;
try {
const read = await client.readResource(base.resourceUri, metadata);
return extractAppInfo(tool, read.result);
} catch (e) {
return {
...base,
resourceError: e instanceof Error ? e.message : String(e),
};
}
}
/**
* Canonicalise a server URL the same way the web inspector does before storing
* OAuth state (`new URL().href` lowercases the host, normalises the scheme, and
@ -754,32 +451,6 @@ function buildHandoff(
};
}
/**
* Apply a connection timeout to a resolved server's settings, building a
* minimal {@link InspectorServerSettings} when none came from the file. Ad-hoc
* invocations get {@link DEFAULT_CONNECT_TIMEOUT_MS} so a black-holed host
* fails fast; catalog/config invocations keep their file-level timeout unless
* `--connect-timeout` is passed explicitly.
*/
export function withConnectTimeout(
settings: InspectorServerSettings | undefined,
connectionTimeout: number | undefined,
): InspectorServerSettings | undefined {
if (connectionTimeout === undefined) return settings;
if (settings) return { ...settings, connectionTimeout };
return {
headers: [],
metadata: [],
env: [],
connectionTimeout,
requestTimeout: 0,
taskTtl: DEFAULT_TASK_TTL_MS,
maxFetchRequests: DEFAULT_MAX_FETCH_REQUESTS,
autoRefreshOnListChanged: false,
roots: [],
};
}
function parseKeyValuePair(
value: string,
previous: Record<string, JsonValue> = {},
@ -815,6 +486,8 @@ type ParseResult =
clientSecret?: string;
clientMetadataUrl?: string;
callbackUrl?: string;
storedAuthOnly?: boolean;
relogin?: boolean;
}
// Short-circuit modes (`--list-stored-auth`, `--print-handoff`) do their own
// output and need no server connection; runCli returns immediately.
@ -998,6 +671,14 @@ async function parseArgs(argv?: string[]): Promise<ParseResult> {
"--use-stored-auth",
"Read the OAuth access token for --server-url from the OAuth state file (written by the web inspector) and inject it as Authorization: Bearer.",
)
.option(
"--stored-auth-only",
"Never start interactive OAuth; use the shared store if present, otherwise fail with auth_required. Preferred for CI/non-interactive runs. No-op when the server does not require auth.",
)
.option(
"--relogin",
"Delete stored OAuth for this server URL from the shared store before connect (HTTP/SSE URL keys only); interactive login runs only if the server requires auth. Rejected for stdio (no URL-keyed store entry)",
)
.option(
"--wait-for-auth <sec>",
"Poll the OAuth state file until a token for --server-url appears (or the timeout elapses), then proceed as if --use-stored-auth were set. Use after handing off to a human to complete OAuth in a browser.",
@ -1050,11 +731,37 @@ async function parseArgs(argv?: string[]): Promise<ParseResult> {
clientMetadataUrl?: string;
callbackUrl?: string;
useStoredAuth?: boolean;
storedAuthOnly?: boolean;
relogin?: boolean;
waitForAuth?: number;
listStoredAuth?: boolean;
printHandoff?: boolean;
};
if (options.relogin) {
if (options.storedAuthOnly) {
throw new Error("--relogin cannot be combined with --stored-auth-only");
}
if (options.useStoredAuth || options.waitForAuth !== undefined) {
throw new Error(
"--relogin cannot be combined with --use-stored-auth or --wait-for-auth",
);
}
if (options.listStoredAuth || options.printHandoff) {
throw new Error(
"--relogin cannot be combined with --list-stored-auth or --print-handoff",
);
}
if (
options.method === "servers/list" ||
options.method === "servers/show"
) {
throw new Error(
"--relogin cannot be combined with --method servers/list or servers/show (no OAuth connect)",
);
}
}
// State-path precedence (getStateFilePath): MCP_INSPECTOR_OAUTH_STATE_PATH →
// <MCP_STORAGE_DIR>/oauth.json → ~/.mcp-inspector/storage/oauth.json — the
// same file the web backend writes, so tokens are shared across surfaces.
@ -1083,6 +790,21 @@ async function parseArgs(argv?: string[]): Promise<ParseResult> {
return { shortCircuit: true };
}
// Validate --method before stored-auth network work (refresh / wait) so a
// typo or stream method fails locally without burning a token round-trip.
if (!options.method) {
throw new Error(
"Method is required. Use --method to specify the method to invoke.",
);
}
const isCatalogMethod =
options.method === "servers/list" || options.method === "servers/show";
if (!isCatalogMethod && !isOneShotMethod(options.method)) {
throw new Error(
`Unsupported method: ${options.method}. Supported --cli methods: ${ONE_SHOT_METHODS.join(", ")}, servers/list, servers/show.`,
);
}
// Honour MCP_CATALOG_PATH only when no ad-hoc target is given. Applying it
// unconditionally meant a homespace that exports the env var could never run
// `--server-url …` (serverSourceConflict rejects catalog + ad-hoc).
@ -1107,6 +829,30 @@ async function parseArgs(argv?: string[]): Promise<ParseResult> {
headers: options.header as Record<string, string> | undefined,
};
// Catalog list / show — no MCP connection. Run before stored-auth refresh so
// a catalog-only command never triggers a token round-trip it won't use.
if (options.method === "servers/list") {
const servers = await listServerEntries(serverOptions);
await writeFormattedResult(
{ servers },
options.format === "json" ? "json" : "text",
);
return { shortCircuit: true };
}
if (options.method === "servers/show") {
if (!options.server?.trim()) {
throw new Error(
"servers/show requires --server <name> to select a catalog entry.",
);
}
const server = await showServerEntry(options.server, serverOptions);
await writeFormattedResult(
server,
options.format === "json" ? "json" : "text",
);
return { shortCircuit: true };
}
if (options.waitForAuth !== undefined || options.useStoredAuth) {
if (!options.serverUrl) {
throw new Error(
@ -1186,12 +932,6 @@ async function parseArgs(argv?: string[]): Promise<ParseResult> {
options.connectTimeout ?? (adHoc ? DEFAULT_CONNECT_TIMEOUT_MS : undefined),
);
if (!options.method) {
throw new Error(
"Method is required. Use --method to specify the method to invoke.",
);
}
if (
options.appInfo &&
options.method !== "tools/call" &&
@ -1241,7 +981,7 @@ async function parseArgs(argv?: string[]): Promise<ParseResult> {
? Object.fromEntries(
Object.entries(options.metadata).map(([key, value]) => [
key,
String(value),
metaValueToString(value),
]),
)
: undefined,
@ -1249,7 +989,7 @@ async function parseArgs(argv?: string[]): Promise<ParseResult> {
? Object.fromEntries(
Object.entries(options.toolMetadata).map(([key, value]) => [
key,
String(value),
metaValueToString(value),
]),
)
: undefined,
@ -1266,6 +1006,8 @@ async function parseArgs(argv?: string[]): Promise<ParseResult> {
clientSecret: options.clientSecret,
clientMetadataUrl: options.clientMetadataUrl,
callbackUrl: options.callbackUrl,
storedAuthOnly: options.storedAuthOnly === true,
relogin: options.relogin === true,
};
}
@ -1282,6 +1024,8 @@ export async function runCli(argv?: string[]): Promise<void> {
clientSecret,
clientMetadataUrl,
callbackUrl,
storedAuthOnly,
relogin,
} = parsed;
const clientConfig = await loadRunnerClientConfig({ clientConfigPath });
const callbackUrlConfig = parseRunnerOAuthCallbackUrl(callbackUrl);
@ -1296,5 +1040,7 @@ export async function runCli(argv?: string[]): Promise<void> {
clientMetadataUrl,
},
callbackUrlConfig,
storedAuthOnly === true,
relogin === true,
);
}

View file

@ -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;
}
}

View file

@ -0,0 +1,35 @@
import { InspectorClient } from "@inspector/core/mcp/index.js";
import { extractAppInfo } from "@inspector/core/mcp/apps.js";
import type { AppInfo } from "@inspector/core/mcp/apps.js";
import type { CliAppInfo } from "./method-types.js";
/**
* Build the CLI's app-info for a tool. Never throws — failures fold into
* `{hasApp:false, resourceError}` so list probes stay per-tool tolerant.
*/
export async function collectAppInfo(
client: Pick<InspectorClient, "readResource">,
tool: Parameters<typeof extractAppInfo>[0],
metadata: Record<string, string> | undefined,
): Promise<CliAppInfo> {
let base: AppInfo;
try {
base = extractAppInfo(tool);
} catch (e) {
return {
hasApp: false,
toolName: tool.name,
resourceError: e instanceof Error ? e.message : String(e),
};
}
if (!base.hasApp || base.resourceUri === undefined) return base;
try {
const read = await client.readResource(base.resourceUri, metadata);
return extractAppInfo(tool, read.result);
} catch (e) {
return {
...base,
resourceError: e instanceof Error ? e.message : String(e),
};
}
}

View file

@ -0,0 +1,35 @@
import {
DEFAULT_MAX_FETCH_REQUESTS,
DEFAULT_TASK_TTL_MS,
type InspectorServerSettings,
} from "@inspector/core/mcp/types.js";
/**
* Default connect timeout (ms) for ad-hoc server invocations. Without this an
* unreachable server hangs the CLI indefinitely.
*/
export const DEFAULT_CONNECT_TIMEOUT_MS = 15000;
/**
* Apply a connection timeout to a resolved server's settings, building a
* minimal {@link InspectorServerSettings} when none came from the file.
*/
export function withConnectTimeout(
settings: InspectorServerSettings | undefined,
connectionTimeout: number | undefined,
): InspectorServerSettings | undefined {
if (connectionTimeout === undefined) return settings;
if (settings) return { ...settings, connectionTimeout };
return {
headers: [],
metadata: [],
env: [],
connectionTimeout,
requestTimeout: 0,
taskTtl: DEFAULT_TASK_TTL_MS,
maxFetchRequests: DEFAULT_MAX_FETCH_REQUESTS,
autoRefreshOnListChanged: false,
paginatedLists: false,
roots: [],
};
}

View file

@ -0,0 +1,40 @@
import { awaitableLog } from "../utils/awaitable-log.js";
import { emitResult } from "./emit-result.js";
import type { MethodArgs, MethodOutcome } from "./method-types.js";
/**
* Write a {@link MethodOutcome} to stdout (result / NDJSON / long-lived stream).
* Stream methods stay attached until SIGINT/SIGTERM.
*
* TODO(#1432): long-lived stream path does not yet handle EPIPE / stdout error
* (session CLI / `mcpi` follow-up).
*/
export async function consumeMethodOutcome(
outcome: MethodOutcome,
args: MethodArgs,
): Promise<void> {
if (outcome.kind === "result") {
await emitResult(outcome.result, outcome.appInfo, args);
return;
}
if (outcome.kind === "ndjson") {
for (const line of outcome.lines) {
await awaitableLog(JSON.stringify(line) + "\n");
}
return;
}
await new Promise<void>((resolve) => {
const stop = outcome.start((obj) => {
void awaitableLog(JSON.stringify(obj) + "\n");
});
const onSignal = () => {
stop();
process.off("SIGINT", onSignal);
process.off("SIGTERM", onSignal);
resolve();
};
process.on("SIGINT", onSignal);
process.on("SIGTERM", onSignal);
});
}

View file

@ -0,0 +1,46 @@
import { awaitableLog } from "../utils/awaitable-log.js";
import { CliExitCodeError, EXIT_CODES } from "../error-handler.js";
import type { CliAppInfo, McpResponse, MethodArgs } from "./method-types.js";
/**
* Write the method result (and any app-info) to stdout, honouring `--format`
* and `--app-info`, then map `isError`/no-app outcomes onto the exit-code map.
*/
export async function emitResult(
result: McpResponse,
appInfo: CliAppInfo | undefined,
args: MethodArgs,
): Promise<void> {
const json = args.format === "json";
if (args.appInfo) {
const info: CliAppInfo = appInfo ?? {
hasApp: false,
toolName: args.toolName ?? "",
};
await awaitableLog(JSON.stringify(json ? { appInfo: info } : info) + "\n");
if (!info.hasApp) {
throw new CliExitCodeError(
EXIT_CODES.NO_APP,
`Tool '${args.toolName}' has no MCP App UI resource (_meta.ui.resourceUri).`,
);
}
return;
}
if (json) {
const envelope: Record<string, unknown> = { result };
if (appInfo?.hasApp) envelope.appInfo = appInfo;
await awaitableLog(JSON.stringify(envelope) + "\n");
} else {
await awaitableLog(JSON.stringify(result, null, 2) + "\n");
}
if ((result as { isError?: unknown }).isError === true) {
throw new CliExitCodeError(
EXIT_CODES.TOOL_ERROR,
`Tool '${args.toolName}' returned isError:true.`,
{ code: "tool_is_error" },
);
}
}

View file

@ -0,0 +1,18 @@
import { awaitableLog } from "../utils/awaitable-log.js";
export type OutputFormat = "text" | "json";
/**
* Write a command result to stdout. Text = pretty-printed JSON; json = single
* `{ "result": … }` envelope (same family as CLI `--format json`).
*/
export async function writeFormattedResult(
result: unknown,
format: OutputFormat = "text",
): Promise<void> {
if (format === "json") {
await awaitableLog(JSON.stringify({ result }) + "\n");
return;
}
await awaitableLog(JSON.stringify(result, null, 2) + "\n");
}

View file

@ -0,0 +1,122 @@
import type { JsonValue } from "@inspector/core/mcp/index.js";
import type { AppInfo } from "@inspector/core/mcp/apps.js";
import type { LoggingLevel } from "@modelcontextprotocol/client";
import type { OutputFormat } from "./format-output.js";
export type { OutputFormat };
/**
* {@link AppInfo} plus a CLI-only `resourceError` so a `resources/read` failure
* during the probe is reported instead of being silently swallowed.
*/
export type CliAppInfo = AppInfo & { resourceError?: string };
/** Arguments for a single MCP method invocation via {@link runMethod}. */
export type MethodArgs = {
method?: string;
promptName?: string;
promptArgs?: Record<string, JsonValue>;
uri?: string;
logLevel?: LoggingLevel;
toolName?: string;
toolArg?: Record<string, JsonValue>;
toolMeta?: Record<string, string>;
metadata?: Record<string, string>;
appInfo?: boolean;
format?: OutputFormat;
/** Task id for tasks/get, tasks/cancel, tasks/result. */
taskId?: string;
/** When true, tools/call uses callToolStream (task-augmented). */
task?: boolean;
/** roots/set payload (JSON array of {uri, name?}). */
rootsJson?: string;
/** prompts/complete: argument name / value / ref. */
completeRefType?: "ref/prompt" | "ref/resource";
completeRef?: string;
completeArgName?: string;
completeArgValue?: string;
};
export type McpResponse = Record<string, unknown>;
/**
* Discriminated outcome from {@link runMethod}. Most methods return a `result`
* for formatting; `tools/list --app-info` returns `ndjson` lines for the
* caller to write. Stream methods return `stream` for a long-lived consumer.
*/
export type MethodOutcome =
| { kind: "result"; result: McpResponse; appInfo?: CliAppInfo }
/** One JSON object per line (e.g. tools/list --app-info). Caller writes stdout. */
| { kind: "ndjson"; lines: unknown[] }
| {
kind: "stream";
/** Human label for errors. */
label: string;
/** Subscribe and push NDJSON object lines; return an unsubscribe. */
start: (writeLine: (obj: unknown) => void) => () => void;
};
/**
* Full method set supported by {@link runMethod}.
*
* TODO(#1432): several of these (subscribe, tasks, roots, logging/tail, …) are
* not exposed by `mcp-inspector --cli` today; they exist for the experimental
* session CLI (`mcpi`) and other Node runners that share this dispatcher.
*/
export const SESSION_RPC_METHODS = [
"initialize",
"tools/list",
"tools/call",
"resources/list",
"resources/read",
"resources/templates/list",
"resources/subscribe",
"resources/unsubscribe",
"prompts/list",
"prompts/get",
"prompts/complete",
"logging/setLevel",
"logging/tail",
"tasks/list",
"tasks/get",
"tasks/cancel",
"tasks/result",
"roots/list",
"roots/set",
] as const;
export type SessionRpcMethod = (typeof SESSION_RPC_METHODS)[number];
/**
* Methods accepted by `mcp-inspector --cli` (plus catalog-only
* `servers/list` / `servers/show`, handled before connect). Stream methods and
* long-lived RPCs (`logging/tail`, `tasks/*`, …) are rejected so the CLI
* never hangs waiting for SIGINT.
*/
export const ONE_SHOT_METHODS = [
"initialize",
"tools/list",
"tools/call",
"resources/list",
"resources/read",
"resources/templates/list",
"prompts/list",
"prompts/get",
"logging/setLevel",
] as const;
export type OneShotMethod = (typeof ONE_SHOT_METHODS)[number];
export function isOneShotMethod(method: string): method is OneShotMethod {
return (ONE_SHOT_METHODS as readonly string[]).includes(method);
}
/**
* Encode a metadata / tool-metadata value for MethodArgs (string map).
* Strings pass through; objects/arrays use JSON.stringify so structure is kept
* (avoids String(obj) → "[object Object]").
*/
export function metaValueToString(value: JsonValue): string {
if (typeof value === "string") return value;
return JSON.stringify(value);
}

View file

@ -0,0 +1,322 @@
import type { InspectorClient } from "@inspector/core/mcp/index.js";
import type { Root } from "@modelcontextprotocol/client";
import {
ManagedToolsState,
ManagedResourcesState,
ManagedResourceTemplatesState,
ManagedPromptsState,
ManagedRequestorTasksState,
MessageLogState,
} from "@inspector/core/mcp/state/index.js";
import { CliExitCodeError, EXIT_CODES } from "../error-handler.js";
import { collectAppInfo } from "./collect-app-info.js";
import type {
CliAppInfo,
McpResponse,
MethodArgs,
MethodOutcome,
} from "./method-types.js";
/**
* Run one MCP method against a connected {@link InspectorClient}.
* Core method dispatch used by the CLI (and other Inspector Node runners).
*
* TODO(#1432): stream / task / roots / subscribe paths are exercised by the
* experimental session CLI (`mcpi`); `mcp-inspector --cli` only admits
* {@link ONE_SHOT_METHODS} plus catalog `servers/*`.
*/
export async function runMethod(
inspectorClient: InspectorClient,
args: MethodArgs & { method: string },
): Promise<MethodOutcome> {
let managedToolsState: ManagedToolsState | null = null;
let managedResourcesState: ManagedResourcesState | null = null;
let managedResourceTemplatesState: ManagedResourceTemplatesState | null =
null;
let managedPromptsState: ManagedPromptsState | null = null;
let managedTasksState: ManagedRequestorTasksState | null = null;
try {
let result: McpResponse;
let appInfo: CliAppInfo | undefined;
if (args.method === "tools/list" || args.method === "tools/call") {
managedToolsState = new ManagedToolsState(inspectorClient);
managedToolsState.setMetadata(args.metadata);
await managedToolsState.refresh();
}
if (args.method === "resources/list") {
managedResourcesState = new ManagedResourcesState(inspectorClient);
managedResourcesState.setMetadata(args.metadata);
await managedResourcesState.refresh();
} else if (args.method === "resources/templates/list") {
managedResourceTemplatesState = new ManagedResourceTemplatesState(
inspectorClient,
);
managedResourceTemplatesState.setMetadata(args.metadata);
await managedResourceTemplatesState.refresh();
} else if (args.method === "prompts/list") {
managedPromptsState = new ManagedPromptsState(inspectorClient);
managedPromptsState.setMetadata(args.metadata);
await managedPromptsState.refresh();
} else if (args.method === "tasks/list") {
managedTasksState = new ManagedRequestorTasksState(inspectorClient);
await managedTasksState.refresh();
}
if (args.method === "tools/list") {
const tools = managedToolsState!.getTools();
if (args.appInfo) {
// Collect every probe first, then emit NDJSON as a batch. A piped
// consumer therefore sees lines only after all tools are probed (not
// as each completes). Preferable to a partial stream when one tool's
// probe fails mid-list; collectAppInfo never throws for a single tool.
const lines: unknown[] = [];
for (const tool of tools) {
lines.push(
await collectAppInfo(inspectorClient, tool, args.metadata),
);
}
return { kind: "ndjson", lines };
}
result = { tools };
} else if (args.method === "tools/call") {
if (!args.toolName) {
throw new Error(
"Tool name is required for tools/call method. Use --tool-name to specify the tool name.",
);
}
const tool = managedToolsState!
.getTools()
.find((t) => t.name === args.toolName);
if (!tool) {
// Distinct from tools/call isError:true (tool ran, reported failure)
// and from "no App" under --app-info — the tool simply isn't listed.
throw new CliExitCodeError(
EXIT_CODES.TOOL_ERROR,
`Tool '${args.toolName}' not found on server.`,
{ code: "tool_not_found" },
);
}
// collectAppInfo only for --app-info / --format json — text mode skips
// the resources/read probe so a plain tools/call stays a single RPC.
if (args.appInfo || args.format === "json") {
appInfo = await collectAppInfo(inspectorClient, tool, args.metadata);
}
if (args.appInfo) {
result = { ...appInfo };
} else {
const invocation = args.task
? await inspectorClient.callToolStream(
tool,
args.toolArg || {},
args.metadata,
args.toolMeta,
)
: await inspectorClient.callTool(
tool,
args.toolArg || {},
args.metadata,
args.toolMeta,
);
if (invocation.result !== null) {
result = invocation.result;
} else {
result = {
content: [
{
type: "text" as const,
text: invocation.error || "Tool call failed",
},
],
isError: true,
};
}
}
} else if (args.method === "resources/list") {
result = { resources: managedResourcesState!.getResources() };
} else if (args.method === "resources/read") {
if (!args.uri) {
throw new Error(
"URI is required for resources/read method. Use --uri to specify the resource URI.",
);
}
const invocation = await inspectorClient.readResource(
args.uri,
args.metadata,
);
result = invocation.result;
} else if (args.method === "resources/templates/list") {
result = {
resourceTemplates:
managedResourceTemplatesState!.getResourceTemplates(),
};
} else if (args.method === "resources/subscribe") {
if (!args.uri) {
throw new Error(
"URI is required for resources/subscribe. Use --uri to specify the resource URI.",
);
}
await inspectorClient.subscribeToResource(args.uri);
return {
kind: "stream",
label: "resources/subscribe",
start: (writeLine) => {
writeLine({ type: "subscribed", uri: args.uri });
const onUpdate = (ev: Event) => {
const detail = (ev as CustomEvent<{ uri: string }>).detail;
writeLine({
type: "resources/updated",
uri: detail?.uri ?? args.uri,
});
};
inspectorClient.addEventListener("resourceUpdated", onUpdate);
return () => {
inspectorClient.removeEventListener("resourceUpdated", onUpdate);
void inspectorClient.unsubscribeFromResource(args.uri!);
};
},
};
} else if (args.method === "resources/unsubscribe") {
if (!args.uri) {
throw new Error(
"URI is required for resources/unsubscribe. Use --uri to specify the resource URI.",
);
}
await inspectorClient.unsubscribeFromResource(args.uri);
result = { unsubscribed: true, uri: args.uri };
} else if (args.method === "prompts/list") {
result = { prompts: managedPromptsState!.getPrompts() };
} else if (args.method === "prompts/get") {
if (!args.promptName) {
throw new Error(
"Prompt name is required for prompts/get method. Use --prompt-name to specify the prompt name.",
);
}
const invocation = await inspectorClient.getPrompt(
args.promptName,
args.promptArgs || {},
args.metadata,
);
result = invocation.result;
} else if (args.method === "prompts/complete") {
if (!args.completeRefType || !args.completeRef || !args.completeArgName) {
throw new Error(
"prompts/complete requires --complete-ref-type, --complete-ref, and --complete-arg-name.",
);
}
const ref =
args.completeRefType === "ref/prompt"
? ({ type: "ref/prompt", name: args.completeRef } as const)
: ({ type: "ref/resource", uri: args.completeRef } as const);
result = await inspectorClient.getCompletions(
ref,
args.completeArgName,
args.completeArgValue ?? "",
undefined,
args.metadata,
);
} else if (args.method === "initialize") {
result = {
serverInfo: inspectorClient.getServerInfo(),
protocolVersion: inspectorClient.getProtocolVersion(),
capabilities: inspectorClient.getCapabilities(),
instructions: inspectorClient.getInstructions(),
};
} else if (args.method === "logging/setLevel") {
if (!args.logLevel) {
throw new Error(
"Log level is required for logging/setLevel method. Use --log-level to specify the log level.",
);
}
await inspectorClient.setLoggingLevel(args.logLevel);
result = {};
} else if (args.method === "logging/tail") {
return {
kind: "stream",
label: "logging/tail",
start: (writeLine) => {
const log = new MessageLogState(inspectorClient);
const onMessage = (ev: Event) => {
const entry = (ev as CustomEvent).detail as {
direction?: string;
message?: { method?: string };
};
// Follow server logging notifications only (not full RPC history).
if (
entry.direction === "notification" &&
entry.message?.method === "notifications/message"
) {
writeLine(entry);
}
};
log.addEventListener("message", onMessage);
return () => {
log.removeEventListener("message", onMessage);
log.destroy();
};
},
};
} else if (args.method === "tasks/list") {
result = { tasks: managedTasksState!.getTasks() };
} else if (args.method === "tasks/get") {
if (!args.taskId) {
throw new Error("Task id is required for tasks/get. Use --task-id.");
}
result = {
task: await inspectorClient.getRequestorTask(args.taskId),
};
} else if (args.method === "tasks/cancel") {
if (!args.taskId) {
throw new Error("Task id is required for tasks/cancel. Use --task-id.");
}
await inspectorClient.cancelRequestorTask(args.taskId);
result = { cancelled: true, taskId: args.taskId };
} else if (args.method === "tasks/result") {
if (!args.taskId) {
throw new Error("Task id is required for tasks/result. Use --task-id.");
}
// SDK return type is a protocol result object; we only need a JSON bag.
result = (await inspectorClient.getRequestorTaskResult(
args.taskId,
)) as McpResponse;
} else if (args.method === "roots/list") {
result = { roots: inspectorClient.getRoots() };
} else if (args.method === "roots/set") {
if (!args.rootsJson) {
throw new Error(
"roots/set requires --roots-json '<json array of {uri,name?}>'.",
);
}
let roots: Root[];
try {
const parsed: unknown = JSON.parse(args.rootsJson);
if (!Array.isArray(parsed)) {
throw new Error("must be a JSON array");
}
roots = parsed as Root[];
} catch (e) {
throw new Error(
`--roots-json is invalid: ${e instanceof Error ? e.message : String(e)}`,
);
}
await inspectorClient.setRoots(roots);
result = { roots: inspectorClient.getRoots() };
} else {
throw new Error(
`Unsupported method: ${args.method}. Supported methods include: initialize, tools/*, resources/*, prompts/*, logging/*, tasks/*, roots/*.`,
);
}
return { kind: "result", result, appInfo };
} finally {
managedToolsState?.destroy();
managedResourcesState?.destroy();
managedResourceTemplatesState?.destroy();
managedPromptsState?.destroy();
managedTasksState?.destroy();
}
}

View file

@ -0,0 +1,226 @@
import type {
InspectorServerSettings,
MCPServerConfig,
} from "@inspector/core/mcp/types.js";
import { InMemorySecretStore } from "@inspector/core/auth/node/secret-store.js";
import {
loadServerEntries,
selectServerEntry,
type ServerLoadOptions,
} from "@inspector/core/mcp/node/index.js";
/** One catalog/config entry as returned by `servers/list`. */
export type ServerListEntry = {
name: string;
type: string;
/** Command line, URL, or other short identity for display. */
detail: string;
/**
* Optional live-session name when a caller annotates catalog entries
* with connected sessions (omitted for plain catalog listing).
*/
session?: string;
/** True when that session is the most-recently-used connected session. */
isMru?: boolean;
};
/** Minimal session shape needed to annotate catalog entries. */
export type SessionListRef = {
name: string;
isMru?: boolean;
};
/**
* Mark catalog entries that have a live session with the same name.
* Does not mutate `entries`.
*
* TODO(#1432): consumed by the experimental session CLI (`mcpi`); kept here so
* that client can reuse catalog listing without duplicating this helper.
*/
export function annotateServerEntriesWithSessions(
entries: ServerListEntry[],
sessions: SessionListRef[],
): ServerListEntry[] {
if (sessions.length === 0) return entries;
const byName = new Map(sessions.map((s) => [s.name, s] as const));
return entries.map((entry) => {
const session = byName.get(entry.name);
if (!session) return entry;
return {
...entry,
session: session.name,
...(session.isMru === true ? { isMru: true } : {}),
};
});
}
/** Detail view for `servers/show` (secrets redacted). */
export type ServerShowEntry = {
name: string;
type: string;
detail: string;
config: Record<string, unknown>;
settings?: Record<string, unknown>;
};
const REDACTED = "[redacted]";
/**
* Summarise an {@link MCPServerConfig} for catalog listing (no connection).
*/
export function summarizeServerConfig(config: MCPServerConfig): {
type: string;
detail: string;
} {
// Narrow on the URL-bearing transports first — stdio's `type` is optional, so
// an else-after-`=== "stdio"` check would still leave `StdioServerConfig` in
// the residual union (see `StdioServerConfig` in core/mcp/types.ts).
if (config.type === "sse" || config.type === "streamable-http") {
return { type: config.type, detail: config.url ?? "" };
}
const args = config.args?.length ? ` ${config.args.join(" ")}` : "";
return { type: "stdio", detail: `${config.command}${args}` };
}
/**
* Load catalog/config entries and return a sorted name + summary list.
* Uses an empty in-memory secret store by default so listing never touches the
* OS keychain (names/types/details do not need rehydrated secrets).
*/
export async function listServerEntries(
serverOptions: ServerLoadOptions = {},
): Promise<ServerListEntry[]> {
const entries = await loadServerEntries({
...serverOptions,
secretStore: serverOptions.secretStore ?? new InMemorySecretStore(),
});
return Object.entries(entries)
.map(([name, resolved]) => {
const { type, detail } = summarizeServerConfig(resolved.config);
return { name, type, detail };
})
.sort((a, b) => a.name.localeCompare(b.name));
}
/**
* Resolve one catalog/config entry for `servers/show` (no MCP connection).
* Secret-bearing fields (env values, OAuth client secret, sensitive headers)
* are replaced with {@link REDACTED}.
*/
export async function showServerEntry(
serverName: string,
serverOptions: ServerLoadOptions = {},
): Promise<ServerShowEntry> {
const name = serverName.trim();
if (!name) {
throw new Error("servers/show requires a server name.");
}
const entries = await loadServerEntries(serverOptions);
const selected = selectServerEntry(entries, name);
const { type, detail } = summarizeServerConfig(selected.config);
const result: ServerShowEntry = {
name,
type,
detail,
config: sanitizeServerConfig(selected.config),
};
if (selected.settings) {
result.settings = sanitizeServerSettings(selected.settings);
}
return result;
}
/** Visible for tests. */
export function sanitizeServerConfig(
config: MCPServerConfig,
): Record<string, unknown> {
const out: Record<string, unknown> = { ...config };
if ("env" in config && config.env) {
out.env = redactStringRecord(config.env);
}
if ("requestInit" in config && isPlainObject(config.requestInit)) {
out.requestInit = sanitizeInitRecord(config.requestInit);
}
if ("eventSourceInit" in config && isPlainObject(config.eventSourceInit)) {
out.eventSourceInit = sanitizeInitRecord(config.eventSourceInit);
}
return out;
}
/** Visible for tests. */
export function sanitizeServerSettings(
settings: InspectorServerSettings,
): Record<string, unknown> {
const out: Record<string, unknown> = {
...settings,
headers: (settings.headers ?? []).map((h) => ({
key: h.key,
value: isSensitiveHeader(h.key) ? REDACTED : h.value,
})),
metadata: (settings.metadata ?? []).map((m) => ({
key: m.key,
value: isSensitiveHeader(m.key) ? REDACTED : m.value,
})),
env: (settings.env ?? []).map((e) => ({
key: e.key,
value: REDACTED,
})),
};
if (settings.oauthClientSecret !== undefined) {
out.oauthClientSecret = REDACTED;
}
return out;
}
function redactStringRecord(
record: Record<string, string>,
): Record<string, string> {
return Object.fromEntries(Object.keys(record).map((key) => [key, REDACTED]));
}
function isPlainObject(value: unknown): value is Record<string, unknown> {
return value !== null && typeof value === "object" && !Array.isArray(value);
}
/** Redact sensitive header values inside requestInit / eventSourceInit. */
function sanitizeInitRecord(
init: Record<string, unknown>,
): Record<string, unknown> {
const out: Record<string, unknown> = { ...init };
if (isPlainObject(init.headers)) {
out.headers = Object.fromEntries(
Object.entries(init.headers).map(([key, value]) => [
key,
isSensitiveHeader(key) ? REDACTED : value,
]),
);
} else if (Array.isArray(init.headers)) {
// HeadersInit pair form: [["Authorization", "Bearer …"], …]
out.headers = init.headers.map((entry) => {
if (
Array.isArray(entry) &&
entry.length >= 2 &&
typeof entry[0] === "string"
) {
const key = entry[0];
const value = entry[1];
return [key, isSensitiveHeader(key) ? REDACTED : value];
}
return entry;
});
}
return out;
}
function isSensitiveHeader(key: string): boolean {
const k = key.toLowerCase();
return (
k.includes("auth") ||
k.includes("cookie") ||
k.includes("secret") ||
k.includes("token") ||
k.includes("password") ||
k.includes("api-key") ||
k.includes("apikey")
);
}

View file

@ -0,0 +1,6 @@
import open from "open";
/** Open a URL in the user's default browser (best-effort). */
export async function openUrl(url: string | URL): Promise<void> {
await open(typeof url === "string" ? url : url.href);
}

84
clients/cli/src/style.ts Normal file
View file

@ -0,0 +1,84 @@
import type { OutputFormat } from "./handlers/format-output.js";
/**
* ANSI / OSC 8 styling helpers.
*
* TODO(#1432): the CLI OAuth path only needs {@link Style.link} today; bold /
* color helpers and {@link styleFromOpts} are used by the experimental session
* CLI (`mcpi`) human formatter.
*/
export type Style = {
/** Whether ANSI styling is enabled. */
readonly ansi: boolean;
bold: (s: string) => string;
dim: (s: string) => string;
red: (s: string) => string;
yellow: (s: string) => string;
green: (s: string) => string;
cyan: (s: string) => string;
/** OSC 8 hyperlink when ansi; otherwise plain label (defaulting to uri). */
link: (uri: string, label?: string) => string;
};
const identity = (s: string): string => s;
function sgr(enabled: boolean, open: number, close: number) {
if (!enabled) return identity;
return (s: string): string => `\u001b[${open}m${s}\u001b[${close}m`;
}
/**
* Build a style helper. When `ansi` is false, all methods are identity
* (and `link` returns the label only — no OSC 8).
*/
export function createStyle(ansi: boolean): Style {
return {
ansi,
bold: sgr(ansi, 1, 22),
dim: sgr(ansi, 2, 22),
red: sgr(ansi, 31, 39),
yellow: sgr(ansi, 33, 39),
green: sgr(ansi, 32, 39),
cyan: sgr(ansi, 36, 39),
link: (uri: string, label?: string): string => {
const text = label ?? uri;
if (!ansi || !uri) return text;
// OSC 8 hyperlink: ESC ] 8 ; ; URI BEL text ESC ] 8 ; ; BEL
return `\u001b]8;;${uri}\u0007${text}\u001b]8;;\u0007`;
},
};
}
/** Plain (no ANSI) style — default for tests and `--plain`. */
export const PLAIN: Style = createStyle(false);
export type ResolveAnsiOptions = {
/** `--plain` — force no ANSI. */
plain?: boolean;
/** `--format`; json never uses ANSI for the payload path. */
format?: OutputFormat;
/** Override TTY detection (tests). */
isTTY?: boolean;
/** Override `NO_COLOR` (tests). */
noColorEnv?: string | undefined;
};
/**
* Decide whether session human output should use ANSI.
* Off when: `--plain`, `NO_COLOR` set, `--format json`, or the caller's stream
* is not a TTY (override via `isTTY`; default checks `process.stdout`).
*/
export function resolveAnsiEnabled(opts: ResolveAnsiOptions = {}): boolean {
if (opts.plain) return false;
if (opts.format === "json") return false;
const noColor =
opts.noColorEnv !== undefined ? opts.noColorEnv : process.env.NO_COLOR;
if (noColor !== undefined && noColor !== "") return false;
const tty =
opts.isTTY !== undefined ? opts.isTTY : process.stdout.isTTY === true;
return tty;
}
export function styleFromOpts(opts: ResolveAnsiOptions = {}): Style {
return createStyle(resolveAnsiEnabled(opts));
}