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

@ -7,7 +7,7 @@ import { ServerSettingsModal } from "./ServerSettingsModal";
const initialSettings: InspectorServerSettings = {
headers: [{ key: "Authorization", value: "Bearer abc" }],
env: [],
metadata: [{ key: "userId", value: "u-1" }],
metadata: { userId: "u-1" },
connectionTimeout: 30000,
requestTimeout: 60000,
taskTtl: 60000,
@ -21,7 +21,7 @@ const initialSettings: InspectorServerSettings = {
const emptySettings: InspectorServerSettings = {
headers: [],
env: [],
metadata: [],
metadata: {},
connectionTimeout: 30000,
requestTimeout: 60000,
taskTtl: 60000,
@ -310,76 +310,6 @@ describe("ServerSettingsModal", () => {
expect(lastCall.headers[0].value).toContain("Bearer abc");
});
it("calls onSettingsChange when adding metadata after expanding the section", async () => {
const user = userEvent.setup();
const onSettingsChange = vi.fn();
renderWithMantine(
<ServerSettingsModal
opened
settings={emptySettings}
serverType="streamable-http"
isStdio={false}
onClose={vi.fn()}
onSettingsChange={onSettingsChange}
/>,
);
await user.click(screen.getByRole("button", { name: "Request Metadata" }));
await user.click(screen.getByRole("button", { name: "+ Add Metadata" }));
expect(onSettingsChange).toHaveBeenCalledWith({
...emptySettings,
metadata: [{ key: "", value: "" }],
});
});
it("calls onSettingsChange when removing metadata", async () => {
const user = userEvent.setup();
const onSettingsChange = vi.fn();
renderWithMantine(
<ServerSettingsModal
opened
settings={initialSettings}
serverType="streamable-http"
isStdio={false}
onClose={vi.fn()}
onSettingsChange={onSettingsChange}
/>,
);
await user.click(screen.getByRole("button", { name: "Request Metadata" }));
// Both the header and metadata rows have a remove button; each is named
// for the row it belongs to, so no positional guess is needed.
await user.click(
screen.getByRole("button", {
name: "Remove metadata entry, userId, row 1",
}),
);
expect(onSettingsChange).toHaveBeenCalledWith({
...initialSettings,
metadata: [],
});
});
it("calls onSettingsChange when typing in metadata key/value", async () => {
const user = userEvent.setup();
const onSettingsChange = vi.fn();
renderWithMantine(
<ServerSettingsModal
opened
settings={initialSettings}
serverType="streamable-http"
isStdio={false}
onClose={vi.fn()}
onSettingsChange={onSettingsChange}
/>,
);
await user.click(screen.getByRole("button", { name: "Request Metadata" }));
const valueInput = screen.getByDisplayValue("u-1");
await user.type(valueInput, "9");
expect(onSettingsChange).toHaveBeenCalled();
const lastCall =
onSettingsChange.mock.calls[onSettingsChange.mock.calls.length - 1][0];
expect(lastCall.metadata[0].key).toBe("userId");
});
it("calls onSettingsChange when changing timeouts", async () => {
const user = userEvent.setup();
const onSettingsChange = vi.fn();
@ -703,20 +633,13 @@ describe("ServerSettingsModal", () => {
expect(lastCall.headers[1]).toEqual({ key: "B", value: "2" });
});
it("preserves sibling rows when editing one of several metadata entries", async () => {
it("replaces the whole metadata object when the JSON editor changes", async () => {
const user = userEvent.setup();
const onSettingsChange = vi.fn();
const twoMeta: InspectorServerSettings = {
...emptySettings,
metadata: [
{ key: "m1", value: "v1" },
{ key: "m2", value: "v2" },
],
};
renderWithMantine(
<ServerSettingsModal
opened
settings={twoMeta}
settings={emptySettings}
serverType="streamable-http"
isStdio={false}
onClose={vi.fn()}
@ -724,9 +647,15 @@ describe("ServerSettingsModal", () => {
/>,
);
await user.click(screen.getByRole("button", { name: "Request Metadata" }));
await user.type(screen.getByDisplayValue("v1"), "x");
const editor = screen.getByLabelText("Request metadata JSON");
await user.clear(editor);
await user.type(editor, '{{"trace":{{"id":"abc"}}}');
const lastCall = onSettingsChange.mock.calls.at(-1)?.[0];
expect(lastCall.metadata[1]).toEqual({ key: "m2", value: "v2" });
// The nested object arrives as an object, not a stringified one — the
// whole point of #1910.
expect(lastCall.metadata).toEqual({ trace: { id: "abc" } });
// Every other setting is carried through untouched.
expect(lastCall.headers).toEqual(emptySettings.headers);
});
it("preserves sibling rows when editing one of several roots", async () => {