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>
172 lines
6.1 KiB
TypeScript
172 lines
6.1 KiB
TypeScript
import type { SecretStore } from "../../auth/node/secret-store.js";
|
|
import { defaultSecretStore } from "../../auth/node/secret-store-selection.js";
|
|
import type { InspectorServerSettings, MCPServerConfig } from "../types.js";
|
|
import { DEFAULT_MAX_FETCH_REQUESTS, DEFAULT_TASK_TTL_MS } from "../types.js";
|
|
import { mcpConfigToServerEntries } from "../serverList.js";
|
|
import {
|
|
applyOverrides,
|
|
resolveServerConfigs,
|
|
resolveServerSource,
|
|
serverSourceConflict,
|
|
readServerListFile,
|
|
hasAdHocServerOptions,
|
|
withDefaultCatalogPath,
|
|
type ServerConfigOptions,
|
|
} from "./config.js";
|
|
import { rehydrateMcpConfigFromKeychain } from "./server-secrets.js";
|
|
|
|
/**
|
|
* A server resolved from a catalog/config file or an ad-hoc target, paired with
|
|
* the per-server settings (headers, timeouts, OAuth, etc.) lifted from the file.
|
|
* `config` is the SDK-facing transport config; `settings` is the v2
|
|
* `InspectorServerSettings` model (post-#1358 top-level shape).
|
|
*/
|
|
export type ResolvedServer = {
|
|
config: MCPServerConfig;
|
|
settings?: InspectorServerSettings;
|
|
};
|
|
|
|
/** Loader options: the shared source flags plus a single `--header` set that is
|
|
* broadcast into every resolved server's settings. */
|
|
export type ServerLoadOptions = ServerConfigOptions & {
|
|
headers?: Record<string, string>;
|
|
/** Test injection; defaults to the selected store (see
|
|
* `secret-store-selection.ts`) for catalog/config loads. */
|
|
secretStore?: SecretStore;
|
|
};
|
|
|
|
/**
|
|
* Build an `InspectorServerSettings` carrying only `--header` overrides (all
|
|
* other fields defaulted). Returns undefined when no headers are given so it can
|
|
* be used as a no-op base in `mergeSettings`.
|
|
*/
|
|
export function headersToServerSettings(
|
|
headers?: Record<string, string>,
|
|
): InspectorServerSettings | undefined {
|
|
if (!headers || Object.keys(headers).length === 0) {
|
|
return undefined;
|
|
}
|
|
return {
|
|
headers: Object.entries(headers).map(([key, value]) => ({ key, value })),
|
|
env: [],
|
|
metadata: {},
|
|
connectionTimeout: 0,
|
|
requestTimeout: 0,
|
|
taskTtl: DEFAULT_TASK_TTL_MS,
|
|
maxFetchRequests: DEFAULT_MAX_FETCH_REQUESTS,
|
|
autoRefreshOnListChanged: false,
|
|
paginatedLists: false,
|
|
roots: [],
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Overlay CLI `--header` values onto the settings lifted from the file. Only the
|
|
* `headers` field is overridden — timeouts, OAuth, and the rest of the file's
|
|
* settings are preserved.
|
|
*/
|
|
function mergeSettings(
|
|
base: InspectorServerSettings | undefined,
|
|
headers?: Record<string, string>,
|
|
): InspectorServerSettings | undefined {
|
|
const fromHeaders = headersToServerSettings(headers);
|
|
if (!fromHeaders) return base;
|
|
if (!base) return fromHeaders;
|
|
return { ...base, headers: fromHeaders.headers };
|
|
}
|
|
|
|
/**
|
|
* Resolve every server from a catalog/config file (or a single ad-hoc target)
|
|
* into `{ config, settings }`, lifting disk-level headers/timeouts/OAuth into
|
|
* `InspectorServerSettings` via `mcpConfigToServerEntries`. Shared by the CLI
|
|
* and TUI so both apply the same file→settings resolution (issue #1482).
|
|
*
|
|
* Applies the default writable catalog when no source/ad-hoc target is given,
|
|
* enforces the `--catalog`/`--config`/ad-hoc conflict matrix, and seeds an empty
|
|
* writable catalog on first run (a missing read-only `--config` still errors).
|
|
*/
|
|
export async function loadServerEntries(
|
|
serverOptions: ServerLoadOptions,
|
|
): Promise<Record<string, ResolvedServer>> {
|
|
serverOptions = withDefaultCatalogPath(serverOptions);
|
|
|
|
const conflict = serverSourceConflict({
|
|
hasCatalog: Boolean(serverOptions.catalogPath?.trim()),
|
|
hasConfig: Boolean(serverOptions.configPath?.trim()),
|
|
hasAdHoc: hasAdHocServerOptions(serverOptions),
|
|
});
|
|
if (conflict) {
|
|
throw new Error(conflict);
|
|
}
|
|
|
|
const source = resolveServerSource(serverOptions);
|
|
|
|
if (source) {
|
|
let config = readServerListFile(source.path, source.writable);
|
|
const secretStore = serverOptions.secretStore ?? defaultSecretStore();
|
|
config = await rehydrateMcpConfigFromKeychain(config, secretStore);
|
|
const entries = mcpConfigToServerEntries(config);
|
|
const result: Record<string, ResolvedServer> = {};
|
|
for (const entry of entries) {
|
|
result[entry.name] = {
|
|
config: applyOverrides(entry.config, {
|
|
env: serverOptions.env,
|
|
cwd: serverOptions.cwd,
|
|
}),
|
|
// Deliberate broadcast: a single `--header` set is merged into EVERY
|
|
// server in the catalog/config (fine for the common single-server case;
|
|
// for multi-server files, prefer per-server headers in the file itself).
|
|
settings: mergeSettings(entry.settings, serverOptions.headers),
|
|
};
|
|
}
|
|
return result;
|
|
}
|
|
|
|
const configs = resolveServerConfigs(serverOptions, "multi");
|
|
if (configs.length === 0) {
|
|
throw new Error(
|
|
"At least one server is required. Use --catalog/--config <path> or an ad-hoc target (command/URL).",
|
|
);
|
|
}
|
|
return {
|
|
default: {
|
|
config: configs[0]!,
|
|
settings: headersToServerSettings(serverOptions.headers),
|
|
},
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Pick a single resolved server for runners that connect to exactly one (the
|
|
* CLI). With `serverName`, returns that entry or errors listing the available
|
|
* names; otherwise requires exactly one server in the source.
|
|
*
|
|
* The unknown-name error is source-agnostic ("not found" rather than "not found
|
|
* in config file") because `entries` may come from a file *or* a single ad-hoc
|
|
* target — e.g. `--server foo` alongside a positional command resolves to just
|
|
* `{ default }`, where "in config file" would be misleading.
|
|
*/
|
|
export function selectServerEntry(
|
|
entries: Record<string, ResolvedServer>,
|
|
serverName?: string,
|
|
): ResolvedServer {
|
|
const names = Object.keys(entries);
|
|
if (serverName) {
|
|
const entry = entries[serverName];
|
|
if (!entry) {
|
|
throw new Error(
|
|
`Server '${serverName}' not found. Available servers: ${names.join(", ")}`,
|
|
);
|
|
}
|
|
return entry;
|
|
}
|
|
if (names.length === 0) {
|
|
throw new Error("No servers found in config file");
|
|
}
|
|
if (names.length > 1) {
|
|
throw new Error(
|
|
`Multiple servers found in config file. Please specify one with --server. Available servers: ${names.join(", ")}`,
|
|
);
|
|
}
|
|
return entries[names[0]!]!;
|
|
}
|