diff --git a/clients/cli/README.md b/clients/cli/README.md index d5c033df..976f2ec6 100644 --- a/clients/cli/README.md +++ b/clients/cli/README.md @@ -110,15 +110,15 @@ Options that specify the MCP server (catalog/config file, ad-hoc command/URL, en | `--prompt-name ` | Prompt name (for `prompts/get`). | | `--prompt-args ` | Prompt arguments; repeat for multiple. | | `--log-level ` | Logging level for `logging/setLevel` (e.g. `debug`, `info`). | -| `--metadata ` | General metadata (key=value); applied to all methods. | -| `--tool-metadata ` | Tool-specific metadata for `tools/call`. | +| `--metadata ` | General `_meta` entries (key=value); applied to all methods. The value is JSON-parsed when it parses, so `trace={"id":"abc"}` sends a real object and `n=3` a number; anything that is not valid JSON is sent as the literal string. | +| `--tool-metadata ` | Tool-specific `_meta` entries for `tools/call`. Same JSON-parsed value handling as `--metadata`. | | `--connect-timeout ` | Connection timeout in ms. Defaults to `15000` for ad-hoc `--server-url`/target runs (so a black-holed host fails fast) and to the file-level timeout for `--catalog`/`--config` runs. `0` disables the timeout. | | `--app-info` | Probe a tool's MCP App UI metadata without invoking it. With `--method tools/call --tool-name `: prints one JSON line (`hasApp`, `resourceUri`, `csp`, `permissions`, `domain`, …) and exits `0` if the tool has an app or `2` (`no_app`) if not. With `--method tools/list`: emits NDJSON — one app-info line per tool over a single connection. | | `--format ` | Output format. `text` (default) pretty-prints the result. `json` emits a single JSON object on stdout (`{ "result": … }`, plus `{ "appInfo": … }` as a sibling key for App tools) with no banners, so the whole output pipes cleanly into `jq`. | | `--relogin` | Delete stored OAuth for this server URL from the shared store before connect; interactive login still only runs if the server requires auth. Requires an HTTP/SSE URL (rejected for stdio). Conflicts with `--stored-auth-only` / `--use-stored-auth` / `--wait-for-auth` / catalog short-circuits. | | `--stored-auth-only` | **CI / non-interactive safe:** never start interactive OAuth / step-up (and never auto-open a browser); use the shared store if present, otherwise fail immediately with `auth_required`. Prefer this over a bare pipe/CI run that would otherwise attempt interactive login. | -`servers/show` redacts secret-bearing fields (`env` values, sensitive headers / `settings.metadata` keys, `requestInit` / `eventSourceInit` headers, `oauthClientSecret`). It does **not** scrub credentials embedded in a server `url` (userinfo or query tokens) or in stdio `args` — treat `detail` / raw URL fields as potentially sensitive before pasting into issues. +`servers/show` redacts secret-bearing fields (`env` values, sensitive headers, sensitive `settings.metadata` keys whose whole value is replaced whether or not it is structured, `requestInit` / `eventSourceInit` headers, `oauthClientSecret`). It does **not** scrub credentials embedded in a server `url` (userinfo or query tokens) or in stdio `args` — treat `detail` / raw URL fields as potentially sensitive before pasting into issues. #### App probing (`--app-info`) and machine-readable output (`--format json`) diff --git a/clients/cli/__tests__/helpers/oauth-test-fakes.ts b/clients/cli/__tests__/helpers/oauth-test-fakes.ts index b53e1570..8c5e91f9 100644 --- a/clients/cli/__tests__/helpers/oauth-test-fakes.ts +++ b/clients/cli/__tests__/helpers/oauth-test-fakes.ts @@ -63,7 +63,7 @@ export function makeFakeServerSettings( ): InspectorServerSettings { return { headers: [], - metadata: [], + metadata: {}, env: [], connectionTimeout: 0, requestTimeout: 0, diff --git a/clients/cli/__tests__/metadata.test.ts b/clients/cli/__tests__/metadata.test.ts index b5867d50..a4cb05fc 100644 --- a/clients/cli/__tests__/metadata.test.ts +++ b/clients/cli/__tests__/metadata.test.ts @@ -373,16 +373,17 @@ describe("Metadata Tests", () => { expectCliSuccess(result); - // Validate metadata values are sent as strings + // A JSON-parseable value goes out as that JSON type, not as its + // string spelling (#1910) — `_meta` takes any JSON. const recordedRequests = server.getRecordedRequests(); const toolsListRequest = recordedRequests.find( (r) => r.method === "tools/list", ); expect(toolsListRequest).toBeDefined(); expect(toolsListRequest?.metadata).toEqual({ - integer_value: "42", - decimal_value: "3.14159", - negative_value: "-10", + integer_value: 42, + decimal_value: 3.14159, + negative_value: -10, }); } finally { await server.stop(); @@ -428,7 +429,7 @@ describe("Metadata Tests", () => { } }); - it("JSON.stringifies object/array metadata (not String → [object Object])", async () => { + it("sends object/array/boolean metadata as real JSON, not stringified (#1910)", async () => { const server = createTestServerHttp({ serverInfo: createTestServerInfo(), tools: [createEchoTool()], @@ -456,9 +457,9 @@ describe("Metadata Tests", () => { (r) => r.method === "tools/list", ); expect(toolsListRequest?.metadata).toEqual({ - nested: '{"key":"value"}', - list: "[1,2,3]", - flag: "true", + nested: { key: "value" }, + list: [1, 2, 3], + flag: true, }); } finally { await server.stop(); @@ -819,7 +820,7 @@ describe("Metadata Tests", () => { ); expect(toolsListRequest).toBeDefined(); expect(toolsListRequest?.metadata).toEqual({ - integration_test: "true", + integration_test: true, test_phase: "all_methods", }); } finally { @@ -866,8 +867,8 @@ describe("Metadata Tests", () => { ); expect(toolCallRequest).toBeDefined(); expect(toolCallRequest?.metadata).toEqual({ - session_id: "12345", - user_id: "67890", + session_id: 12345, + user_id: 67890, timestamp: "2024-01-01T00:00:00Z", request_id: "req-abc-123", tool_session: "session-xyz-789", @@ -919,8 +920,8 @@ describe("Metadata Tests", () => { expect(toolCallRequest).toBeDefined(); expect(toolCallRequest?.metadata).toEqual({ valid_key: "valid_value", - numeric_key: "123", - boolean_key: "true", + numeric_key: 123, + boolean_key: true, json_key: '\'{"test":"value"}\'', // Single quotes are preserved special_key: "!@#$%^&*()", unicode_key: "🚀🎉✨", diff --git a/clients/cli/__tests__/method-types.test.ts b/clients/cli/__tests__/method-types.test.ts index 42c71025..85230043 100644 --- a/clients/cli/__tests__/method-types.test.ts +++ b/clients/cli/__tests__/method-types.test.ts @@ -1,7 +1,6 @@ import { describe, it, expect } from "vitest"; import { isOneShotMethod, - metaValueToString, ONE_SHOT_METHODS, SESSION_RPC_METHODS, } from "../src/handlers/method-types.js"; @@ -26,11 +25,3 @@ describe("ONE_SHOT_METHODS", () => { expect(ONE_SHOT_METHODS).not.toContain("logging/tail"); }); }); - -describe("metaValueToString", () => { - it("passes strings through and JSON-encodes structured values", () => { - expect(metaValueToString("plain")).toBe("plain"); - expect(metaValueToString({ a: 1 })).toBe('{"a":1}'); - expect(metaValueToString([1, 2])).toBe("[1,2]"); - }); -}); diff --git a/clients/cli/__tests__/programmatic-ergonomics.test.ts b/clients/cli/__tests__/programmatic-ergonomics.test.ts index 1c6fdee7..88f42f10 100644 --- a/clients/cli/__tests__/programmatic-ergonomics.test.ts +++ b/clients/cli/__tests__/programmatic-ergonomics.test.ts @@ -12,7 +12,7 @@ import { describe("withConnectTimeout", () => { const baseSettings: InspectorServerSettings = { headers: [{ key: "X-Test", value: "1" }], - metadata: [], + metadata: {}, env: [], connectionTimeout: 42, requestTimeout: 0, diff --git a/clients/cli/__tests__/servers-list.test.ts b/clients/cli/__tests__/servers-list.test.ts index 8ac7b599..5772ab3e 100644 --- a/clients/cli/__tests__/servers-list.test.ts +++ b/clients/cli/__tests__/servers-list.test.ts @@ -219,10 +219,14 @@ describe("showServerEntry / servers/show", () => { { key: "Authorization", value: "Bearer x" }, { key: "X-Custom", value: "ok" }, ], - metadata: [ - { key: "Authorization", value: "Bearer meta" }, - { key: "X-Custom", value: "ok" }, - ], + metadata: { + Authorization: "Bearer meta", + "X-Custom": "ok", + // A structured value under a sensitive key: the redaction must replace + // the whole value, not walk into it (#1910). + "X-Api-Key": { primary: "sk-live-1", fallback: "sk-live-2" }, + nested: { keep: true }, + }, env: [ { key: "TOKEN", value: "secret" }, { key: "", value: "still-secret" }, @@ -242,10 +246,13 @@ describe("showServerEntry / servers/show", () => { { key: "Authorization", value: "[redacted]" }, { key: "X-Custom", value: "ok" }, ]); - expect(sanitized.metadata).toEqual([ - { key: "Authorization", value: "[redacted]" }, - { key: "X-Custom", value: "ok" }, - ]); + expect(sanitized.metadata).toEqual({ + Authorization: "[redacted]", + "X-Custom": "ok", + "X-Api-Key": "[redacted]", + // A structured value under a non-sensitive key survives intact. + nested: { keep: true }, + }); expect(sanitized.env).toEqual([ { key: "TOKEN", value: "[redacted]" }, { key: "", value: "[redacted]" }, diff --git a/clients/cli/src/cli.ts b/clients/cli/src/cli.ts index f4d418ae..35ca3dd9 100644 --- a/clients/cli/src/cli.ts +++ b/clients/cli/src/cli.ts @@ -33,7 +33,6 @@ import { consumeMethodOutcome } from "./handlers/consume-outcome.js"; import { runMethod } from "./handlers/run-method.js"; import { isOneShotMethod, - metaValueToString, ONE_SHOT_METHODS, type MethodArgs, } from "./handlers/method-types.js"; @@ -999,22 +998,12 @@ async function parseArgs(argv?: string[]): Promise { promptName: options.promptName, promptArgs: options.promptArgs, logLevel: options.logLevel, - metadata: options.metadata - ? Object.fromEntries( - Object.entries(options.metadata).map(([key, value]) => [ - key, - metaValueToString(value), - ]), - ) - : undefined, - toolMeta: options.toolMetadata - ? Object.fromEntries( - Object.entries(options.toolMetadata).map(([key, value]) => [ - key, - metaValueToString(value), - ]), - ) - : undefined, + // `--metadata`/`--tool-metadata` values are parsed as JSON, and `_meta` + // takes any JSON — so they go through unflattened (#1910). They used to be + // squeezed through `metaValueToString`, which sent `{"a":1}` as the + // *string* `'{"a":1}'`. + metadata: options.metadata, + toolMeta: options.toolMetadata, appInfo: options.appInfo === true, format: options.format, }; diff --git a/clients/cli/src/handlers/collect-app-info.ts b/clients/cli/src/handlers/collect-app-info.ts index aa2df1fc..d46e3754 100644 --- a/clients/cli/src/handlers/collect-app-info.ts +++ b/clients/cli/src/handlers/collect-app-info.ts @@ -2,6 +2,7 @@ import { InspectorClient } from "@inspector/core/mcp/index.js"; import { extractAppInfo } from "@inspector/core/mcp/apps.js"; import type { AppInfo } from "@inspector/core/mcp/apps.js"; import type { CliAppInfo } from "./method-types.js"; +import type { RequestMetadata } from "@inspector/core/mcp/types.js"; /** * Build the CLI's app-info for a tool. Never throws — failures fold into @@ -10,7 +11,7 @@ import type { CliAppInfo } from "./method-types.js"; export async function collectAppInfo( client: Pick, tool: Parameters[0], - metadata: Record | undefined, + metadata: RequestMetadata | undefined, ): Promise { let base: AppInfo; try { diff --git a/clients/cli/src/handlers/connect-timeout.ts b/clients/cli/src/handlers/connect-timeout.ts index 7d5a7b2c..8bd8cf65 100644 --- a/clients/cli/src/handlers/connect-timeout.ts +++ b/clients/cli/src/handlers/connect-timeout.ts @@ -22,7 +22,7 @@ export function withConnectTimeout( if (settings) return { ...settings, connectionTimeout }; return { headers: [], - metadata: [], + metadata: {}, env: [], connectionTimeout, requestTimeout: 0, diff --git a/clients/cli/src/handlers/method-types.ts b/clients/cli/src/handlers/method-types.ts index b0505425..424b941b 100644 --- a/clients/cli/src/handlers/method-types.ts +++ b/clients/cli/src/handlers/method-types.ts @@ -1,4 +1,5 @@ import type { JsonValue } from "@inspector/core/mcp/index.js"; +import type { RequestMetadata } from "@inspector/core/mcp/types.js"; import type { AppInfo } from "@inspector/core/mcp/apps.js"; import type { LoggingLevel } from "@modelcontextprotocol/client"; import type { OutputFormat } from "./format-output.js"; @@ -20,8 +21,8 @@ export type MethodArgs = { logLevel?: LoggingLevel; toolName?: string; toolArg?: Record; - toolMeta?: Record; - metadata?: Record; + toolMeta?: RequestMetadata; + metadata?: RequestMetadata; appInfo?: boolean; format?: OutputFormat; /** Task id for tasks/get, tasks/cancel, tasks/result. */ @@ -110,13 +111,3 @@ export type OneShotMethod = (typeof ONE_SHOT_METHODS)[number]; export function isOneShotMethod(method: string): method is OneShotMethod { return (ONE_SHOT_METHODS as readonly string[]).includes(method); } - -/** - * Encode a metadata / tool-metadata value for MethodArgs (string map). - * Strings pass through; objects/arrays use JSON.stringify so structure is kept - * (avoids String(obj) → "[object Object]"). - */ -export function metaValueToString(value: JsonValue): string { - if (typeof value === "string") return value; - return JSON.stringify(value); -} diff --git a/clients/cli/src/handlers/servers-list.ts b/clients/cli/src/handlers/servers-list.ts index 78bd896f..d0247851 100644 --- a/clients/cli/src/handlers/servers-list.ts +++ b/clients/cli/src/handlers/servers-list.ts @@ -157,10 +157,15 @@ export function sanitizeServerSettings( key: h.key, value: isSensitiveHeader(h.key) ? REDACTED : h.value, })), - metadata: (settings.metadata ?? []).map((m) => ({ - key: m.key, - value: isSensitiveHeader(m.key) ? REDACTED : m.value, - })), + // Metadata is a JSON object whose values may be any JSON (#1910), so the + // redaction replaces the whole value rather than a string — a sensitive + // key holding an object must not leak through its members. + metadata: Object.fromEntries( + Object.entries(settings.metadata ?? {}).map(([key, value]) => [ + key, + isSensitiveHeader(key) ? REDACTED : value, + ]), + ), env: (settings.env ?? []).map((e) => ({ key: e.key, value: REDACTED, diff --git a/clients/tui/__tests__/App.test.tsx b/clients/tui/__tests__/App.test.tsx index a66fe603..3e749dff 100644 --- a/clients/tui/__tests__/App.test.tsx +++ b/clients/tui/__tests__/App.test.tsx @@ -340,7 +340,7 @@ function oneEmaHttp(): Record { config: { type: "streamable-http", url: "http://localhost:8080/mcp" }, settings: { requestTimeout: 0, - metadata: [], + metadata: {}, headers: [], env: [], roots: [], diff --git a/clients/tui/__tests__/helpers/oauth-test-fakes.ts b/clients/tui/__tests__/helpers/oauth-test-fakes.ts index efff339e..5e6e0fae 100644 --- a/clients/tui/__tests__/helpers/oauth-test-fakes.ts +++ b/clients/tui/__tests__/helpers/oauth-test-fakes.ts @@ -19,7 +19,7 @@ export function makeFakeServerSettings( ): InspectorServerSettings { return { headers: [], - metadata: [], + metadata: {}, env: [], connectionTimeout: 0, requestTimeout: 0, diff --git a/clients/tui/src/App.tsx b/clients/tui/src/App.tsx index 240bbd82..df88e3a9 100644 --- a/clients/tui/src/App.tsx +++ b/clients/tui/src/App.tsx @@ -291,13 +291,9 @@ function App({ transport: createTransportNode, logger: getTuiLogger(), }; - const defaultMetadata = savedSettings?.metadata - ? Object.fromEntries( - savedSettings.metadata - .filter((m) => m.key.trim() !== "") - .map((m) => [m.key, m.value]), - ) - : undefined; + // Per-server default `_meta` is already a JSON object (#1910) — no + // pair-array flattening left to do; `{}` means "no defaults". + const defaultMetadata = savedSettings?.metadata; const clientAuthOptions = buildRunnerClientAuthOptions( clientConfig, savedSettings, diff --git a/clients/web/src/App.test.tsx b/clients/web/src/App.test.tsx index fdf01413..e5056190 100644 --- a/clients/web/src/App.test.tsx +++ b/clients/web/src/App.test.tsx @@ -1635,7 +1635,7 @@ const settingsWithRoots = ( ): InspectorServerSettings => ({ headers: [], env: [], - metadata: [], + metadata: {}, connectionTimeout: 0, requestTimeout: 0, taskTtl: 60000, diff --git a/clients/web/src/App.tsx b/clients/web/src/App.tsx index 67f7d0ff..1be3d046 100644 --- a/clients/web/src/App.tsx +++ b/clients/web/src/App.tsx @@ -384,7 +384,7 @@ async function replayProtocolRequest( const EMPTY_SETTINGS: InspectorServerSettings = { headers: [], env: [], - metadata: [], + metadata: {}, connectionTimeout: 0, requestTimeout: 0, taskTtl: DEFAULT_TASK_TTL_MS, @@ -2466,13 +2466,9 @@ function App() { const activeCimdUrl = getActiveCimdClientMetadataUrl(clientConfig); // Flatten the persisted settings into the InspectorClient options shape. // Empty / zero values stay unset so the SDK defaults apply. - const defaultMetadata = savedSettings?.metadata - ? Object.fromEntries( - savedSettings.metadata - .filter((m) => m.key.trim() !== "") - .map((m) => [m.key, m.value]), - ) - : undefined; + // Per-server default `_meta` is already a JSON object (#1910) — no + // pair-array flattening left to do; `{}` means "no defaults". + const defaultMetadata = savedSettings?.metadata; const serverAuthorizationParams = savedSettings ? oauthAuthorizationParamsFromSettings(savedSettings) : undefined; diff --git a/clients/web/src/components/elements/JsonObjectInput/JsonObjectInput.stories.tsx b/clients/web/src/components/elements/JsonObjectInput/JsonObjectInput.stories.tsx new file mode 100644 index 00000000..51405384 --- /dev/null +++ b/clients/web/src/components/elements/JsonObjectInput/JsonObjectInput.stories.tsx @@ -0,0 +1,92 @@ +import { useState } from "react"; +import type { Meta, StoryObj } from "@storybook/react-vite"; +import { expect, userEvent, within } from "storybook/test"; +import type { JsonObject } from "@inspector/core/json/jsonUtils.js"; +import { JsonObjectInput } from "./JsonObjectInput"; + +/** + * The component is controlled, so every story drives it through a small + * stateful wrapper — otherwise the box would not accept a keystroke and the + * play functions could not exercise the draft behavior at all. + */ +function ControlledJsonObjectInput({ initial }: { initial: JsonObject }) { + const [value, setValue] = useState(initial); + return ( + + ); +} + +const meta: Meta = { + title: "Elements/JsonObjectInput", + component: ControlledJsonObjectInput, +}; + +export default meta; +type Story = StoryObj; + +/** A structured payload renders as formatted JSON, nesting intact. */ +export const Populated: Story = { + args: { + initial: { + tenant: "acme", + trace: { id: "abc123", sampled: true }, + features: ["apps", "tasks"], + }, + }, + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + const box = await canvas.findByLabelText("Request metadata JSON"); + await expect(JSON.parse((box as HTMLTextAreaElement).value)).toEqual({ + tenant: "acme", + trace: { id: "abc123", sampled: true }, + features: ["apps", "tasks"], + }); + }, +}; + +/** An empty payload opens as `{}` and carries no error. */ +export const Empty: Story = { + args: { initial: {} }, + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + const box = await canvas.findByLabelText("Request metadata JSON"); + await expect(box).toHaveValue("{}"); + await expect(canvas.queryByText(/Not valid JSON/)).toBeNull(); + }, +}; + +/** + * Half-typed text is displayed as typed and flagged, rather than being + * re-escaped back into the box each keystroke (the #1928 failure this draft + * split exists to avoid). + */ +export const InvalidDraft: Story = { + args: { initial: {} }, + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + const box = await canvas.findByLabelText("Request metadata JSON"); + await userEvent.clear(box); + await userEvent.type(box, '{{"a":'); + await expect(box).toHaveValue('{"a":'); + await expect(await canvas.findByText(/Not valid JSON/)).toBeVisible(); + }, +}; + +/** Valid JSON that is not an object is rejected with its own message. */ +export const NotAnObject: Story = { + args: { initial: {} }, + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + const box = await canvas.findByLabelText("Request metadata JSON"); + await userEvent.clear(box); + await userEvent.type(box, "[[1,2]"); + await expect( + await canvas.findByText(/Must be a JSON object/), + ).toBeVisible(); + }, +}; diff --git a/clients/web/src/components/elements/JsonObjectInput/JsonObjectInput.test.tsx b/clients/web/src/components/elements/JsonObjectInput/JsonObjectInput.test.tsx new file mode 100644 index 00000000..37d02dfb --- /dev/null +++ b/clients/web/src/components/elements/JsonObjectInput/JsonObjectInput.test.tsx @@ -0,0 +1,172 @@ +import { useState } from "react"; +import { describe, it, expect, vi } from "vitest"; +import { screen } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import type { JsonObject } from "@inspector/core/json/jsonUtils.js"; +import { renderWithMantine } from "../../../test/renderWithMantine"; +import { JsonObjectInput, parseObjectDraft } from "./JsonObjectInput"; + +const LABEL = "Payload JSON"; + +/** Controlled harness: the component never owns the value, only the draft. */ +function Harness({ + initial, + onChange, +}: { + initial: JsonObject; + onChange?: (v: JsonObject) => void; +}) { + const [value, setValue] = useState(initial); + return ( + <> + { + setValue(next); + onChange?.(next); + }} + /> + {JSON.stringify(value)} + + ); +} + +function box() { + return screen.getByLabelText(LABEL) as HTMLTextAreaElement; +} + +describe("parseObjectDraft", () => { + it("reads empty text as the empty object rather than an error", () => { + expect(parseObjectDraft("")).toEqual({ ok: true, value: {} }); + expect(parseObjectDraft(" \n ")).toEqual({ ok: true, value: {} }); + }); + + it("accepts a JSON object with values of any JSON type", () => { + const result = parseObjectDraft( + '{"s":"a","n":1,"b":true,"z":null,"arr":[1,{"k":2}],"o":{"deep":{"er":1}}}', + ); + expect(result).toEqual({ + ok: true, + value: { + s: "a", + n: 1, + b: true, + z: null, + arr: [1, { k: 2 }], + o: { deep: { er: 1 } }, + }, + }); + }); + + it("rejects text that is not JSON", () => { + expect(parseObjectDraft('{"a":')).toEqual({ + ok: false, + error: "Not valid JSON — changes are not applied", + }); + }); + + it.each([ + ["an array", "[1,2]"], + ["a string", '"hello"'], + ["a number", "42"], + ["null", "null"], + ])("rejects valid JSON that is not an object: %s", (_label, text) => { + const result = parseObjectDraft(text); + expect(result.ok).toBe(false); + expect(result.ok === false && result.error).toMatch( + /Must be a JSON object/, + ); + }); +}); + +describe("JsonObjectInput", () => { + it("opens with the value serialized two-space, nesting intact", () => { + renderWithMantine(); + expect(box().value).toBe( + '{\n "a": {\n "b": [\n 1,\n 2\n ]\n }\n}', + ); + }); + + it("emits the parsed object — a structured value stays structured", async () => { + const user = userEvent.setup(); + const onChange = vi.fn(); + renderWithMantine(); + await user.clear(box()); + await user.type(box(), '{{"trace":{{"id":"x"},"n":3}'); + expect(onChange).toHaveBeenLastCalledWith({ trace: { id: "x" }, n: 3 }); + }); + + it("shows the text as typed while it is invalid and does not emit", async () => { + const user = userEvent.setup(); + const onChange = vi.fn(); + renderWithMantine(); + // Append a stray character rather than clearing: clearing is itself a + // valid edit (it means `{}`) and would emit, which is not what is under + // test here. + await user.click(box()); + await user.keyboard("{End}x"); + // Displayed verbatim — not re-escaped back through JSON.stringify. + expect(box().value.endsWith("}x")).toBe(true); + expect( + screen.getByText("Not valid JSON — changes are not applied"), + ).toBeInTheDocument(); + // Nothing emitted, so the caller still holds the last valid object. + expect(onChange).not.toHaveBeenCalled(); + expect(screen.getByTestId("emitted").textContent).toBe('{"keep":"me"}'); + }); + + it("flags valid JSON that is not an object without emitting", async () => { + const user = userEvent.setup(); + const onChange = vi.fn(); + renderWithMantine(); + await user.clear(box()); + // The clear is a legitimate edit to `{}`; only what follows is under test. + expect(onChange).toHaveBeenLastCalledWith({}); + onChange.mockClear(); + await user.type(box(), "[[1]"); + expect(screen.getByText(/Must be a JSON object/)).toBeInTheDocument(); + expect(onChange).not.toHaveBeenCalled(); + }); + + it("treats a cleared box as the empty object", async () => { + const user = userEvent.setup(); + const onChange = vi.fn(); + renderWithMantine(); + await user.clear(box()); + expect(onChange).toHaveBeenLastCalledWith({}); + expect(screen.queryByText(/Not valid JSON/)).toBeNull(); + }); + + it("re-syncs when the parent's value diverges from the draft", async () => { + // An external reset (switching servers, a reloaded catalog) must reach the + // box; an in-progress edit must not be overwritten by the value it emitted. + function Externally() { + const [value, setValue] = useState({ a: 1 }); + return ( + <> + + + + ); + } + const user = userEvent.setup(); + renderWithMantine(); + await user.click(screen.getByRole("button", { name: "reset" })); + expect(JSON.parse(box().value)).toEqual({ b: 2 }); + }); + + it("leaves an in-progress edit alone when the parent echoes it back", async () => { + const user = userEvent.setup(); + renderWithMantine(); + await user.clear(box()); + // Typed without the closing brace auto-pair: the text is what the user has + // so far, and the echo of the parsed value must not reformat it mid-edit. + await user.type(box(), '{{"a":1}'); + expect(box().value).toBe('{"a":1}'); + }); +}); diff --git a/clients/web/src/components/elements/JsonObjectInput/JsonObjectInput.tsx b/clients/web/src/components/elements/JsonObjectInput/JsonObjectInput.tsx new file mode 100644 index 00000000..93df2516 --- /dev/null +++ b/clients/web/src/components/elements/JsonObjectInput/JsonObjectInput.tsx @@ -0,0 +1,113 @@ +import { useState } from "react"; +import { JsonInput } from "@mantine/core"; +import type { JsonObject } from "@inspector/core/json/jsonUtils.js"; +import { useValueChange } from "../../../hooks/useValueChange"; + +const ObjectJsonInput = JsonInput.withProps({ + formatOnBlur: true, + autosize: true, + minRows: 4, + spellCheck: false, +}); + +/** Two-space canonical form — the same shape the catalog file is written in. */ +function serialize(value: JsonObject): string { + return JSON.stringify(value, null, 2); +} + +/** A plain JSON object, i.e. not an array and not a scalar. */ +function isJsonObject(value: unknown): value is JsonObject { + return value !== null && typeof value === "object" && !Array.isArray(value); +} + +/** + * What a draft's text currently means: the object to emit, or why it can't be. + * + * Empty text is the object `{}` rather than an error — clearing the box is how + * "send no metadata" is spelled, and an empty editor showing a red error would + * read as broken. + */ +type DraftState = + | { ok: true; value: JsonObject } + | { ok: false; error: string }; + +export function parseObjectDraft(text: string): DraftState { + if (text.trim() === "") return { ok: true, value: {} }; + let parsed: unknown; + try { + parsed = JSON.parse(text); + } catch { + return { ok: false, error: "Not valid JSON — changes are not applied" }; + } + if (!isJsonObject(parsed)) { + return { + ok: false, + error: "Must be a JSON object (`{ … }`) — changes are not applied", + }; + } + return { ok: true, value: parsed }; +} + +export interface JsonObjectInputProps { + value: JsonObject; + onChange: (value: JsonObject) => void; + label?: string; + description?: string; + placeholder?: string; + "aria-label"?: string; + "data-testid"?: string; +} + +/** + * A JSON-object editor: the user types JSON, the parent receives the parsed + * object. + * + * This is the editor for payloads whose *values* may be any JSON, which is why + * it exists beside the key/value row editors used for headers and env. `_meta` + * is the case that forced it (#1910) — nothing in the MCP spec restricts a + * `_meta` value to a string, so a `{ key, value }` row editor cannot express + * what the protocol allows. + * + * The text the user is typing is the source of truth for what is *displayed*; + * the parent only ever sees a parsed object. That split is what makes the box + * typeable: driving it off the parent's value and stringifying on every render + * re-escapes half-typed text once per keystroke, which is the #1928 symptom. + * `SchemaJsonField` in `SchemaForm.tsx` carries the same split for the same + * reason — see the long comment there. + * + * While the text is invalid the parent is **not** told, so the last valid + * object stands. The alternative — emitting `{}` — would silently discard a + * user's configured metadata the moment they typed a stray character, and this + * form has no Save button to gate instead: `onChange` writes straight through. + * The state is not silent, though; the field says so on itself via `error`. + */ +export function JsonObjectInput({ + value, + onChange, + ...inputProps +}: JsonObjectInputProps) { + const [draft, setDraft] = useState(() => serialize(value)); + + // Re-sync only when the parent's value genuinely diverges from what the draft + // parses to, so an external reset (switching servers, a reloaded catalog) + // reaches the box while an in-progress edit is left alone. Compared through + // canonical JSON because the parent hands back a fresh object each time. + const parsed = parseObjectDraft(draft); + const draftJson = parsed.ok ? serialize(parsed.value) : null; + useValueChange(serialize(value), (next) => { + if (next !== draftJson) setDraft(next); + }); + + return ( + { + setDraft(text); + const result = parseObjectDraft(text); + if (result.ok) onChange(result.value); + }} + /> + ); +} diff --git a/clients/web/src/components/groups/ServerSettingsForm/ServerSettingsForm.stories.tsx b/clients/web/src/components/groups/ServerSettingsForm/ServerSettingsForm.stories.tsx index 19c54849..c6e6fb69 100644 --- a/clients/web/src/components/groups/ServerSettingsForm/ServerSettingsForm.stories.tsx +++ b/clients/web/src/components/groups/ServerSettingsForm/ServerSettingsForm.stories.tsx @@ -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 = { 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, diff --git a/clients/web/src/components/groups/ServerSettingsForm/ServerSettingsForm.test.tsx b/clients/web/src/components/groups/ServerSettingsForm/ServerSettingsForm.test.tsx index 7ea0e396..cd5e168a 100644 --- a/clients/web/src/components/groups/ServerSettingsForm/ServerSettingsForm.test.tsx +++ b/clients/web/src/components/groups/ServerSettingsForm/ServerSettingsForm.test.tsx @@ -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( { 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( - , - ); - 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( , ); - 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( + , ); - 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( - , - ); - // 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 ?`). diff --git a/clients/web/src/components/groups/ServerSettingsForm/ServerSettingsForm.tsx b/clients/web/src/components/groups/ServerSettingsForm/ServerSettingsForm.tsx index 18f0c522..36179c9b 100644 --- a/clients/web/src/components/groups/ServerSettingsForm/ServerSettingsForm.tsx +++ b/clients/web/src/components/groups/ServerSettingsForm/ServerSettingsForm.tsx @@ -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({ Request Metadata - - - Metadata sent with every MCP request (included in _meta field) - - + Add Metadata - - {settings.metadata.length === 0 ? ( - No request metadata configured - ) : ( - - )} + + 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{" "} + {"{}"} to send none. + + diff --git a/clients/web/src/components/groups/ServerSettingsModal/ServerSettingsModal.stories.tsx b/clients/web/src/components/groups/ServerSettingsModal/ServerSettingsModal.stories.tsx index e6cb9e14..453d41cb 100644 --- a/clients/web/src/components/groups/ServerSettingsModal/ServerSettingsModal.stories.tsx +++ b/clients/web/src/components/groups/ServerSettingsModal/ServerSettingsModal.stories.tsx @@ -14,7 +14,7 @@ const initialSettings: InspectorServerSettings = { { key: "X-Request-Id", value: "req-456" }, ], env: [], - metadata: [{ key: "userId", value: "user-789" }], + metadata: { userId: "user-789", client: { name: "inspector" } }, connectionTimeout: 30000, requestTimeout: 60000, taskTtl: 60000, @@ -74,7 +74,7 @@ export const EmptySettings: Story = { settings: { headers: [], env: [], - metadata: [], + metadata: {}, connectionTimeout: 30000, requestTimeout: 60000, taskTtl: 60000, @@ -96,7 +96,7 @@ export const StdioServer: Story = { { key: "API_KEY", value: "abc-123" }, { key: "LOG_LEVEL", value: "debug" }, ], - metadata: [], + metadata: {}, connectionTimeout: 0, requestTimeout: 0, taskTtl: 60000, diff --git a/clients/web/src/components/groups/ServerSettingsModal/ServerSettingsModal.test.tsx b/clients/web/src/components/groups/ServerSettingsModal/ServerSettingsModal.test.tsx index ba0303d4..c42fba16 100644 --- a/clients/web/src/components/groups/ServerSettingsModal/ServerSettingsModal.test.tsx +++ b/clients/web/src/components/groups/ServerSettingsModal/ServerSettingsModal.test.tsx @@ -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( - , - ); - 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( - , - ); - 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( - , - ); - 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( { />, ); 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 () => { diff --git a/clients/web/src/components/groups/ServerSettingsModal/ServerSettingsModal.tsx b/clients/web/src/components/groups/ServerSettingsModal/ServerSettingsModal.tsx index e2af62f5..fbdfa7ee 100644 --- a/clients/web/src/components/groups/ServerSettingsModal/ServerSettingsModal.tsx +++ b/clients/web/src/components/groups/ServerSettingsModal/ServerSettingsModal.tsx @@ -5,6 +5,7 @@ import type { InspectorServerSettings, ModernLogLevel, OAuthSettings, + RequestMetadata, ServerProtocolEra, ServerType, } from "@inspector/core/mcp/types.js"; @@ -155,24 +156,7 @@ export function ServerSettingsModal({ onSettingsChange({ ...settings, cwd: value }); } - function handleAddMetadata() { - onSettingsChange({ - ...settings, - metadata: [...settings.metadata, { key: "", value: "" }], - }); - } - - function handleRemoveMetadata(index: number) { - onSettingsChange({ - ...settings, - metadata: settings.metadata.filter((_, i) => i !== index), - }); - } - - function handleMetadataChange(index: number, key: string, value: string) { - const metadata = settings.metadata.map((m, i) => - i === index ? { key, value } : m, - ); + function handleMetadataChange(metadata: RequestMetadata) { onSettingsChange({ ...settings, metadata }); } @@ -303,8 +287,6 @@ export function ServerSettingsModal({ onRemoveEnv={handleRemoveEnv} onEnvChange={handleEnvChange} onCwdChange={handleCwdChange} - onAddMetadata={handleAddMetadata} - onRemoveMetadata={handleRemoveMetadata} onMetadataChange={handleMetadataChange} onTimeoutChange={handleTimeoutChange} onAutoRefreshChange={handleAutoRefreshChange} diff --git a/clients/web/src/test/core/client/runner.test.ts b/clients/web/src/test/core/client/runner.test.ts index 06dd0e1b..0c8660a4 100644 --- a/clients/web/src/test/core/client/runner.test.ts +++ b/clients/web/src/test/core/client/runner.test.ts @@ -76,7 +76,7 @@ describe("runner client auth options", () => { taskTtl: 60000, maxFetchRequests: 10, autoRefreshOnListChanged: false, - metadata: [], + metadata: {}, headers: [], env: [], roots: [], @@ -99,7 +99,7 @@ describe("runner client auth options", () => { taskTtl: 60000, maxFetchRequests: 10, autoRefreshOnListChanged: false, - metadata: [], + metadata: {}, headers: [], env: [], roots: [], @@ -116,7 +116,7 @@ describe("runner client auth options", () => { taskTtl: 60000, maxFetchRequests: 10, autoRefreshOnListChanged: false, - metadata: [], + metadata: {}, headers: [], env: [], roots: [], @@ -135,7 +135,7 @@ describe("runner client auth options", () => { taskTtl: 60000, maxFetchRequests: 10, autoRefreshOnListChanged: false, - metadata: [], + metadata: {}, headers: [], env: [], roots: [], @@ -156,7 +156,7 @@ describe("runner client auth options", () => { taskTtl: 60000, maxFetchRequests: 10, autoRefreshOnListChanged: false, - metadata: [], + metadata: {}, headers: [], env: [], roots: [], @@ -187,7 +187,7 @@ describe("runner client auth options", () => { taskTtl: 60000, maxFetchRequests: 10, autoRefreshOnListChanged: false, - metadata: [], + metadata: {}, headers: [], env: [], roots: [], diff --git a/clients/web/src/test/core/mcp/inspectorClient-peer-handler-timing.test.ts b/clients/web/src/test/core/mcp/inspectorClient-peer-handler-timing.test.ts index 84f019cc..cfc22003 100644 --- a/clients/web/src/test/core/mcp/inspectorClient-peer-handler-timing.test.ts +++ b/clients/web/src/test/core/mcp/inspectorClient-peer-handler-timing.test.ts @@ -854,7 +854,7 @@ describe("InspectorClient peer-handler timing (#1797)", () => { serverSettings: { headers: [], env: [], - metadata: [], + metadata: {}, connectionTimeout: 0, requestTimeout: 0, taskTtl: 0, diff --git a/clients/web/src/test/core/mcp/node/servers.test.ts b/clients/web/src/test/core/mcp/node/servers.test.ts index 05373af5..a54aaa8d 100644 --- a/clients/web/src/test/core/mcp/node/servers.test.ts +++ b/clients/web/src/test/core/mcp/node/servers.test.ts @@ -30,7 +30,7 @@ describe("headersToServerSettings", () => { expect(settings?.headers).toEqual([ { key: "Authorization", value: "Bearer t" }, ]); - expect(settings?.metadata).toEqual([]); + expect(settings?.metadata).toEqual({}); expect(settings?.roots).toEqual([]); }); }); diff --git a/clients/web/src/test/core/mcp/remote/remoteClientTransport.test.ts b/clients/web/src/test/core/mcp/remote/remoteClientTransport.test.ts index c0478851..c2ddcc2a 100644 --- a/clients/web/src/test/core/mcp/remote/remoteClientTransport.test.ts +++ b/clients/web/src/test/core/mcp/remote/remoteClientTransport.test.ts @@ -361,7 +361,7 @@ describe("RemoteClientTransport", () => { const settings = { headers: [{ key: "X-Tenant", value: "acme" }], env: [], - metadata: [], + metadata: {}, connectionTimeout: 0, requestTimeout: 0, taskTtl: 0, diff --git a/clients/web/src/test/core/mcp/serverList.test.ts b/clients/web/src/test/core/mcp/serverList.test.ts index 0492622d..13559751 100644 --- a/clients/web/src/test/core/mcp/serverList.test.ts +++ b/clients/web/src/test/core/mcp/serverList.test.ts @@ -11,6 +11,7 @@ import { mcpConfigToServerEntries, mergeSecretsIntoStored, normalizeServerType, + normalizeStoredMetadata, oauthAuthorizationParamsFromSettings, oauthEndpointOverridesFromSettings, serverEntriesToMcpConfig, @@ -240,7 +241,7 @@ describe("serverEntriesToMcpConfig", () => { }); it("round-trips a populated set of Inspector-extension fields (post-#1358 flat shape)", () => { - // Disk shape: top-level `headers` (Record), `metadata` (pair-array), + // Disk shape: top-level `headers` (Record), `metadata` (JSON object), // numeric timeouts, nested `oauth`. Round-trip must preserve the on- // disk shape byte-equivalent so a hand-edited file is stable. const original: MCPConfig = { @@ -249,7 +250,7 @@ describe("serverEntriesToMcpConfig", () => { type: "streamable-http", url: "https://x.test/mcp", headers: { Authorization: "Bearer xyz" }, - metadata: [{ key: "tenant", value: "acme" }], + metadata: { tenant: "acme", limits: { rps: 10 } }, connectionTimeout: 30000, requestTimeout: 60000, oauth: { @@ -581,7 +582,7 @@ describe("serverEntriesToMcpConfig", () => { headers: [{ key: "X-Tenant", value: "acme" }], // Non-stdio server → empty env mirror in memory (for the form) env: [], - metadata: [], + metadata: {}, connectionTimeout: 0, requestTimeout: 0, // Absent taskTtl on disk → product default in memory (for the form) @@ -663,7 +664,7 @@ describe("serverEntriesToMcpConfig", () => { { key: " ", value: "whitespace" }, ], env: [], - metadata: [], + metadata: {}, connectionTimeout: 0, requestTimeout: 0, taskTtl: 0, @@ -690,7 +691,7 @@ describe("serverEntriesToMcpConfig", () => { settings: { headers: [], env: [], - metadata: [], + metadata: {}, connectionTimeout: 0, requestTimeout: 0, taskTtl: 0, @@ -719,7 +720,7 @@ describe("serverEntriesToMcpConfig", () => { settings: { headers: [], env: [], - metadata: [], + metadata: {}, connectionTimeout: 0, requestTimeout: 0, taskTtl: 0, @@ -758,7 +759,7 @@ describe("serverEntriesToMcpConfig", () => { settings: { headers: [], env: [], - metadata: [], + metadata: {}, connectionTimeout: 0, requestTimeout: 0, taskTtl: 0, @@ -785,7 +786,7 @@ describe("serverEntriesToMcpConfig", () => { settings: { headers: [], env: [], - metadata: [], + metadata: {}, connectionTimeout: 0, requestTimeout: 0, taskTtl: 0, @@ -1163,7 +1164,7 @@ describe("enterpriseManaged oauth settings", () => { const stored = inspectorSettingsToStoredFields({ headers: [], env: [], - metadata: [], + metadata: {}, connectionTimeout: 0, requestTimeout: 0, taskTtl: 60000, @@ -1180,7 +1181,7 @@ describe("enterpriseManaged oauth settings", () => { const stored = inspectorSettingsToStoredFields({ headers: [], env: [], - metadata: [], + metadata: {}, connectionTimeout: 0, requestTimeout: 0, taskTtl: 60000, @@ -1196,7 +1197,7 @@ describe("oauthOnInsufficientScope (SEP-2350)", () => { const baseSettings = { headers: [], env: [], - metadata: [], + metadata: {}, connectionTimeout: 0, requestTimeout: 0, taskTtl: 60000, @@ -1374,6 +1375,109 @@ describe("oauthOnInsufficientScope (SEP-2350)", () => { }); }); +describe("normalizeStoredMetadata (#1910)", () => { + let warn: ReturnType; + beforeEach(() => { + warn = vi.spyOn(console, "warn").mockImplementation(() => {}); + }); + afterEach(() => { + warn.mockRestore(); + }); + + it("passes an object through, values of any JSON type intact", () => { + const meta = { + s: "a", + n: 1, + b: false, + z: null, + arr: [1, { k: 2 }], + o: { deep: true }, + }; + expect(normalizeStoredMetadata(meta)).toEqual(meta); + }); + + it("copies rather than aliasing the stored object", () => { + // The settings object is edited in place by the form; sharing the + // reference with the parsed catalog would mutate it behind the caller. + const meta = { a: 1 }; + const out = normalizeStoredMetadata(meta); + out.b = 2; + expect(meta).toEqual({ a: 1 }); + }); + + it("reads the pre-#1910 pair array so an existing mcp.json keeps working", () => { + expect( + normalizeStoredMetadata([ + { key: "tenant", value: "acme" }, + { key: "n", value: 3 }, + ]), + ).toEqual({ tenant: "acme", n: 3 }); + }); + + it("skips a blank-key legacy pair", () => { + expect( + normalizeStoredMetadata([ + { key: "", value: "orphan" }, + { key: " ", value: "orphan" }, + { key: "kept", value: "yes" }, + ]), + ).toEqual({ kept: "yes" }); + }); + + it("reads a legacy pair with no value as an empty string", () => { + expect(normalizeStoredMetadata([{ key: "k" }])).toEqual({ k: "" }); + }); + + it("drops a malformed legacy entry with a warning, keeping its siblings", () => { + expect( + normalizeStoredMetadata([ + "nope", + null, + { value: "no key" }, + { key: "kept", value: 1 }, + ]), + ).toEqual({ kept: 1 }); + expect(warn).toHaveBeenCalledTimes(3); + }); + + it.each([ + ["undefined", undefined], + ["null", null], + ])("reads %s as the empty object without warning", (_label, input) => { + expect(normalizeStoredMetadata(input)).toEqual({}); + expect(warn).not.toHaveBeenCalled(); + }); + + it.each([ + ["a string", "oops"], + ["a number", 7], + ["a boolean", true], + ])("drops %s with a warning", (_label, input) => { + expect(normalizeStoredMetadata(input)).toEqual({}); + expect(warn).toHaveBeenCalledTimes(1); + }); + + it("migrates a legacy file to the object shape on the next write", () => { + // Read side accepts the pair array; write side emits only the object, so + // one round-trip is the migration. + const entries = mcpConfigToServerEntries({ + mcpServers: { + legacy: { + type: "streamable-http", + url: "https://x.test/mcp", + metadata: [{ key: "tenant", value: "acme" }] as unknown as Record< + string, + never + >, + }, + }, + }); + expect(entries[0]?.settings?.metadata).toEqual({ tenant: "acme" }); + const round = serverEntriesToMcpConfig(entries); + expect(round.mcpServers.legacy?.metadata).toEqual({ tenant: "acme" }); + }); +}); + describe("envRecordToPairs / envPairsToRecord", () => { it("envRecordToPairs preserves key insertion order", () => { expect(envRecordToPairs({ B: "2", A: "1" })).toEqual([ @@ -1469,7 +1573,7 @@ describe("stdio env / cwd mirroring", () => { headers: [], env: [{ key: "API_KEY", value: "secret" }], cwd: "/srv/app", - metadata: [], + metadata: {}, connectionTimeout: 0, requestTimeout: 0, taskTtl: 60000, diff --git a/clients/web/src/test/core/mcp/state/managedPromptsState.test.ts b/clients/web/src/test/core/mcp/state/managedPromptsState.test.ts index 939af2ef..1d0717f9 100644 --- a/clients/web/src/test/core/mcp/state/managedPromptsState.test.ts +++ b/clients/web/src/test/core/mcp/state/managedPromptsState.test.ts @@ -13,7 +13,7 @@ function prompt(name: string): Prompt { const AUTO_REFRESH_SETTINGS: InspectorServerSettings = { headers: [], env: [], - metadata: [], + metadata: {}, connectionTimeout: 0, requestTimeout: 0, taskTtl: 60000, diff --git a/clients/web/src/test/core/mcp/state/managedResourceTemplatesState.test.ts b/clients/web/src/test/core/mcp/state/managedResourceTemplatesState.test.ts index be88cd5f..643b9f8a 100644 --- a/clients/web/src/test/core/mcp/state/managedResourceTemplatesState.test.ts +++ b/clients/web/src/test/core/mcp/state/managedResourceTemplatesState.test.ts @@ -13,7 +13,7 @@ function template(name: string): ResourceTemplate { const AUTO_REFRESH_SETTINGS: InspectorServerSettings = { headers: [], env: [], - metadata: [], + metadata: {}, connectionTimeout: 0, requestTimeout: 0, taskTtl: 60000, diff --git a/clients/web/src/test/core/mcp/state/managedResourcesState.test.ts b/clients/web/src/test/core/mcp/state/managedResourcesState.test.ts index 7944f3a4..15fb404a 100644 --- a/clients/web/src/test/core/mcp/state/managedResourcesState.test.ts +++ b/clients/web/src/test/core/mcp/state/managedResourcesState.test.ts @@ -13,7 +13,7 @@ function resource(uri: string): Resource { const AUTO_REFRESH_SETTINGS: InspectorServerSettings = { headers: [], env: [], - metadata: [], + metadata: {}, connectionTimeout: 0, requestTimeout: 0, taskTtl: 60000, diff --git a/clients/web/src/test/core/mcp/state/managedToolsState.test.ts b/clients/web/src/test/core/mcp/state/managedToolsState.test.ts index 8455dd74..f62118b6 100644 --- a/clients/web/src/test/core/mcp/state/managedToolsState.test.ts +++ b/clients/web/src/test/core/mcp/state/managedToolsState.test.ts @@ -13,7 +13,7 @@ function tool(name: string): Tool { const AUTO_REFRESH_SETTINGS: InspectorServerSettings = { headers: [], env: [], - metadata: [], + metadata: {}, connectionTimeout: 0, requestTimeout: 0, taskTtl: 60000, diff --git a/clients/web/src/test/core/mcp/state/pagedPromptsState.test.ts b/clients/web/src/test/core/mcp/state/pagedPromptsState.test.ts index b82828b8..715facf2 100644 --- a/clients/web/src/test/core/mcp/state/pagedPromptsState.test.ts +++ b/clients/web/src/test/core/mcp/state/pagedPromptsState.test.ts @@ -11,7 +11,7 @@ function prompt(name: string): Prompt { const PAGINATED_SETTINGS: InspectorServerSettings = { headers: [], env: [], - metadata: [], + metadata: {}, connectionTimeout: 0, requestTimeout: 0, taskTtl: 60000, diff --git a/clients/web/src/test/core/mcp/state/pagedResourcesState.test.ts b/clients/web/src/test/core/mcp/state/pagedResourcesState.test.ts index c8fee7e7..87c405f4 100644 --- a/clients/web/src/test/core/mcp/state/pagedResourcesState.test.ts +++ b/clients/web/src/test/core/mcp/state/pagedResourcesState.test.ts @@ -11,7 +11,7 @@ function resource(uri: string): Resource { const PAGINATED_SETTINGS: InspectorServerSettings = { headers: [], env: [], - metadata: [], + metadata: {}, connectionTimeout: 0, requestTimeout: 0, taskTtl: 60000, diff --git a/clients/web/src/test/core/mcp/state/pagedToolsState.test.ts b/clients/web/src/test/core/mcp/state/pagedToolsState.test.ts index b8ee12f5..463a304f 100644 --- a/clients/web/src/test/core/mcp/state/pagedToolsState.test.ts +++ b/clients/web/src/test/core/mcp/state/pagedToolsState.test.ts @@ -11,7 +11,7 @@ function tool(name: string): Tool { const PAGINATED_SETTINGS: InspectorServerSettings = { headers: [], env: [], - metadata: [], + metadata: {}, connectionTimeout: 0, requestTimeout: 0, taskTtl: 60000, diff --git a/clients/web/src/test/core/react/useServers.test.tsx b/clients/web/src/test/core/react/useServers.test.tsx index c0d5260c..15cb01fb 100644 --- a/clients/web/src/test/core/react/useServers.test.tsx +++ b/clients/web/src/test/core/react/useServers.test.tsx @@ -412,7 +412,7 @@ describe("useServers", () => { await result.current.updateServerSettings("alpha", { headers: [{ key: "X-Tenant", value: "acme" }], env: [], - metadata: [{ key: "trace", value: "abc" }], + metadata: { trace: "abc" }, connectionTimeout: 5000, requestTimeout: 30000, taskTtl: 30000, @@ -425,7 +425,7 @@ describe("useServers", () => { expect(result.current.servers[0]?.settings).toEqual({ headers: [{ key: "X-Tenant", value: "acme" }], env: [], - metadata: [{ key: "trace", value: "abc" }], + metadata: { trace: "abc" }, connectionTimeout: 5000, requestTimeout: 30000, taskTtl: 30000, @@ -444,7 +444,7 @@ describe("useServers", () => { expect(stored.url).toBe("https://x.test/mcp"); expect(stored).not.toHaveProperty("settings"); expect(stored.headers).toEqual({ "X-Tenant": "acme" }); - expect(stored.metadata).toEqual([{ key: "trace", value: "abc" }]); + expect(stored.metadata).toEqual({ trace: "abc" }); expect(stored.connectionTimeout).toBe(5000); expect(stored.requestTimeout).toBe(30000); expect(stored.taskTtl).toBe(30000); @@ -461,7 +461,7 @@ describe("useServers", () => { await result.current.updateServerSettings("nonexistent", { headers: [], env: [], - metadata: [], + metadata: {}, connectionTimeout: 0, requestTimeout: 0, taskTtl: 0, @@ -511,7 +511,7 @@ describe("useServers", () => { expect(result.current.servers[0]?.settings).toEqual({ headers: [{ key: "X-Keep", value: "yes" }], env: [], - metadata: [], + metadata: {}, connectionTimeout: 0, requestTimeout: 0, // Absent taskTtl on disk reads back as the product default for the form. diff --git a/clients/web/src/test/integration/mcp/inspectorClient-coverage-backfill.test.ts b/clients/web/src/test/integration/mcp/inspectorClient-coverage-backfill.test.ts index d873453c..d0067baf 100644 --- a/clients/web/src/test/integration/mcp/inspectorClient-coverage-backfill.test.ts +++ b/clients/web/src/test/integration/mcp/inspectorClient-coverage-backfill.test.ts @@ -389,7 +389,7 @@ describe("InspectorClient coverage backfill", () => { serverSettings: { headers: [], env: [], - metadata: [{ key: "x-test", value: "1" }], + metadata: { "x-test": "1" }, connectionTimeout: 0, requestTimeout: 0, taskTtl: 0, @@ -655,7 +655,7 @@ describe("InspectorClient coverage backfill", () => { serverSettings: { headers: [], env: [], - metadata: [], + metadata: {}, connectionTimeout: 1, requestTimeout: 0, taskTtl: 0, diff --git a/clients/web/src/test/integration/mcp/inspectorClient-modern-era.test.ts b/clients/web/src/test/integration/mcp/inspectorClient-modern-era.test.ts index cb346ec0..06b932d5 100644 --- a/clients/web/src/test/integration/mcp/inspectorClient-modern-era.test.ts +++ b/clients/web/src/test/integration/mcp/inspectorClient-modern-era.test.ts @@ -506,7 +506,7 @@ describe("modern-era negotiation (2026-07-28)", () => { versionNegotiation: eraToVersionNegotiation("modern"), serverSettings: { headers: [], - metadata: [], + metadata: {}, env: [], connectionTimeout: 0, requestTimeout: 0, @@ -535,7 +535,7 @@ describe("modern-era negotiation (2026-07-28)", () => { versionNegotiation: eraToVersionNegotiation("modern"), serverSettings: { headers: [], - metadata: [], + metadata: {}, env: [], connectionTimeout: 0, requestTimeout: 0, @@ -586,7 +586,7 @@ describe("modern-era negotiation (2026-07-28)", () => { versionNegotiation: eraToVersionNegotiation("modern"), serverSettings: { headers: [], - metadata: [], + metadata: {}, env: [], connectionTimeout: 0, requestTimeout: 0, diff --git a/clients/web/src/test/integration/mcp/inspectorClient.test.ts b/clients/web/src/test/integration/mcp/inspectorClient.test.ts index 8c05b7ed..9fbb0608 100644 --- a/clients/web/src/test/integration/mcp/inspectorClient.test.ts +++ b/clients/web/src/test/integration/mcp/inspectorClient.test.ts @@ -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): InFlightCall { /** Get all resources from the client via listResources() (paginates if needed). */ async function getAllResources( client: InspectorClient, - metadata?: Record, + metadata?: RequestMetadata, ): Promise { 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, + metadata?: RequestMetadata, ): Promise { 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, + metadata?: RequestMetadata, ): Promise { 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`, 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; + metadata?: RequestMetadata; }> = []; client!.addEventListener( diff --git a/clients/web/src/test/integration/mcp/node/transport.test.ts b/clients/web/src/test/integration/mcp/node/transport.test.ts index af68081f..97cedc9e 100644 --- a/clients/web/src/test/integration/mcp/node/transport.test.ts +++ b/clients/web/src/test/integration/mcp/node/transport.test.ts @@ -218,7 +218,7 @@ describe("Transport", () => { { key: "", value: "ignored-empty-key" }, ], env: [], - metadata: [], + metadata: {}, connectionTimeout: 0, requestTimeout: 0, taskTtl: 0, @@ -267,7 +267,7 @@ describe("Transport", () => { const baseSettings: InspectorServerSettings = { headers: [], env: [], - metadata: [], + metadata: {}, connectionTimeout: 0, requestTimeout: 0, taskTtl: 0, @@ -305,7 +305,7 @@ describe("Transport", () => { const settings: InspectorServerSettings = { headers: [{ key: "X-Tenant", value: "acme" }], env: [], - metadata: [], + metadata: {}, connectionTimeout: 0, requestTimeout: 0, taskTtl: 0, @@ -350,7 +350,7 @@ describe("Transport", () => { const settings: InspectorServerSettings = { headers: [], env: [], - metadata: [], + metadata: {}, connectionTimeout: 0, requestTimeout: 0, taskTtl: 0, diff --git a/clients/web/src/test/integration/mcp/remote/server-extra-coverage.test.ts b/clients/web/src/test/integration/mcp/remote/server-extra-coverage.test.ts index e6140467..cd95f46d 100644 --- a/clients/web/src/test/integration/mcp/remote/server-extra-coverage.test.ts +++ b/clients/web/src/test/integration/mcp/remote/server-extra-coverage.test.ts @@ -457,7 +457,7 @@ describe("server.ts supplemental coverage", () => { const base = { headers: [], - metadata: [], + metadata: {}, connectionTimeout: 0, requestTimeout: 0, }; @@ -891,7 +891,7 @@ describe("server.ts supplemental coverage", () => { config: { type: "streamable-http", url: "https://x.test/mcp" }, settings: { headers: [], - metadata: [], + metadata: {}, connectionTimeout: 0, requestTimeout: 0, oauthClientSecret: "shh", @@ -1039,7 +1039,7 @@ describe("server.ts supplemental coverage", () => { config: { type: "streamable-http", url: "https://x.test/mcp" }, settings: { headers: [], - metadata: [], + metadata: {}, connectionTimeout: 0, requestTimeout: 0, oauthClientSecret: "new-secret", diff --git a/clients/web/src/test/integration/mcp/remote/servers-route.test.ts b/clients/web/src/test/integration/mcp/remote/servers-route.test.ts index 9862f55d..d0f9d407 100644 --- a/clients/web/src/test/integration/mcp/remote/servers-route.test.ts +++ b/clients/web/src/test/integration/mcp/remote/servers-route.test.ts @@ -425,10 +425,11 @@ describe("/api/servers routes", () => { // Wire envelope unchanged from #1353: pair-array headers, flat // oauth* fields. Backend splats these into the flat disk shape: // object headers, nested oauth, plus the inspector-only fields - // at the top level. + // at the top level. Metadata is the exception — it crosses as a + // JSON object, since a `_meta` value may be any JSON (#1910). settings: { headers: [{ key: "Authorization", value: "Bearer xyz" }], - metadata: [{ key: "tenant", value: "acme" }], + metadata: { tenant: "acme", limits: { rps: 10 } }, connectionTimeout: 30000, requestTimeout: 60000, oauthClientId: "client-abc", @@ -442,7 +443,7 @@ describe("/api/servers routes", () => { // Disk shape: flat, no `settings` wrapper, object headers, nested oauth. expect(stored).not.toHaveProperty("settings"); expect(stored.headers).toEqual({ Authorization: "Bearer xyz" }); - expect(stored.metadata).toEqual([{ key: "tenant", value: "acme" }]); + expect(stored.metadata).toEqual({ tenant: "acme", limits: { rps: 10 } }); expect(stored.connectionTimeout).toBe(30000); expect(stored.requestTimeout).toBe(60000); expect(stored.oauth).toEqual({ @@ -462,7 +463,7 @@ describe("/api/servers routes", () => { config: { type: "streamable-http", url: "https://x.test/mcp" }, settings: { headers: [], - metadata: [], + metadata: {}, connectionTimeout: 0, requestTimeout: 0, oauthAuthorizationParams: [ @@ -488,7 +489,7 @@ describe("/api/servers routes", () => { config: { type: "streamable-http", url: "https://x.test/mcp" }, settings: { headers: [], - metadata: [], + metadata: {}, connectionTimeout: 0, requestTimeout: 0, oauthAuthorizationParams: { kc_idp_hint: "corp" }, @@ -509,7 +510,7 @@ describe("/api/servers routes", () => { config: { type: "streamable-http", url: "https://x.test/mcp" }, settings: { headers: [], - metadata: [], + metadata: {}, connectionTimeout: 0, requestTimeout: 0, oauthAuthorizationUrl: "https://staging.test/authorize", @@ -534,7 +535,7 @@ describe("/api/servers routes", () => { config: { type: "streamable-http", url: "https://x.test/mcp" }, settings: { headers: [], - metadata: [], + metadata: {}, connectionTimeout: 0, requestTimeout: 0, oauthAuthorizationUrl: "", @@ -557,7 +558,7 @@ describe("/api/servers routes", () => { config: { type: "streamable-http", url: "https://x.test/mcp" }, settings: { headers: [], - metadata: [], + metadata: {}, connectionTimeout: 0, requestTimeout: 0, oauthTokenUrl: 42, @@ -583,7 +584,7 @@ describe("/api/servers routes", () => { config: { type: "streamable-http", url: "https://x.test/mcp" }, settings: { headers: [{ key: "X-Tenant", value: "acme" }], - metadata: [], + metadata: {}, connectionTimeout: 0, requestTimeout: 45000, }, @@ -617,7 +618,7 @@ describe("/api/servers routes", () => { body: JSON.stringify({ settings: { headers: [], - metadata: [], + metadata: {}, connectionTimeout: 0, requestTimeout: 0, autoRefreshOnListChanged: true, @@ -649,7 +650,7 @@ describe("/api/servers routes", () => { body: JSON.stringify({ settings: { headers: [], - metadata: [], + metadata: {}, connectionTimeout: 0, requestTimeout: 0, autoRefreshOnListChanged: false, @@ -671,7 +672,7 @@ describe("/api/servers routes", () => { config: { type: "stdio", command: "node" }, settings: { headers: [], - metadata: [], + metadata: {}, connectionTimeout: 0, requestTimeout: 0, autoRefreshOnListChanged: "yes", @@ -696,7 +697,7 @@ describe("/api/servers routes", () => { body: JSON.stringify({ settings: { headers: [], - metadata: [], + metadata: {}, connectionTimeout: 0, requestTimeout: 0, maxFetchRequests: 5000, @@ -724,7 +725,7 @@ describe("/api/servers routes", () => { body: JSON.stringify({ settings: { headers: [], - metadata: [], + metadata: {}, connectionTimeout: 0, requestTimeout: 0, maxFetchRequests: 0, @@ -756,7 +757,7 @@ describe("/api/servers routes", () => { body: JSON.stringify({ settings: { headers: [], - metadata: [], + metadata: {}, connectionTimeout: 0, requestTimeout: 0, maxFetchRequests: 1000, @@ -778,7 +779,7 @@ describe("/api/servers routes", () => { config: { type: "stdio", command: "node" }, settings: { headers: [], - metadata: [], + metadata: {}, connectionTimeout: 0, requestTimeout: 0, maxFetchRequests: -5, @@ -884,7 +885,7 @@ describe("/api/servers routes", () => { // headers should be an array of {key, value}; "oops" is a string. settings: { headers: "oops", - metadata: [], + metadata: {}, connectionTimeout: 0, requestTimeout: 0, }, @@ -915,7 +916,7 @@ describe("/api/servers routes", () => { // write lock and apply only the settings patch. settings: { headers: [{ key: "X-Tenant", value: "acme" }], - metadata: [], + metadata: {}, connectionTimeout: 0, requestTimeout: 0, }, @@ -957,7 +958,7 @@ describe("/api/servers routes", () => { config: { type: "streamable-http", url: "https://x.test/mcp" }, settings: { headers: [], - metadata: [], + metadata: {}, connectionTimeout: 0, requestTimeout: 0, oauthClientId: "", @@ -983,7 +984,7 @@ describe("/api/servers routes", () => { config: { type: "stdio", command: "node" }, settings: { headers: [{ key: "X-A", value: "1" }], - metadata: [], + metadata: {}, connectionTimeout: 0, requestTimeout: 0, // Unknown stowaway — must not survive the validator. @@ -1101,7 +1102,7 @@ describe("/api/servers routes", () => { url: "https://x.test/mcp", settings: { headers: [{ key: "X-Tenant", value: "acme" }], - metadata: [], + metadata: {}, connectionTimeout: 30000, requestTimeout: 0, oauthClientId: "client-abc", @@ -1207,7 +1208,7 @@ describe("/api/servers routes", () => { config: { type: "stdio", command: "node" }, settings: { headers: [], - metadata: [], + metadata: {}, connectionTimeout: 0, requestTimeout: 0, roots: [ @@ -1255,7 +1256,7 @@ describe("/api/servers routes", () => { config: { type: "stdio", command: "node" }, settings: { headers: [], - metadata: [], + metadata: {}, connectionTimeout: 0, requestTimeout: 0, roots: [{ uri: " " }], @@ -1285,7 +1286,7 @@ describe("/api/servers routes", () => { config: { type: "stdio", command: "node" }, settings: { headers: [], - metadata: [], + metadata: {}, connectionTimeout: 0, requestTimeout: 0, // uri must be a string. @@ -1336,7 +1337,7 @@ describe("/api/servers routes", () => { { key: "Authorization", value: "Bearer the-token" }, { key: "X-Tenant", value: "acme" }, ], - metadata: [], + metadata: {}, connectionTimeout: 0, requestTimeout: 0, }, @@ -1368,7 +1369,7 @@ describe("/api/servers routes", () => { body: JSON.stringify({ settings: { headers: [], - metadata: [], + metadata: {}, connectionTimeout: 0, requestTimeout: 0, env: [{ key: "API_KEY", value: "abc-123" }], @@ -1416,7 +1417,7 @@ describe("/api/servers routes", () => { body: JSON.stringify({ settings: { headers: [], - metadata: [], + metadata: {}, connectionTimeout: 5000, requestTimeout: 0, }, @@ -1484,7 +1485,7 @@ describe("/api/servers routes", () => { body: JSON.stringify({ settings: { headers: [], - metadata: [], + metadata: {}, connectionTimeout: 0, requestTimeout: 0, env: [], @@ -1516,7 +1517,7 @@ describe("/api/servers routes", () => { body: JSON.stringify({ settings: { headers: [], - metadata: [], + metadata: {}, connectionTimeout: 0, requestTimeout: 0, env: [ @@ -1587,7 +1588,7 @@ describe("/api/servers routes", () => { body: JSON.stringify({ settings: { headers: [], - metadata: [], + metadata: {}, connectionTimeout: 0, requestTimeout: 0, env: "nope", @@ -1614,7 +1615,7 @@ describe("/api/servers routes", () => { body: JSON.stringify({ settings: { headers: [], - metadata: [], + metadata: {}, connectionTimeout: 0, requestTimeout: 0, env: [], @@ -1642,7 +1643,7 @@ describe("/api/servers routes", () => { body: JSON.stringify({ settings: { headers: [], - metadata: [], + metadata: {}, connectionTimeout: 0, requestTimeout: 0, env: [], @@ -1675,7 +1676,7 @@ describe("/api/servers routes", () => { body: JSON.stringify({ settings: { headers: [], - metadata: [], + metadata: {}, connectionTimeout: 0, requestTimeout: 0, env: [{ key: "API_KEY", value: "abc" }], @@ -1775,7 +1776,7 @@ describe("/api/servers routes", () => { config: { type: "streamable-http", url: "https://x.test/mcp" }, settings: { headers: [], - metadata: [], + metadata: {}, connectionTimeout: 0, requestTimeout: 0, oauthClientId: "cid", @@ -1935,7 +1936,7 @@ describe("/api/servers routes", () => { body: JSON.stringify({ settings: { headers: [], - metadata: [], + metadata: {}, connectionTimeout: 0, requestTimeout: 0, oauthClientId: "cid", @@ -2327,7 +2328,7 @@ describe("/api/servers routes", () => { config: { type: "streamable-http", url: "https://x.test/mcp" }, settings: { headers: [], - metadata: [], + metadata: {}, connectionTimeout: 0, requestTimeout: 0, oauthClientId: "cid", @@ -2361,7 +2362,7 @@ describe("/api/servers routes", () => { config: { type: "streamable-http", url: "https://x.test/mcp" }, settings: { headers: [], - metadata: [], + metadata: {}, connectionTimeout: 0, requestTimeout: 0, oauthClientId: "cid", @@ -2658,7 +2659,7 @@ describe("a config-only PUT must not delete stored secrets (#2083)", () => { settings: { headers: [], env: [], - metadata: [], + metadata: {}, connectionTimeout: 30000, requestTimeout: 60000, taskTtl: 60000, @@ -2726,7 +2727,7 @@ describe("a config-only PUT must not delete stored secrets (#2083)", () => { settings: { headers: [], env: [], - metadata: [], + metadata: {}, connectionTimeout: 30000, requestTimeout: 60000, taskTtl: 60000, @@ -2879,7 +2880,7 @@ describe("plaintext migration against a session-scoped store (#1950)", () => { settings: { headers: [], env: [], - metadata: [], + metadata: {}, connectionTimeout: 45000, requestTimeout: 60000, taskTtl: 60000, @@ -2927,7 +2928,7 @@ describe("plaintext migration against a session-scoped store (#1950)", () => { settings: { headers: [], env: [], - metadata: [], + metadata: {}, connectionTimeout: 30000, requestTimeout: 60000, taskTtl: 60000, diff --git a/clients/web/src/test/integration/mcp/remote/transport.test.ts b/clients/web/src/test/integration/mcp/remote/transport.test.ts index 2e2b55ec..98feef7e 100644 --- a/clients/web/src/test/integration/mcp/remote/transport.test.ts +++ b/clients/web/src/test/integration/mcp/remote/transport.test.ts @@ -412,7 +412,7 @@ describe("Remote transport e2e", () => { { key: "X-Trace", value: "abc123" }, ], env: [], - metadata: [], + metadata: {}, connectionTimeout: 0, requestTimeout: 0, taskTtl: 0, diff --git a/core/mcp/__tests__/fakeInspectorClient.ts b/core/mcp/__tests__/fakeInspectorClient.ts index 728ef5f7..8e3519e9 100644 --- a/core/mcp/__tests__/fakeInspectorClient.ts +++ b/core/mcp/__tests__/fakeInspectorClient.ts @@ -36,6 +36,7 @@ import type { ResourceSubscriptionStreamState, ToolCallInvocation, ExcludedTool, + RequestMetadata, } from "../types.js"; import { INACTIVE_SUBSCRIPTION_STREAM_STATE } from "../types.js"; import type { MalformedListItem } from "../listSalvage.js"; @@ -145,25 +146,25 @@ export class FakeInspectorClient listAllTools = vi.fn( async (_options?: { cacheMode?: CacheMode; - metadata?: Record; + metadata?: RequestMetadata; }) => ({ tools: drainPages(this.toolPages, "tools") }), ); listAllPrompts = vi.fn( async (_options?: { cacheMode?: CacheMode; - metadata?: Record; + metadata?: RequestMetadata; }) => ({ prompts: drainPages(this.promptPages, "prompts") }), ); listAllResources = vi.fn( async (_options?: { cacheMode?: CacheMode; - metadata?: Record; + metadata?: RequestMetadata; }) => ({ resources: drainPages(this.resourcePages, "resources") }), ); listAllResourceTemplates = vi.fn( async (_options?: { cacheMode?: CacheMode; - metadata?: Record; + metadata?: RequestMetadata; }) => ({ resourceTemplates: drainPages( this.resourceTemplatePages, @@ -224,7 +225,7 @@ export class FakeInspectorClient _argumentName: string, _argumentValue: string, _context?: Record, - _metadata?: Record, + _metadata?: RequestMetadata, ): Promise<{ values: string[]; total?: number; hasMore?: boolean }> => ({ values: [], }), diff --git a/core/mcp/inspectorClient.ts b/core/mcp/inspectorClient.ts index fb0ad80e..c4ad06db 100644 --- a/core/mcp/inspectorClient.ts +++ b/core/mcp/inspectorClient.ts @@ -17,6 +17,7 @@ import type { PendingRequestOrigin, ResourceSubscriptionStreamState, ExcludedTool, + RequestMetadata, } from "./types.js"; import { scanXMcpHeaderDeclarations, @@ -356,8 +357,8 @@ function notificationMethodFromSchema(schema: unknown): string | undefined { interface ToolCallRequest { tool: Tool; args: Record; - generalMetadata?: Record; - toolSpecificMetadata?: Record; + generalMetadata?: RequestMetadata; + toolSpecificMetadata?: RequestMetadata; taskOptions?: { ttl?: number }; options?: { skipOutputValidation?: boolean }; } @@ -420,7 +421,7 @@ export class InspectorClient extends InspectorClientEventTarget { private progress: boolean; private resetTimeoutOnProgress: boolean; private requestTimeout: number | undefined; - private defaultMetadata: Record | undefined; + private defaultMetadata: RequestMetadata | undefined; private serverSettings: InspectorServerSettings | undefined; private versionNegotiation: VersionNegotiationOptions; private status: ConnectionStatus = "disconnected"; @@ -1035,7 +1036,7 @@ export class InspectorClient extends InspectorClientEventTarget { * injecting an empty `_meta` field. */ private mergeMeta( - callMetadata?: Record, + callMetadata?: RequestMetadata, ): Record | undefined { const defaults = this.defaultMetadata; // Modern-era per-request log level (#1629): stamp the opt-in `_meta` key on @@ -1055,6 +1056,26 @@ export class InspectorClient extends InspectorClientEventTarget { return Object.keys(merged).length > 0 ? merged : undefined; } + /** + * The `progressToken` a caller stamped on its request metadata, when it is + * one the SDK will accept. + * + * `_meta` values are arbitrary JSON (#1910), but `progressToken` is a + * reserved key the spec types as `string | number`. Anything else — an + * object, an array, `null`, a boolean — is not a token the SDK can correlate + * a `notifications/progress` back to, so it is read as "no token" rather than + * forwarded. The key still reaches the wire inside `_meta`; this only governs + * whether the client wires up a progress callback for it. + */ + private progressTokenOf( + metadata: RequestMetadata | undefined, + ): ProgressToken | undefined { + const token = metadata?.progressToken; + return typeof token === "string" || typeof token === "number" + ? token + : undefined; + } + private getRequestOptions( progressToken?: ProgressToken, signal?: AbortSignal, @@ -1110,7 +1131,7 @@ export class InspectorClient extends InspectorClientEventTarget { * so the merge/omit branch is defined once. */ private aggregateListParams( - metadata?: Record, + metadata?: RequestMetadata, ): { _meta: Record } | undefined { const effectiveMeta = this.mergeMeta(metadata); return effectiveMeta ? { _meta: effectiveMeta } : undefined; @@ -3405,7 +3426,7 @@ export class InspectorClient extends InspectorClientEventTarget { */ async listTools( cursor?: string, - metadata?: Record, + metadata?: RequestMetadata, ): Promise<{ tools: Tool[]; nextCursor?: string }> { if (!this.client) { throw new Error("Client is not connected"); @@ -3423,7 +3444,7 @@ export class InspectorClient extends InspectorClientEventTarget { this.client!.request( { method: "tools/list", params }, ListToolsResultSchema, - this.getRequestOptions(metadata?.progressToken), + this.getRequestOptions(this.progressTokenOf(metadata)), ), ); const tools = [...(response.tools || [])]; @@ -3444,7 +3465,7 @@ export class InspectorClient extends InspectorClientEventTarget { */ async listAllTools(options?: { cacheMode?: CacheMode; - metadata?: Record; + metadata?: RequestMetadata; }): Promise<{ tools: Tool[] }> { if (!this.client) { throw new Error("Client is not connected"); @@ -3596,7 +3617,7 @@ export class InspectorClient extends InspectorClientEventTarget { * from it so every field except the entries is still validated. */ resultSchema: z.ZodObject; aggregate: () => Promise; - metadata?: Record; + metadata?: RequestMetadata; /** * Filter applied to SALVAGED entries only, to reproduce a filter the SDK's * strict aggregate applies for us. Without it the fallback would return a @@ -3674,7 +3695,7 @@ export class InspectorClient extends InspectorClientEventTarget { itemsKey: string; itemSchema: z.ZodType; resultSchema: z.ZodObject; - metadata?: Record; + metadata?: RequestMetadata; err: unknown; }): Promise { const pageSchema = lenientListPageSchema(resultSchema, itemsKey); @@ -3725,7 +3746,7 @@ export class InspectorClient extends InspectorClientEventTarget { : this.client!.request( { method, params }, pageSchema, - this.getRequestOptions(metadata?.progressToken), + this.getRequestOptions(this.progressTokenOf(metadata)), ), { method }, ); @@ -3811,7 +3832,7 @@ export class InspectorClient extends InspectorClientEventTarget { * stops the walk (non-converging-server guard, mirroring the SDK). */ async refreshExcludedTools( - metadata?: Record, + metadata?: RequestMetadata, ): Promise { const excluded: ExcludedTool[] = []; // Gated to connections that actually exclude; otherwise this is a pure @@ -3881,7 +3902,7 @@ export class InspectorClient extends InspectorClientEventTarget { */ private async listToolsForScan( cursor: string | undefined, - metadata: Record | undefined, + metadata: RequestMetadata | undefined, ): Promise<{ tools: Tool[]; nextCursor?: string; @@ -3924,7 +3945,7 @@ export class InspectorClient extends InspectorClientEventTarget { : this.client!.request( { method: "tools/list", params }, pageSchema, - this.getRequestOptions(metadata?.progressToken), + this.getRequestOptions(this.progressTokenOf(metadata)), ), { method: "tools/list" }, ); @@ -4016,8 +4037,8 @@ export class InspectorClient extends InspectorClientEventTarget { async callTool( tool: Tool, args: Record, - generalMetadata?: Record, - toolSpecificMetadata?: Record, + generalMetadata?: RequestMetadata, + toolSpecificMetadata?: RequestMetadata, taskOptions?: { ttl?: number }, options?: { skipOutputValidation?: boolean }, ): Promise { @@ -4245,7 +4266,7 @@ export class InspectorClient extends InspectorClientEventTarget { const convertedArgs = this.convertStringToolArgs(tool, args); // Merge general metadata with tool-specific metadata; tool-specific wins. - const callMetadata: Record | undefined = + const callMetadata: RequestMetadata | undefined = generalMetadata || toolSpecificMetadata ? { ...(generalMetadata || {}), ...(toolSpecificMetadata || {}) } : undefined; @@ -4270,7 +4291,7 @@ export class InspectorClient extends InspectorClientEventTarget { } const requestOptions = this.getRequestOptions( - metadata?.progressToken, + this.progressTokenOf(metadata), signal, ); this.applyMirroredParamHeaders(tool, convertedArgs, requestOptions); @@ -4360,11 +4381,11 @@ export class InspectorClient extends InspectorClientEventTarget { private dispatchFailedToolCall( tool: Tool, args: Record, - generalMetadata: Record | undefined, - toolSpecificMetadata: Record | undefined, + generalMetadata: RequestMetadata | undefined, + toolSpecificMetadata: RequestMetadata | undefined, errorMessage: string, ): void { - const callMetadata: Record | undefined = + const callMetadata: RequestMetadata | undefined = generalMetadata || toolSpecificMetadata ? { ...(generalMetadata || {}), ...(toolSpecificMetadata || {}) } : undefined; @@ -4848,8 +4869,8 @@ export class InspectorClient extends InspectorClientEventTarget { async callToolStream( tool: Tool, args: Record, - generalMetadata?: Record, - toolSpecificMetadata?: Record, + generalMetadata?: RequestMetadata, + toolSpecificMetadata?: RequestMetadata, taskOptions?: { ttl?: number }, ): Promise { if (!this.client) { @@ -4859,7 +4880,7 @@ export class InspectorClient extends InspectorClientEventTarget { const convertedArgs = this.convertStringToolArgs(tool, args); // Merge general metadata with tool-specific metadata; tool-specific wins. - const callMetadata: Record | undefined = + const callMetadata: RequestMetadata | undefined = generalMetadata || toolSpecificMetadata ? { ...(generalMetadata || {}), ...(toolSpecificMetadata || {}) } : undefined; @@ -4895,7 +4916,9 @@ export class InspectorClient extends InspectorClientEventTarget { // attach one here either — doing so would request a progress token (and // emit requestorTaskProgress) for task calls only, bypassing the toggle // that governs every other call path. - const requestOptions = this.getRequestOptions(metadata?.progressToken); + const requestOptions = this.getRequestOptions( + this.progressTokenOf(metadata), + ); // The task-augmented `tools/call` needs the same SEP-2243 mirroring as the // plain one — a strict modern server rejects it with -32020 otherwise. this.applyMirroredParamHeaders(tool, convertedArgs, requestOptions); @@ -5056,7 +5079,7 @@ export class InspectorClient extends InspectorClientEventTarget { return invocation; } catch (error) { // Merge general metadata with tool-specific metadata for error case - const callMetadata: Record | undefined = + const callMetadata: RequestMetadata | undefined = generalMetadata || toolSpecificMetadata ? { ...(generalMetadata || {}), ...(toolSpecificMetadata || {}) } : undefined; @@ -5086,7 +5109,7 @@ export class InspectorClient extends InspectorClientEventTarget { */ async listResources( cursor?: string, - metadata?: Record, + metadata?: RequestMetadata, ): Promise<{ resources: Resource[]; nextCursor?: string }> { if (!this.client) { throw new Error("Client is not connected"); @@ -5100,7 +5123,7 @@ export class InspectorClient extends InspectorClientEventTarget { this.client!.request( { method: "resources/list", params }, ListResourcesResultSchema, - this.getRequestOptions(metadata?.progressToken), + this.getRequestOptions(this.progressTokenOf(metadata)), ), ); return { @@ -5117,7 +5140,7 @@ export class InspectorClient extends InspectorClientEventTarget { */ async listAllResources(options?: { cacheMode?: CacheMode; - metadata?: Record; + metadata?: RequestMetadata; }): Promise<{ resources: Resource[] }> { if (!this.client) { throw new Error("Client is not connected"); @@ -5148,7 +5171,7 @@ export class InspectorClient extends InspectorClientEventTarget { */ async readResource( uri: string, - metadata?: Record, + metadata?: RequestMetadata, ): Promise { if (!this.client) { throw new Error("Client is not connected"); @@ -5167,7 +5190,7 @@ export class InspectorClient extends InspectorClientEventTarget { "resources/read", params, ReadResourceResultSchema, - this.getRequestOptions(metadata?.progressToken), + this.getRequestOptions(this.progressTokenOf(metadata)), ), { method: "resources/read" }, ); @@ -5198,7 +5221,7 @@ export class InspectorClient extends InspectorClientEventTarget { async readResourceFromTemplate( uriTemplate: string, params: Record, - metadata?: Record, + metadata?: RequestMetadata, ): Promise { if (!this.client) { throw new Error("Client is not connected"); @@ -5260,7 +5283,7 @@ export class InspectorClient extends InspectorClientEventTarget { */ async listResourceTemplates( cursor?: string, - metadata?: Record, + metadata?: RequestMetadata, ): Promise<{ resourceTemplates: ResourceTemplate[]; nextCursor?: string }> { if (!this.client) { throw new Error("Client is not connected"); @@ -5275,7 +5298,7 @@ export class InspectorClient extends InspectorClientEventTarget { this.client!.request( { method: "resources/templates/list", params }, ListResourceTemplatesResultSchema, - this.getRequestOptions(metadata?.progressToken), + this.getRequestOptions(this.progressTokenOf(metadata)), ), { method: "resources/templates/list" }, ); @@ -5293,7 +5316,7 @@ export class InspectorClient extends InspectorClientEventTarget { */ async listAllResourceTemplates(options?: { cacheMode?: CacheMode; - metadata?: Record; + metadata?: RequestMetadata; }): Promise<{ resourceTemplates: ResourceTemplate[] }> { if (!this.client) { throw new Error("Client is not connected"); @@ -5324,7 +5347,7 @@ export class InspectorClient extends InspectorClientEventTarget { */ async listPrompts( cursor?: string, - metadata?: Record, + metadata?: RequestMetadata, ): Promise<{ prompts: Prompt[]; nextCursor?: string }> { if (!this.client) { throw new Error("Client is not connected"); @@ -5338,7 +5361,7 @@ export class InspectorClient extends InspectorClientEventTarget { this.client!.request( { method: "prompts/list", params }, ListPromptsResultSchema, - this.getRequestOptions(metadata?.progressToken), + this.getRequestOptions(this.progressTokenOf(metadata)), ), ); return { @@ -5355,7 +5378,7 @@ export class InspectorClient extends InspectorClientEventTarget { */ async listAllPrompts(options?: { cacheMode?: CacheMode; - metadata?: Record; + metadata?: RequestMetadata; }): Promise<{ prompts: Prompt[] }> { if (!this.client) { throw new Error("Client is not connected"); @@ -5388,7 +5411,7 @@ export class InspectorClient extends InspectorClientEventTarget { async getPrompt( name: string, args?: Record, - metadata?: Record, + metadata?: RequestMetadata, ): Promise { if (!this.client) { throw new Error("Client is not connected"); @@ -5412,7 +5435,7 @@ export class InspectorClient extends InspectorClientEventTarget { "prompts/get", params, GetPromptResultSchema, - this.getRequestOptions(metadata?.progressToken), + this.getRequestOptions(this.progressTokenOf(metadata)), ), { method: "prompts/get", toolName: name }, ); @@ -5451,7 +5474,7 @@ export class InspectorClient extends InspectorClientEventTarget { argumentName: string, argumentValue: string, context?: Record, - metadata?: Record, + metadata?: RequestMetadata, ): Promise<{ values: string[]; total?: number; hasMore?: boolean }> { if (!this.client) { return { values: [] }; @@ -5473,7 +5496,7 @@ export class InspectorClient extends InspectorClientEventTarget { () => this.client!.complete( params, - this.getRequestOptions(metadata?.progressToken), + this.getRequestOptions(this.progressTokenOf(metadata)), ), { method: "completion/complete", diff --git a/core/mcp/inspectorClientEventTarget.ts b/core/mcp/inspectorClientEventTarget.ts index 572909c8..58aa41f6 100644 --- a/core/mcp/inspectorClientEventTarget.ts +++ b/core/mcp/inspectorClientEventTarget.ts @@ -25,6 +25,7 @@ import type { ResourceTemplateReadInvocation, ResourceSubscriptionStreamState, ExcludedTool, + RequestMetadata, } from "./types.js"; import type { MalformedListItem } from "./listSalvage.js"; import type { @@ -112,7 +113,7 @@ export interface InspectorClientEventMap { timestamp: Date; success: boolean; error?: string; - metadata?: Record; + metadata?: RequestMetadata; /** Non-fatal outputSchema mismatch detected on the skipOutputValidation path. */ outputValidationError?: string; }; diff --git a/core/mcp/inspectorClientProtocol.ts b/core/mcp/inspectorClientProtocol.ts index 756b560f..ef290f9e 100644 --- a/core/mcp/inspectorClientProtocol.ts +++ b/core/mcp/inspectorClientProtocol.ts @@ -21,6 +21,7 @@ import type { ToolCallInvocation, ResourceSubscriptionStreamState, ExcludedTool, + RequestMetadata, } from "./types.js"; import type { CacheMode, @@ -82,19 +83,19 @@ export interface InspectorClientProtocol extends InspectorClientEventTarget { // Paginated list methods used by managed/paged state stores listTools( cursor?: string, - metadata?: Record, + metadata?: RequestMetadata, ): Promise<{ tools: Tool[]; nextCursor?: string }>; listPrompts( cursor?: string, - metadata?: Record, + metadata?: RequestMetadata, ): Promise<{ prompts: Prompt[]; nextCursor?: string }>; listResources( cursor?: string, - metadata?: Record, + metadata?: RequestMetadata, ): Promise<{ resources: Resource[]; nextCursor?: string }>; listResourceTemplates( cursor?: string, - metadata?: Record, + metadata?: RequestMetadata, ): Promise<{ resourceTemplates: ResourceTemplate[]; nextCursor?: string }>; listRequestorTasks( cursor?: string, @@ -126,41 +127,41 @@ export interface InspectorClientProtocol extends InspectorClientEventTarget { // `cacheMode` uses the SDK default ('use'). listAllTools(options?: { cacheMode?: CacheMode; - metadata?: Record; + metadata?: RequestMetadata; }): Promise<{ tools: Tool[] }>; listAllPrompts(options?: { cacheMode?: CacheMode; - metadata?: Record; + metadata?: RequestMetadata; }): Promise<{ prompts: Prompt[] }>; listAllResources(options?: { cacheMode?: CacheMode; - metadata?: Record; + metadata?: RequestMetadata; }): Promise<{ resources: Resource[] }>; listAllResourceTemplates(options?: { cacheMode?: CacheMode; - metadata?: Record; + metadata?: RequestMetadata; }): Promise<{ resourceTemplates: ResourceTemplate[] }>; // Invocation methods used by paged result panels callTool( tool: Tool, args: Record, - generalMetadata?: Record, - toolSpecificMetadata?: Record, + generalMetadata?: RequestMetadata, + toolSpecificMetadata?: RequestMetadata, ): Promise; readResource( uri: string, - metadata?: Record, + metadata?: RequestMetadata, ): Promise; readResourceFromTemplate( uriTemplate: string, params: Record, - metadata?: Record, + metadata?: RequestMetadata, ): Promise; getPrompt( name: string, params?: Record, - metadata?: Record, + metadata?: RequestMetadata, ): Promise; // Misc surface required by hooks/state @@ -182,6 +183,6 @@ export interface InspectorClientProtocol extends InspectorClientEventTarget { argumentName: string, argumentValue: string, context?: Record, - metadata?: Record, + metadata?: RequestMetadata, ): Promise<{ values: string[]; total?: number; hasMore?: boolean }>; } diff --git a/core/mcp/node/servers.ts b/core/mcp/node/servers.ts index 9e10c69a..2f296c12 100644 --- a/core/mcp/node/servers.ts +++ b/core/mcp/node/servers.ts @@ -49,7 +49,7 @@ export function headersToServerSettings( return { headers: Object.entries(headers).map(([key, value]) => ({ key, value })), env: [], - metadata: [], + metadata: {}, connectionTimeout: 0, requestTimeout: 0, taskTtl: DEFAULT_TASK_TTL_MS, diff --git a/core/mcp/remote/node/server.ts b/core/mcp/remote/node/server.ts index 6f778c66..0b3bffe5 100644 --- a/core/mcp/remote/node/server.ts +++ b/core/mcp/remote/node/server.ts @@ -41,6 +41,7 @@ import { } from "../../types.js"; import type { InspectorServerSettings, + RequestMetadata, MCPConfig, MCPServerConfig, StdioServerConfig, @@ -1212,6 +1213,11 @@ export function createRemoteApp( // credentials are intentionally lost on first read (hard cutover per // #1358 decision 4). Users re-enter via the form or hand-edit into the // flat shape. + // A plain JSON object — the container `_meta` requires. Values are + // deliberately unchecked: `_meta` takes arbitrary JSON (#1910), and anything + // that survived `JSON.parse` already is. + const isJsonObject = (v: unknown): v is RequestMetadata => + v !== null && typeof v === "object" && !Array.isArray(v); const isStringRecord = (v: unknown): v is Record => { if (v === null || typeof v !== "object" || Array.isArray(v)) return false; for (const val of Object.values(v as Record)) { @@ -1324,10 +1330,18 @@ export function createRemoteApp( ); delete valObj.headers; } - if ("metadata" in valObj && !isKvArray(valObj.metadata)) { + // A JSON object post-#1910; the pre-#1910 `{ key, value }[]` pair array + // is still accepted so an existing file keeps working (it is normalized + // by `normalizeStoredMetadata` on the way into the settings shape, and + // rewritten as an object on the next save). + if ( + "metadata" in valObj && + !isJsonObject(valObj.metadata) && + !isKvArray(valObj.metadata) + ) { logWarn( { route: "/api/servers", id, droppedKey: "metadata" }, - "Dropping malformed `metadata` field — expected `Array<{ key: string, value: string }>`.", + "Dropping malformed `metadata` field — expected a JSON object.", ); delete valObj.metadata; } @@ -1556,10 +1570,14 @@ export function createRemoteApp( error: "settings.headers must be an array of { key, value }", }; } - if (!isKvArray(obj.metadata)) { + // `_meta` takes any JSON, so metadata crosses the wire as a plain JSON + // object rather than the `{ key, value }` rows headers/env still use + // (#1910). Only the container is checked — the values are arbitrary JSON + // by design, and anything that survived `c.req.json()` already is. + if (!isJsonObject(obj.metadata)) { return { ok: false, - error: "settings.metadata must be an array of { key, value }", + error: "settings.metadata must be a JSON object", }; } // env is optional on the wire (older clients / non-stdio servers won't send @@ -1735,7 +1753,7 @@ export function createRemoteApp( // Absent → empty list, matching the read side. The write-through drops // empty-key rows and clears `config.env` when the list is empty. env: isKvArray(obj.env) ? obj.env : [], - metadata: obj.metadata as { key: string; value: string }[], + metadata: obj.metadata as RequestMetadata, connectionTimeout: obj.connectionTimeout as number, requestTimeout: obj.requestTimeout as number, // Absent → product default, matching the read side diff --git a/core/mcp/serverList.ts b/core/mcp/serverList.ts index f4be5f4a..5f4b10bc 100644 --- a/core/mcp/serverList.ts +++ b/core/mcp/serverList.ts @@ -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) { diff --git a/core/mcp/state/managedListState.ts b/core/mcp/state/managedListState.ts index eaf51f5d..bfe6467a 100644 --- a/core/mcp/state/managedListState.ts +++ b/core/mcp/state/managedListState.ts @@ -19,6 +19,7 @@ import type { } from "@modelcontextprotocol/client"; import { isClientDecodeRejection } from "../listSalvage.js"; import { isTerminalStatus } from "../types.js"; +import type { RequestMetadata } from "../types.js"; import { TypedEventTarget } from "../typedEventTarget.js"; /** @@ -67,7 +68,7 @@ export interface ManagedListConfig { fetchAll: ( client: InspectorClientProtocol, cacheMode: CacheMode | undefined, - metadata: Record | undefined, + metadata: RequestMetadata | undefined, ) => Promise; /** * Whether this list drives a list-changed indicator. When true, a @@ -97,7 +98,7 @@ export abstract class ManagedListState< protected items: T[] = []; protected client: InspectorClientProtocol | null = null; private unsubscribe: (() => void) | null = null; - private _metadata: Record | undefined = undefined; + private _metadata: RequestMetadata | undefined = undefined; private listChanged = false; // The last fetch's failure, kept as observable state so a load that fails // (a transport error, or a result the SDK codec rejects as invalid) is @@ -282,7 +283,7 @@ export abstract class ManagedListState< this.emit("errorChange", value); } - setMetadata(metadata?: Record): void { + setMetadata(metadata?: RequestMetadata): void { this._metadata = metadata; } @@ -299,7 +300,7 @@ export abstract class ManagedListState< * the `list_changed` auto-refresh) catch it explicitly (#1953). */ async refresh( - metadata?: Record, + metadata?: RequestMetadata, cacheMode?: CacheMode, ): Promise { let next: T[] | null; @@ -336,7 +337,7 @@ export abstract class ManagedListState< * the console; empty list is the right semantics). */ private async fetchItems( - metadata: Record | undefined, + metadata: RequestMetadata | undefined, cacheMode: CacheMode | undefined, ): Promise { const client = this.client; diff --git a/core/mcp/state/pagedPromptsState.ts b/core/mcp/state/pagedPromptsState.ts index 80fbe6ca..5d15ae5c 100644 --- a/core/mcp/state/pagedPromptsState.ts +++ b/core/mcp/state/pagedPromptsState.ts @@ -15,6 +15,7 @@ import type { InspectorClientProtocol } from "../inspectorClientProtocol.js"; import type { Prompt } from "@modelcontextprotocol/client"; import { isTerminalStatus } from "../types.js"; +import type { RequestMetadata } from "../types.js"; import { TypedEventTarget } from "../typedEventTarget.js"; import type { PagePaginationState } from "./pagedToolsState.js"; @@ -112,7 +113,7 @@ export class PagedPromptsState extends TypedEventTarget, + metadata?: RequestMetadata, ): Promise { const c = this.client; if (!c || c.getStatus() !== "connected") { diff --git a/core/mcp/state/pagedResourceTemplatesState.ts b/core/mcp/state/pagedResourceTemplatesState.ts index 2fd2e2af..efbedbd2 100644 --- a/core/mcp/state/pagedResourceTemplatesState.ts +++ b/core/mcp/state/pagedResourceTemplatesState.ts @@ -14,6 +14,7 @@ import type { InspectorClientProtocol } from "../inspectorClientProtocol.js"; import type { ResourceTemplateType as ResourceTemplate } from "@modelcontextprotocol/client"; import { isTerminalStatus } from "../types.js"; +import type { RequestMetadata } from "../types.js"; import { TypedEventTarget } from "../typedEventTarget.js"; export interface PagedResourceTemplatesStateEventMap { @@ -59,7 +60,7 @@ export class PagedResourceTemplatesState extends TypedEventTarget, + metadata?: RequestMetadata, ): Promise { const c = this.client; if (!c || c.getStatus() !== "connected") { diff --git a/core/mcp/state/pagedResourcesState.ts b/core/mcp/state/pagedResourcesState.ts index bd5194ac..60640faf 100644 --- a/core/mcp/state/pagedResourcesState.ts +++ b/core/mcp/state/pagedResourcesState.ts @@ -16,6 +16,7 @@ import type { InspectorClientProtocol } from "../inspectorClientProtocol.js"; import type { Resource } from "@modelcontextprotocol/client"; import { isTerminalStatus } from "../types.js"; +import type { RequestMetadata } from "../types.js"; import { TypedEventTarget } from "../typedEventTarget.js"; import type { PagePaginationState } from "./pagedToolsState.js"; @@ -113,7 +114,7 @@ export class PagedResourcesState extends TypedEventTarget, + metadata?: RequestMetadata, ): Promise { const c = this.client; if (!c || c.getStatus() !== "connected") { diff --git a/core/mcp/types.ts b/core/mcp/types.ts index 79475bc4..1b94f9ea 100644 --- a/core/mcp/types.ts +++ b/core/mcp/types.ts @@ -24,7 +24,7 @@ import type { OAuthClientProvider } from "@modelcontextprotocol/client"; import type { Transport } from "@modelcontextprotocol/client"; import type { InspectorLogger } from "../logging/logger.js"; import type { AppElicitationRenderer } from "./appElicitation.js"; -import type { JsonValue } from "../json/jsonUtils.js"; +import type { JsonObject, JsonValue } from "../json/jsonUtils.js"; import type { ClientConfig, EnterpriseManagedAuthIdpConfig, @@ -99,11 +99,20 @@ export type StoredMCPServer = MCPServerConfig & { */ headers?: Record; /** - * Default `_meta` keys merged into every outgoing MCP request. Inspector- - * specific (no analog in the broader mcp.json ecosystem), so the pair-array - * shape is preserved on disk and in memory. + * Default `_meta` payload merged into every outgoing MCP request. Inspector- + * specific (no analog in the broader mcp.json ecosystem). + * + * A JSON object, so a value may be any JSON — object, array, number, boolean, + * `null` — not just a string (#1910). Nothing in the MCP spec restricts + * `_meta` to string values, and the SDK models it as a passthrough object; + * the string-pair restriction was the Inspector's own. + * + * The pre-#1910 on-disk shape was a `{ key, value }[]` pair array. It is + * still **read** (see `normalizeStoredMetadata` in `serverList.ts`) so an + * existing `mcp.json` keeps working, but it is never written back — a + * round-trip through the Inspector rewrites the field as an object. */ - metadata?: { key: string; value: string }[]; + metadata?: JsonObject; /** * Protocol era to negotiate with this server (`"legacy" | "auto" | "modern"`), * orthogonal to the transport `type`. Inspector-specific (no analog in the @@ -361,6 +370,19 @@ export interface ServerState { stderrLogs: StderrLogEntry[]; } +/** + * A `_meta` payload: a JSON object whose values may be any JSON, not just + * strings (#1910). + * + * The MCP spec places no restriction on `_meta` value types and the SDK models + * the field as a passthrough object, so the Inspector must be able to send — + * and to record — nested objects, arrays, numbers, booleans and `null`. Used + * for both the per-server default payload (`InspectorServerSettings.metadata`, + * `InspectorClientOptions.defaultMetadata`) and the per-call metadata every + * request verb accepts. + */ +export type RequestMetadata = Record; + /** * Represents a complete resource read invocation, including request parameters, * response, and metadata. @@ -369,7 +391,7 @@ export interface ResourceReadInvocation { result: ReadResourceResult; timestamp: Date; uri: string; - metadata?: Record; + metadata?: RequestMetadata; } /** @@ -382,7 +404,7 @@ export interface ResourceTemplateReadInvocation { result: ReadResourceResult; timestamp: Date; params: Record; - metadata?: Record; + metadata?: RequestMetadata; } /** @@ -394,7 +416,7 @@ export interface PromptGetInvocation { timestamp: Date; name: string; params?: Record; - metadata?: Record; + metadata?: RequestMetadata; } /** @@ -408,7 +430,7 @@ export interface ToolCallInvocation { timestamp: Date; success: boolean; error?: string; - metadata?: Record; + metadata?: RequestMetadata; /** * Set only on the `skipOutputValidation` path: present when the (delivered) * result's structuredContent does NOT match the tool's declared outputSchema. @@ -689,7 +711,12 @@ export function eraToVersionNegotiation( */ export interface InspectorServerSettings { headers: { key: string; value: string }[]; - metadata: { key: string; value: string }[]; + /** + * Default `_meta` payload for this server, edited as a JSON object rather + * than key/value rows so a value can be any JSON (#1910). Always present; + * `{}` means "send no default metadata". + */ + metadata: RequestMetadata; /** * Environment variables for stdio servers, edited as controlled key/value * rows (mirrors `headers`). Only meaningful for stdio transports; non-stdio @@ -1075,7 +1102,7 @@ export interface InspectorClientOptions { * site metadata wins on key collision. Set this from `InspectorServerSettings.metadata` * so persisted server-wide metadata reaches the wire on the first request. */ - defaultMetadata?: Record; + defaultMetadata?: RequestMetadata; /** * Optional per-server runtime settings forwarded to the transport factory diff --git a/core/react/usePagedPrompts.ts b/core/react/usePagedPrompts.ts index 3e3efd44..6f62c524 100644 --- a/core/react/usePagedPrompts.ts +++ b/core/react/usePagedPrompts.ts @@ -1,5 +1,6 @@ import { useState, useEffect, useCallback } from "react"; import type { InspectorClientProtocol } from "../mcp/inspectorClientProtocol.js"; +import type { RequestMetadata } from "../mcp/types.js"; import type { PagedPromptsState, PagedPromptsStateEventMap, @@ -23,7 +24,7 @@ export interface UsePagedPromptsResult { error: Error | null; loadPage: ( cursor?: string, - metadata?: Record, + metadata?: RequestMetadata, ) => Promise; clear: () => void; } @@ -84,7 +85,7 @@ export function usePagedPrompts( const loadPage = useCallback( async ( cursor?: string, - metadata?: Record, + metadata?: RequestMetadata, ): Promise => { if (!pagedPromptsState || !client) { return { prompts: [], nextCursor: undefined }; diff --git a/core/react/usePagedResourceTemplates.ts b/core/react/usePagedResourceTemplates.ts index ab4707a4..c5202596 100644 --- a/core/react/usePagedResourceTemplates.ts +++ b/core/react/usePagedResourceTemplates.ts @@ -1,5 +1,6 @@ import { useState, useEffect, useCallback } from "react"; import type { InspectorClientProtocol } from "../mcp/inspectorClientProtocol.js"; +import type { RequestMetadata } from "../mcp/types.js"; import type { PagedResourceTemplatesState, PagedResourceTemplatesStateEventMap, @@ -12,7 +13,7 @@ export interface UsePagedResourceTemplatesResult { resourceTemplates: ResourceTemplate[]; loadPage: ( cursor?: string, - metadata?: Record, + metadata?: RequestMetadata, ) => Promise; clear: () => void; } @@ -58,7 +59,7 @@ export function usePagedResourceTemplates( const loadPage = useCallback( async ( cursor?: string, - metadata?: Record, + metadata?: RequestMetadata, ): Promise => { if (!pagedResourceTemplatesState || !client) { return { resourceTemplates: [], nextCursor: undefined }; diff --git a/core/react/usePagedResources.ts b/core/react/usePagedResources.ts index 3e5e3f8a..00b52a26 100644 --- a/core/react/usePagedResources.ts +++ b/core/react/usePagedResources.ts @@ -1,5 +1,6 @@ import { useState, useEffect, useCallback } from "react"; import type { InspectorClientProtocol } from "../mcp/inspectorClientProtocol.js"; +import type { RequestMetadata } from "../mcp/types.js"; import type { PagedResourcesState, PagedResourcesStateEventMap, @@ -23,7 +24,7 @@ export interface UsePagedResourcesResult { error: Error | null; loadPage: ( cursor?: string, - metadata?: Record, + metadata?: RequestMetadata, ) => Promise; clear: () => void; } @@ -90,7 +91,7 @@ export function usePagedResources( const loadPage = useCallback( async ( cursor?: string, - metadata?: Record, + metadata?: RequestMetadata, ): Promise => { if (!pagedResourcesState || !client) { return { resources: [], nextCursor: undefined }; diff --git a/docs/mcp-server-configuration.md b/docs/mcp-server-configuration.md index ac105f3a..c6bbd29b 100644 --- a/docs/mcp-server-configuration.md +++ b/docs/mcp-server-configuration.md @@ -168,7 +168,7 @@ These have no analog in the broader `mcp.json` ecosystem. Each is **omitted on w | `protocolEra` | `"legacy"` | `"legacy"` \| `"auto"` \| `"modern"` — which protocol era to negotiate, orthogonal to the transport | | `modernLogLevel` | `"debug"` | Per-request log level stamped on modern connections, or `"off"`. Legacy connections ignore it | | `roots` | — | Roots advertised via the `roots` client capability; each is `{ uri, name? }` | -| `metadata` | — | Default `_meta` keys merged into every outgoing request | +| `metadata` | — | Default `_meta` payload merged into every outgoing request — a JSON object, values may be any JSON | | `connectionTimeout` / `requestTimeout` | — | Timeouts in ms | | `taskTtl` | `60000` | TTL in ms for tasks created via "Run as task" (`DEFAULT_TASK_TTL_MS`) | | `autoRefreshOnListChanged` | `false` | Refresh lists automatically on `*/list_changed` instead of only flagging the indicator | @@ -177,6 +177,18 @@ These have no analog in the broader `mcp.json` ecosystem. Each is **omitted on w | `maxFetchRequests` | `1000` | Network-log retention for this server (`DEFAULT_MAX_FETCH_REQUESTS`); `0` means unlimited | | `oauth` | — | `{ clientId, clientSecret, scopes, authorizationParams, authorizationUrl, tokenUrl, enterpriseManaged, onInsufficientScope }` | +`metadata` is a JSON **object**, and its values may be any JSON — an object, an array, a number, a boolean, `null` — not only a string. Nothing in the MCP spec restricts `_meta` value types, and the SDK models the field as a passthrough object, so the Inspector does not narrow it either ([#1910](https://github.com/modelcontextprotocol/inspector/issues/1910)). Edit it in Server Settings → Request Metadata, which is a JSON editor rather than the key/value rows `headers` and `env` use; text that is not a JSON object is flagged inline and not applied. + +```jsonc +"metadata": { + "tenant": "acme", + "trace": { "id": "abc123", "sampled": true }, + "features": ["apps", "tasks"] +} +``` + +> **Reading an older file.** Before #1910 this field was a `[{ "key": …, "value": … }]` pair array. That shape is still **read**, so an existing `mcp.json` keeps working unchanged; it is never written back, so the field is rewritten as an object the next time the Inspector saves the entry. + `oauth.authorizationParams` is a string→string record of extra query parameters merged into the OAuth **authorization request** URL only — never the token request. Use it for provider-specific hints the core specs don't standardize (Keycloak's `kc_idp_hint`, OIDC's `login_hint` / `prompt` / `acr_values`, Auth0's `audience`). The protocol-critical parameters — `client_id`, `code_challenge`, `code_challenge_method`, `redirect_uri`, `resource`, `response_type`, `scope`, `state` — are **reserved**: the web form rejects them inline, and any that reach the merge anyway are dropped with a warning rather than overriding what the flow set (overriding them breaks PKCE, the CSRF state binding, or RFC 8707). Edit them in Server Settings → Authorization ("Additional authorization parameters"), beside Scopes. `oauth.authorizationUrl` and `oauth.tokenUrl` override the `authorization_endpoint` and `token_endpoint` that authorization-server metadata discovery resolved. The Inspector deliberately has no such fields by default — it resolves both from the AS's metadata document, exactly as a real MCP host does — but a server under development often advertises its *production* authorization server while you want to hit staging. Set either (they are independent) to an absolute `http(s)` URL and it replaces what the metadata returned, for both the authorization request and the token request; leave blank to use discovery. A malformed value is flagged inline in the form and dropped with a warning at connect time rather than failing the connection. Edit them in Server Settings → Authorization ("Authorization URL override" / "Token URL override").