CLI: catalog listing, auth flags, and OAuth browser UX (#1782)
* feat(cli): catalog listing, auth flags, and OAuth browser UX Add servers/list and servers/show, --relogin / --stored-auth-only, browser OAuth navigation with OSC 8 links, and extract method handlers under clients/cli/src/handlers. Tracks #1781. Co-authored-by: Cursor <cursoragent@cursor.com> * fix(cli): address #1782 review — relogin clear, stored-auth-only, docs Clear both raw and normalized OAuth keys on --relogin; check the shared store before bailing under --stored-auth-only mid-session; restore the tests README link target; document servers/show redaction limits; keep mcpi groundwork with TODO(#1432) markers. Co-authored-by: Cursor <cursoragent@cursor.com> * fix(cli): gate OAuth browser open and harden #1782 review follow-ups Arm auto-open only for CLI-owned interactive OAuth, honor MCP_AUTO_OPEN_ENABLED, move --relogin clear after validation, skip keychain on servers/list, and cover secret redaction after rehydrate. Co-authored-by: Cursor <cursoragent@cursor.com> * fix(cli): silence disarmed OAuth URLs and round-3 review nits Suppress authorize URL print when navigation is disarmed or --stored-auth-only; document MCP_AUTO_OPEN_ENABLED; redact Cookie/auth headers; keep servers/show unit tests off the OS keychain. Co-authored-by: Cursor <cursoragent@cursor.com> * test(cli): cover withArmedAutoOpen for the ≥90 branch gate CI failed on cliOAuth.ts branch coverage after the auto-open arming helper landed; exercise the armed path through runCliInteractiveOAuth. Co-authored-by: Cursor <cursoragent@cursor.com> * fix(cli): mid-RPC unauthorized recovery and clear round-4 nits Mirror connect's UnauthorizedError path in withCliAuthRecoveryRetry, let MCP_AUTO_OPEN_ENABLED=true force open off-TTY, mock open-url in the in-process runner, reject --relogin on catalog methods, restore the __tests__ README inventory, and narrow OAuth helpers to structural client types. Co-authored-by: Cursor <cursoragent@cursor.com> * fix(cli): reconnect after mid-RPC OAuth and finish round-5 nits Reconnect before retrying after unauthorized recovery, guard non-OAuth configs, decouple forceAutoOpen, move open-url mock to vitest setupFiles, and reject --relogin with stored-auth short-circuits. Co-authored-by: Cursor <cursoragent@cursor.com> * fix(cli): fail fast on non-TTY interactive OAuth and round-6 nits Skip interactive OAuth without a TTY unless MCP_AUTO_OPEN_ENABLED=true; document --stored-auth-only for CI; redact settings.metadata; reject --relogin for stdio; clarify AuthRecoveryRequired reconnect comments. Co-authored-by: Cursor <cursoragent@cursor.com> * fix(cli): admit interactive OAuth when stdin is a TTY (round-7) Gate on stdin||stderr so `2>&1 | tee` still works; fold confirm/auto-open/ TTY into CliOAuthConnectOptions; rename authRequiredFailure; document MCP_AUTO_OPEN_ENABLED=true as non-TTY admit. Co-authored-by: Cursor <cursoragent@cursor.com> * fix(cli): fail fast on step-up without stdin (round-8) Decline [y/N] on readline EOF/close, require a stdin TTY for the default step-up confirmer (force-open admit is not enough), document stderr-only browser auto-open vs stdin||stderr flow admit, and drop the alias wrapper. Co-authored-by: Cursor <cursoragent@cursor.com> * fix(cli): allow piped step-up answers; bound silent pipes (round-9) Drop the hard stdin-TTY gate that broke `echo y | …`. Keep EOF decline and add a short non-TTY timeout so an open pipe that never writes cannot hang. Clarify isTTY as admit-only; extract the readline mock factory. Co-authored-by: Cursor <cursoragent@cursor.com> * fix(cli): keep partial piped step-up answers (round-10) Capture readline `line` before close so `printf y` without a newline is not treated as decline; timeout is auth_required with a distinct message; document the 5s bound and post-confirm callback wait; pin the TTY no-timeout arm. Co-authored-by: Cursor <cursoragent@cursor.com> * fix(cli): tighten step-up timeout messaging and TTY test (round-11) Make the TTY no-timeout assertion use a settled flag (the Promise.race check was vacuous), say "timed out" instead of "declined" on the pipe bound, and document that piped answers need a newline or stdin close. Co-authored-by: Cursor <cursoragent@cursor.com> --------- Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
parent
a7b2fdb2ef
commit
33e88eac9f
1 changed files with 4041 additions and 520 deletions
|
|
@ -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,
|
||||
);
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue