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
3 changed files with 1004 additions and 495 deletions

View file

@ -10,7 +10,7 @@ import {
const defaultSettings: InspectorServerSettings = {
headers: [],
env: [],
metadata: [],
metadata: {},
connectionTimeout: 30000,
requestTimeout: 60000,
taskTtl: 60000,
@ -57,34 +57,9 @@ function InteractiveRender(args: ServerSettingsFormProps) {
},
});
}}
onAddMetadata={() => {
args.onAddMetadata();
updateArgs({
settings: {
...args.settings,
metadata: [...args.settings.metadata, { key: "", value: "" }],
},
});
}}
onRemoveMetadata={(index) => {
args.onRemoveMetadata(index);
updateArgs({
settings: {
...args.settings,
metadata: args.settings.metadata.filter((_, i) => i !== index),
},
});
}}
onMetadataChange={(index, key, value) => {
args.onMetadataChange(index, key, value);
updateArgs({
settings: {
...args.settings,
metadata: args.settings.metadata.map((m, i) =>
i === index ? { key, value } : m,
),
},
});
onMetadataChange={(metadata) => {
args.onMetadataChange(metadata);
updateArgs({ settings: { ...args.settings, metadata } });
}}
onAddEnv={() => {
args.onAddEnv();
@ -197,8 +172,6 @@ const meta: Meta<typeof ServerSettingsForm> = {
onRemoveEnv: fn(),
onEnvChange: fn(),
onCwdChange: fn(),
onAddMetadata: fn(),
onRemoveMetadata: fn(),
onMetadataChange: fn(),
onTimeoutChange: fn(),
onAutoRefreshChange: fn(),
@ -278,10 +251,13 @@ export const AllConfigured: Story = {
{ key: "X-Request-Id", value: "req-456" },
],
env: [],
metadata: [
{ key: "userId", value: "user-789" },
{ key: "sessionId", value: "session-012" },
],
metadata: {
userId: "user-789",
sessionId: "session-012",
// A structured value — the case a `{ key, value }` row editor could
// not express (#1910).
client: { name: "inspector", features: ["apps", "tasks"], retries: 3 },
},
connectionTimeout: 15000,
requestTimeout: 45000,
taskTtl: 45000,

View file

@ -22,7 +22,7 @@ function clearButtonFor(input: HTMLElement): HTMLElement {
const emptySettings: InspectorServerSettings = {
headers: [],
env: [],
metadata: [],
metadata: {},
connectionTimeout: 30000,
requestTimeout: 60000,
taskTtl: 60000,
@ -33,7 +33,7 @@ const emptySettings: InspectorServerSettings = {
const populatedSettings: InspectorServerSettings = {
headers: [{ key: "Authorization", value: "Bearer abc" }],
env: [],
metadata: [{ key: "userId", value: "u-1" }],
metadata: { userId: "u-1", tenant: { id: 7, tags: ["a"] } },
connectionTimeout: 30000,
requestTimeout: 60000,
taskTtl: 60000,
@ -63,8 +63,6 @@ const baseHandlers = {
onRemoveEnv: vi.fn(),
onEnvChange: vi.fn(),
onCwdChange: vi.fn(),
onAddMetadata: vi.fn(),
onRemoveMetadata: vi.fn(),
onMetadataChange: vi.fn(),
onTimeoutChange: vi.fn(),
onAutoRefreshChange: vi.fn(),
@ -239,7 +237,7 @@ describe("ServerSettingsForm", () => {
expect(screen.getByText("Log Level per Request")).toBeInTheDocument();
});
it("shows empty hints for headers and metadata when no entries exist", () => {
it("shows the empty hint for headers, and an empty JSON object for metadata", () => {
renderWithMantine(
<ServerSettingsForm
{...baseHandlers}
@ -250,9 +248,9 @@ describe("ServerSettingsForm", () => {
expect(
screen.getByText("No custom headers configured"),
).toBeInTheDocument();
expect(
screen.getByText("No request metadata configured"),
).toBeInTheDocument();
// Metadata has no rows to be absent — the editor is always present, and
// "none configured" is spelled `{}` in the box itself.
expect(screen.getByLabelText("Request metadata JSON")).toHaveValue("{}");
});
it("invokes onAddHeader when + Add Header is clicked", async () => {
@ -270,21 +268,6 @@ describe("ServerSettingsForm", () => {
expect(onAddHeader).toHaveBeenCalledTimes(1);
});
it("invokes onAddMetadata when + Add Metadata is clicked", async () => {
const user = userEvent.setup();
const onAddMetadata = vi.fn();
renderWithMantine(
<ServerSettingsForm
{...baseHandlers}
onAddMetadata={onAddMetadata}
settings={emptySettings}
expandedSections={["metadata"]}
/>,
);
await user.click(screen.getByRole("button", { name: "+ Add Metadata" }));
expect(onAddMetadata).toHaveBeenCalledTimes(1);
});
it("invokes onHeaderChange when typing in header value input", async () => {
const user = userEvent.setup();
const onHeaderChange = vi.fn();
@ -340,29 +323,38 @@ describe("ServerSettingsForm", () => {
expect(onRemoveHeader).toHaveBeenCalledWith(0);
});
it("invokes onMetadataChange and onRemoveMetadata for metadata rows", async () => {
const user = userEvent.setup();
const onMetadataChange = vi.fn();
const onRemoveMetadata = vi.fn();
it("renders the configured metadata as formatted JSON, nested values included", () => {
renderWithMantine(
<ServerSettingsForm
{...baseHandlers}
onMetadataChange={onMetadataChange}
onRemoveMetadata={onRemoveMetadata}
settings={populatedSettings}
expandedSections={["metadata"]}
/>,
);
const valueInput = screen.getByDisplayValue("u-1");
await user.type(valueInput, "Z");
expect(onMetadataChange).toHaveBeenCalled();
const editor = screen.getByLabelText("Request metadata JSON");
// The point of #1910: a nested object survives into the editor instead of
// being flattened into a `{ key, value }` string row.
expect(JSON.parse((editor as HTMLTextAreaElement).value)).toEqual({
userId: "u-1",
tenant: { id: 7, tags: ["a"] },
});
});
await user.click(
screen.getByRole("button", {
name: "Remove metadata entry, userId, row 1",
}),
it("reports the whole edited object through onMetadataChange", async () => {
const user = userEvent.setup();
const onMetadataChange = vi.fn();
renderWithMantine(
<ServerSettingsForm
{...baseHandlers}
onMetadataChange={onMetadataChange}
settings={emptySettings}
expandedSections={["metadata"]}
/>,
);
expect(onRemoveMetadata).toHaveBeenCalledWith(0);
const editor = screen.getByLabelText("Request metadata JSON");
await user.clear(editor);
await user.type(editor, '{{"n":[[1,2]}');
expect(onMetadataChange).toHaveBeenLastCalledWith({ n: [1, 2] });
});
it("invokes onTimeoutChange when typing in connection timeout", async () => {
@ -1470,23 +1462,6 @@ describe("ServerSettingsForm", () => {
expect(onClearStoredOAuth).toHaveBeenCalledTimes(1);
});
it("clears a metadata value via its Clear button (onMetadataChange with empty value)", async () => {
const user = userEvent.setup();
const onMetadataChange = vi.fn();
renderWithMantine(
<ServerSettingsForm
{...baseHandlers}
onMetadataChange={onMetadataChange}
settings={populatedSettings}
expandedSections={["metadata"]}
/>,
);
// populatedSettings has one metadata { key: "userId", value: "u-1" }
const valueInput = screen.getByDisplayValue("u-1");
await user.click(clearButtonFor(valueInput));
expect(onMetadataChange).toHaveBeenCalledWith(0, "userId", "");
});
it("omits the Clear buttons for an empty header key/value row", () => {
// A row whose key and value are both empty renders no Clear button in
// either field (the null branch of `item.key ?` / `item.value ?`).

View file

@ -4,6 +4,7 @@ import {
Alert,
Button,
Checkbox,
Code,
Flex,
Group,
NumberInput,
@ -13,11 +14,13 @@ import {
TextInput,
} from "@mantine/core";
import { ClearButton } from "../../elements/ClearButton/ClearButton";
import { JsonObjectInput } from "../../elements/JsonObjectInput/JsonObjectInput";
import type { ProtocolEra } from "@modelcontextprotocol/client";
import type {
InspectorServerSettings,
ModernLogLevel,
OAuthSettings,
RequestMetadata,
ServerProtocolEra,
ServerType,
} from "@inspector/core/mcp/types.js";
@ -78,9 +81,12 @@ export interface ServerSettingsFormProps {
onRemoveEnv: (index: number) => void;
onEnvChange: (index: number, key: string, value: string) => void;
onCwdChange: (value: string) => void;
onAddMetadata: () => void;
onRemoveMetadata: (index: number) => void;
onMetadataChange: (index: number, key: string, value: string) => void;
/**
* Replace the whole default-`_meta` payload. Not the `(index, key, value)`
* row callback the header/env editors use — metadata is edited as a JSON
* object, since a `_meta` value may be any JSON (#1910).
*/
onMetadataChange: (metadata: RequestMetadata) => void;
onTimeoutChange: (
field: "connectionTimeout" | "requestTimeout" | "taskTtl",
value: number,
@ -434,8 +440,6 @@ export function ServerSettingsForm({
onRemoveEnv,
onEnvChange,
onCwdChange,
onAddMetadata,
onRemoveMetadata,
onMetadataChange,
onTimeoutChange,
onAutoRefreshChange,
@ -724,22 +728,18 @@ export function ServerSettingsForm({
<Accordion.Control>Request Metadata</Accordion.Control>
<Accordion.Panel>
<Stack gap="md">
<Group justify="space-between">
<HintText>
Metadata sent with every MCP request (included in _meta field)
</HintText>
<AddButton onClick={onAddMetadata}>+ Add Metadata</AddButton>
</Group>
{settings.metadata.length === 0 ? (
<EmptyHint>No request metadata configured</EmptyHint>
) : (
<KeyValueRows
items={settings.metadata}
entityLabel="metadata entry"
onChange={onMetadataChange}
onRemove={onRemoveMetadata}
/>
)}
<HintText>
Metadata merged into the `_meta` field of every MCP request to
this server, as a JSON object. Values may be any JSON — objects,
arrays, numbers, booleans, `null` — not just strings. Leave it as{" "}
<Code>{"{}"}</Code> to send none.
</HintText>
<JsonObjectInput
aria-label="Request metadata JSON"
placeholder={'{\n "example": { "nested": true }\n}'}
value={settings.metadata}
onChange={onMetadataChange}
/>
</Stack>
</Accordion.Panel>
</Accordion.Item>