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>
This commit is contained in:
parent
6829b45f60
commit
b33532c024
1 changed files with 1004 additions and 495 deletions
|
|
@ -15,6 +15,7 @@ import {
|
|||
import type { Root } from "@modelcontextprotocol/client";
|
||||
import type {
|
||||
InspectorServerSettings,
|
||||
RequestMetadata,
|
||||
MCPConfig,
|
||||
MCPServerConfig,
|
||||
ServerEntry,
|
||||
|
|
@ -28,6 +29,7 @@ import {
|
|||
envSecretField,
|
||||
} from "../auth/secret-fields.js";
|
||||
import { toRecord } from "../json/jsonUtils.js";
|
||||
import type { JsonValue } from "../json/jsonUtils.js";
|
||||
|
||||
// The full set of valid `type` discriminator values, used to reject anything
|
||||
// else read off disk so unknown strings can't propagate to narrowing sites.
|
||||
|
|
@ -194,6 +196,60 @@ export function envPairsToRecord(
|
|||
return out;
|
||||
}
|
||||
|
||||
/**
|
||||
* Read a server's `metadata` off disk into the in-memory `RequestMetadata`
|
||||
* object, accepting both the current object shape and the pre-#1910
|
||||
* `{ key, value }[]` pair array.
|
||||
*
|
||||
* The pair array is the shape every `mcp.json` written before #1910 carries, so
|
||||
* dropping it would silently stop sending a user's configured `_meta` on the
|
||||
* first read of an existing file. It is read here and never written back:
|
||||
* `inspectorSettingsToStoredFields` emits only the object form, so the file is
|
||||
* migrated the next time the entry is saved.
|
||||
*
|
||||
* Anything else — a string, a number, an array of non-pairs — is not metadata
|
||||
* the Inspector can send, so it is dropped with a warning rather than half-
|
||||
* interpreted, following `cleanRoots` / `cleanAuthorizationParams` above. A
|
||||
* pair whose key is blank is skipped for the same reason the write side omits
|
||||
* it: `_meta` has no meaningful empty key.
|
||||
*/
|
||||
export function normalizeStoredMetadata(
|
||||
metadata: StoredMCPServer["metadata"] | unknown,
|
||||
): RequestMetadata {
|
||||
if (metadata === undefined || metadata === null) return {};
|
||||
|
||||
if (Array.isArray(metadata)) {
|
||||
const out: RequestMetadata = {};
|
||||
for (const entry of metadata) {
|
||||
if (
|
||||
typeof entry !== "object" ||
|
||||
entry === null ||
|
||||
typeof (entry as { key?: unknown }).key !== "string"
|
||||
) {
|
||||
console.warn(
|
||||
"Ignoring malformed legacy metadata entry (expected `{ key, value }`):",
|
||||
entry,
|
||||
);
|
||||
continue;
|
||||
}
|
||||
const { key, value } = entry as { key: string; value?: JsonValue };
|
||||
if (key.trim() === "") continue;
|
||||
out[key] = value ?? "";
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
if (typeof metadata === "object") {
|
||||
return { ...(metadata as RequestMetadata) };
|
||||
}
|
||||
|
||||
console.warn(
|
||||
"Ignoring malformed `metadata` (expected a JSON object):",
|
||||
metadata,
|
||||
);
|
||||
return {};
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate a server's `oauth.authorizationParams` as it comes off disk, or
|
||||
* `undefined` when there is nothing usable. (#2018)
|
||||
|
|
@ -349,7 +405,7 @@ export function storedFieldsToInspectorSettings(
|
|||
const settings: InspectorServerSettings = {
|
||||
headers: headersPairs,
|
||||
env: envRecordToPairs(stored.env),
|
||||
metadata: stored.metadata ?? [],
|
||||
metadata: normalizeStoredMetadata(stored.metadata),
|
||||
connectionTimeout: stored.connectionTimeout ?? 0,
|
||||
requestTimeout: stored.requestTimeout ?? 0,
|
||||
// Unlike the timeouts (0 = "SDK default"), task TTL has a concrete product
|
||||
|
|
@ -458,9 +514,11 @@ export function inspectorSettingsToStoredFields(
|
|||
out.headers = headersRecord;
|
||||
}
|
||||
|
||||
const metadataFiltered = settings.metadata.filter((m) => m.key.trim() !== "");
|
||||
if (metadataFiltered.length > 0) {
|
||||
out.metadata = metadataFiltered;
|
||||
// Blank-key entries can't exist in the object form (an object has no blank
|
||||
// key the user can leave mid-edit, the way a `{key,value}` row could), so
|
||||
// there is nothing to filter — only the empty-object case to omit.
|
||||
if (Object.keys(settings.metadata).length > 0) {
|
||||
out.metadata = settings.metadata;
|
||||
}
|
||||
|
||||
if (settings.connectionTimeout > 0) {
|
||||
|
|
|
|||
Loading…
Reference in a new issue