CLI: catalog listing, auth flags, and OAuth browser UX (#1782)

* feat(cli): catalog listing, auth flags, and OAuth browser UX

Add servers/list and servers/show, --relogin / --stored-auth-only, browser
OAuth navigation with OSC 8 links, and extract method handlers under
clients/cli/src/handlers. Tracks #1781.

Co-authored-by: Cursor <cursoragent@cursor.com>

* fix(cli): address #1782 review — relogin clear, stored-auth-only, docs

Clear both raw and normalized OAuth keys on --relogin; check the shared
store before bailing under --stored-auth-only mid-session; restore the
tests README link target; document servers/show redaction limits; keep
mcpi groundwork with TODO(#1432) markers.

Co-authored-by: Cursor <cursoragent@cursor.com>

* fix(cli): gate OAuth browser open and harden #1782 review follow-ups

Arm auto-open only for CLI-owned interactive OAuth, honor MCP_AUTO_OPEN_ENABLED,
move --relogin clear after validation, skip keychain on servers/list, and cover
secret redaction after rehydrate.

Co-authored-by: Cursor <cursoragent@cursor.com>

* fix(cli): silence disarmed OAuth URLs and round-3 review nits

Suppress authorize URL print when navigation is disarmed or
--stored-auth-only; document MCP_AUTO_OPEN_ENABLED; redact Cookie/auth
headers; keep servers/show unit tests off the OS keychain.

Co-authored-by: Cursor <cursoragent@cursor.com>

* test(cli): cover withArmedAutoOpen for the ≥90 branch gate

CI failed on cliOAuth.ts branch coverage after the auto-open arming
helper landed; exercise the armed path through runCliInteractiveOAuth.

Co-authored-by: Cursor <cursoragent@cursor.com>

* fix(cli): mid-RPC unauthorized recovery and clear round-4 nits

Mirror connect's UnauthorizedError path in withCliAuthRecoveryRetry, let
MCP_AUTO_OPEN_ENABLED=true force open off-TTY, mock open-url in the
in-process runner, reject --relogin on catalog methods, restore the
__tests__ README inventory, and narrow OAuth helpers to structural client types.

Co-authored-by: Cursor <cursoragent@cursor.com>

* fix(cli): reconnect after mid-RPC OAuth and finish round-5 nits

Reconnect before retrying after unauthorized recovery, guard non-OAuth
configs, decouple forceAutoOpen, move open-url mock to vitest setupFiles,
and reject --relogin with stored-auth short-circuits.

Co-authored-by: Cursor <cursoragent@cursor.com>

* fix(cli): fail fast on non-TTY interactive OAuth and round-6 nits

Skip interactive OAuth without a TTY unless MCP_AUTO_OPEN_ENABLED=true;
document --stored-auth-only for CI; redact settings.metadata; reject
--relogin for stdio; clarify AuthRecoveryRequired reconnect comments.

Co-authored-by: Cursor <cursoragent@cursor.com>

* fix(cli): admit interactive OAuth when stdin is a TTY (round-7)

Gate on stdin||stderr so `2>&1 | tee` still works; fold confirm/auto-open/
TTY into CliOAuthConnectOptions; rename authRequiredFailure; document
MCP_AUTO_OPEN_ENABLED=true as non-TTY admit.

Co-authored-by: Cursor <cursoragent@cursor.com>

* fix(cli): fail fast on step-up without stdin (round-8)

Decline [y/N] on readline EOF/close, require a stdin TTY for the default
step-up confirmer (force-open admit is not enough), document stderr-only
browser auto-open vs stdin||stderr flow admit, and drop the alias wrapper.

Co-authored-by: Cursor <cursoragent@cursor.com>

* fix(cli): allow piped step-up answers; bound silent pipes (round-9)

Drop the hard stdin-TTY gate that broke `echo y | …`. Keep EOF decline and
add a short non-TTY timeout so an open pipe that never writes cannot hang.
Clarify isTTY as admit-only; extract the readline mock factory.

Co-authored-by: Cursor <cursoragent@cursor.com>

* fix(cli): keep partial piped step-up answers (round-10)

Capture readline `line` before close so `printf y` without a newline is
not treated as decline; timeout is auth_required with a distinct message;
document the 5s bound and post-confirm callback wait; pin the TTY no-timeout arm.

Co-authored-by: Cursor <cursoragent@cursor.com>

* fix(cli): tighten step-up timeout messaging and TTY test (round-11)

Make the TTY no-timeout assertion use a settled flag (the Promise.race
check was vacuous), say "timed out" instead of "declined" on the pipe
bound, and document that piped answers need a newline or stdin close.

Co-authored-by: Cursor <cursoragent@cursor.com>

---------

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Bob Dickinson 2026-07-26 13:55:25 -07:00 • committed by GitHub
commit 33e88eac9f
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
8 changed files with 4041 additions and 520 deletions

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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