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:
cliffhall 2026-08-23 12:23:40 -04:00
commit b33532c024

View file

@ -53,6 +53,7 @@ import type {
MessageEntry,
ConnectionStatus,
FetchRequestEntryBase,
RequestMetadata,
} from "@inspector/core/mcp/types.js";
import type { JsonValue } from "@inspector/core/json/jsonUtils.js";
import type {
@ -164,7 +165,7 @@ function settleInFlight(call: Promise<unknown>): InFlightCall {
/** Get all resources from the client via listResources() (paginates if needed). */
async function getAllResources(
client: InspectorClient,
metadata?: Record<string, string>,
metadata?: RequestMetadata,
): Promise<Resource[]> {
const collected: Resource[] = [];
let cursor: string | undefined;
@ -180,7 +181,7 @@ async function getAllResources(
/** Get all resource templates via listResourceTemplates() (paginates if needed). */
async function getAllResourceTemplates(
client: InspectorClient,
metadata?: Record<string, string>,
metadata?: RequestMetadata,
): Promise<ResourceTemplate[]> {
const collected: ResourceTemplate[] = [];
let cursor: string | undefined;
@ -196,7 +197,7 @@ async function getAllResourceTemplates(
/** Get all prompts via listPrompts() (paginates if needed). */
async function getAllPrompts(
client: InspectorClient,
metadata?: Record<string, string>,
metadata?: RequestMetadata,
): Promise<Prompt[]> {
const collected: Prompt[] = [];
let cursor: string | undefined;
@ -386,7 +387,7 @@ describe("InspectorClient", () => {
serverSettings: {
headers: [],
env: [],
metadata: [],
metadata: {},
connectionTimeout: 50,
requestTimeout: 0,
taskTtl: 0,
@ -756,7 +757,7 @@ describe("InspectorClient", () => {
const initial = {
headers: [],
env: [],
metadata: [],
metadata: {},
connectionTimeout: 0,
requestTimeout: 0,
taskTtl: 0,
@ -1372,6 +1373,45 @@ describe("InspectorClient", () => {
messageLogState.destroy();
});
it("sends a structured default _meta value as JSON, not as a string (#1910)", async () => {
// The whole point of #1910: a `_meta` value may be an object, an array,
// a number, a boolean or `null`. The Inspector used to type this map as
// `Record<string, string>`, so a nested payload could only be sent by
// stringifying it — which is a different wire message than the one the
// user configured.
const structured = {
trace: { id: "abc123", sampled: true, hops: [1, 2] },
retries: 3,
beta: false,
unset: null,
};
client = new InspectorClient(
{
type: "stdio",
command: serverCommand.command,
args: serverCommand.args,
},
{
environment: { transport: createTransportNode },
defaultMetadata: structured,
},
);
const messageLogState = new MessageLogState(client);
await client.connect();
await client.listTools();
const listToolsReq = messageLogState
.getMessages()
.find(
(m) =>
m.direction === "request" &&
(m.message as { method?: string }).method === "tools/list",
);
expect(listToolsReq).toBeDefined();
expect(metaOf(listToolsReq!)).toMatchObject(structured);
messageLogState.destroy();
});
it("call-time metadata overrides defaultMetadata on key collision", async () => {
client = new InspectorClient(
{
@ -4241,7 +4281,7 @@ describe("InspectorClient", () => {
timestamp: Date;
success: boolean;
error?: string;
metadata?: Record<string, string>;
metadata?: RequestMetadata;
}> = [];
client!.addEventListener(