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
8 changed files with 4041 additions and 520 deletions
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")
|
||||
);
|
||||
}
|
||||
Loading…
Reference in a new issue