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

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,
);
}