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
14 changed files with 4041 additions and 520 deletions
39
clients/cli/src/clear-stored-auth-for-relogin.ts
Normal file
39
clients/cli/src/clear-stored-auth-for-relogin.ts
Normal 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();
|
||||
}
|
||||
128
clients/cli/src/cli-oauth-navigation.ts
Normal file
128
clients/cli/src/cli-oauth-navigation.ts
Normal 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.
|
||||
}
|
||||
});
|
||||
}
|
||||
|
|
@ -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,
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
35
clients/cli/src/handlers/collect-app-info.ts
Normal file
35
clients/cli/src/handlers/collect-app-info.ts
Normal 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),
|
||||
};
|
||||
}
|
||||
}
|
||||
35
clients/cli/src/handlers/connect-timeout.ts
Normal file
35
clients/cli/src/handlers/connect-timeout.ts
Normal 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: [],
|
||||
};
|
||||
}
|
||||
40
clients/cli/src/handlers/consume-outcome.ts
Normal file
40
clients/cli/src/handlers/consume-outcome.ts
Normal 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);
|
||||
});
|
||||
}
|
||||
46
clients/cli/src/handlers/emit-result.ts
Normal file
46
clients/cli/src/handlers/emit-result.ts
Normal 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" },
|
||||
);
|
||||
}
|
||||
}
|
||||
18
clients/cli/src/handlers/format-output.ts
Normal file
18
clients/cli/src/handlers/format-output.ts
Normal 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");
|
||||
}
|
||||
122
clients/cli/src/handlers/method-types.ts
Normal file
122
clients/cli/src/handlers/method-types.ts
Normal 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);
|
||||
}
|
||||
322
clients/cli/src/handlers/run-method.ts
Normal file
322
clients/cli/src/handlers/run-method.ts
Normal 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();
|
||||
}
|
||||
}
|
||||
226
clients/cli/src/handlers/servers-list.ts
Normal file
226
clients/cli/src/handlers/servers-list.ts
Normal 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")
|
||||
);
|
||||
}
|
||||
6
clients/cli/src/open-url.ts
Normal file
6
clients/cli/src/open-url.ts
Normal 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
84
clients/cli/src/style.ts
Normal 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));
|
||||
}
|
||||
Loading…
Reference in a new issue