inspector/clients/cli/src/handlers/servers-list.ts
cliffhall b33532c024 feat: allow complex JSON values in request _meta
Closes #1910

`_meta` was typed `Record<string, string>` end to end in the Inspector —
on disk as a `{ key, value }[]` pair array, in the Server Settings form as
key/value rows, and in every request verb's per-call metadata parameter.
Nothing in the MCP spec restricts a `_meta` value to a string, and the SDK
models the field as a `z.looseObject`, so the restriction was ours alone.

Widen the whole path to `RequestMetadata` (`Record<string, JsonValue>`):

- Per-server default metadata is a JSON **object** on disk and in memory.
  The pre-#1910 pair array is still read (`normalizeStoredMetadata`) so an
  existing `mcp.json` keeps working; it is never written back, so a save
  migrates the entry.
- The Server Settings → Request Metadata section is a JSON editor
  (`JsonObjectInput`) instead of key/value rows — rows cannot express an
  object value. It keeps the typed text as the display source and emits
  only parsed JSON, the same draft split `SchemaJsonField` uses, so the box
  stays typeable; invalid text is flagged inline and not applied.
- The CLI stops flattening `--metadata` / `--tool-metadata` through
  `metaValueToString`. Those values were already JSON-parsed, so
  `trace={"id":"abc"}` used to reach the wire as the *string* `{"id":"abc"}`
  and now reaches it as an object.
- `servers/show` redaction replaces a sensitive key's whole value rather
  than a string, so a structured value cannot leak through its members.
- `progressTokenOf` narrows the reserved `progressToken` key back to
  `string | number` before it reaches the SDK; any other JSON still travels
  inside `_meta` but wires up no progress callback.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0177mPHAdECD18nLLCTwR5rh
Signed-off-by: cliffhall <cliff@futurescale.com>
2026-08-23 12:23:40 -04:00

231 lines
7.1 KiB
TypeScript

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 is a JSON object whose values may be any JSON (#1910), so the
// redaction replaces the whole value rather than a string — a sensitive
// key holding an object must not leak through its members.
metadata: Object.fromEntries(
Object.entries(settings.metadata ?? {}).map(([key, value]) => [
key,
isSensitiveHeader(key) ? REDACTED : 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")
);
}