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>
231 lines
7.1 KiB
TypeScript
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")
|
|
);
|
|
}
|