feat: allow complex JSON values in request _meta
Closes #1910 `_meta` was typed `Record<string, string>` end to end in the Inspector — on disk as a `{ key, value }[]` pair array, in the Server Settings form as key/value rows, and in every request verb's per-call metadata parameter. Nothing in the MCP spec restricts a `_meta` value to a string, and the SDK models the field as a `z.looseObject`, so the restriction was ours alone. Widen the whole path to `RequestMetadata` (`Record<string, JsonValue>`): - Per-server default metadata is a JSON **object** on disk and in memory. The pre-#1910 pair array is still read (`normalizeStoredMetadata`) so an existing `mcp.json` keeps working; it is never written back, so a save migrates the entry. - The Server Settings → Request Metadata section is a JSON editor (`JsonObjectInput`) instead of key/value rows — rows cannot express an object value. It keeps the typed text as the display source and emits only parsed JSON, the same draft split `SchemaJsonField` uses, so the box stays typeable; invalid text is flagged inline and not applied. - The CLI stops flattening `--metadata` / `--tool-metadata` through `metaValueToString`. Those values were already JSON-parsed, so `trace={"id":"abc"}` used to reach the wire as the *string* `{"id":"abc"}` and now reaches it as an object. - `servers/show` redaction replaces a sensitive key's whole value rather than a string, so a structured value cannot leak through its members. - `progressTokenOf` narrows the reserved `progressToken` key back to `string | number` before it reaches the SDK; any other JSON still travels inside `_meta` but wires up no progress callback. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0177mPHAdECD18nLLCTwR5rh Signed-off-by: cliffhall <cliff@futurescale.com>
This commit is contained in:
parent
6829b45f60
commit
b33532c024
3 changed files with 1004 additions and 495 deletions
|
|
@ -10,7 +10,7 @@ import {
|
|||
const defaultSettings: InspectorServerSettings = {
|
||||
headers: [],
|
||||
env: [],
|
||||
metadata: [],
|
||||
metadata: {},
|
||||
connectionTimeout: 30000,
|
||||
requestTimeout: 60000,
|
||||
taskTtl: 60000,
|
||||
|
|
@ -57,34 +57,9 @@ function InteractiveRender(args: ServerSettingsFormProps) {
|
|||
},
|
||||
});
|
||||
}}
|
||||
onAddMetadata={() => {
|
||||
args.onAddMetadata();
|
||||
updateArgs({
|
||||
settings: {
|
||||
...args.settings,
|
||||
metadata: [...args.settings.metadata, { key: "", value: "" }],
|
||||
},
|
||||
});
|
||||
}}
|
||||
onRemoveMetadata={(index) => {
|
||||
args.onRemoveMetadata(index);
|
||||
updateArgs({
|
||||
settings: {
|
||||
...args.settings,
|
||||
metadata: args.settings.metadata.filter((_, i) => i !== index),
|
||||
},
|
||||
});
|
||||
}}
|
||||
onMetadataChange={(index, key, value) => {
|
||||
args.onMetadataChange(index, key, value);
|
||||
updateArgs({
|
||||
settings: {
|
||||
...args.settings,
|
||||
metadata: args.settings.metadata.map((m, i) =>
|
||||
i === index ? { key, value } : m,
|
||||
),
|
||||
},
|
||||
});
|
||||
onMetadataChange={(metadata) => {
|
||||
args.onMetadataChange(metadata);
|
||||
updateArgs({ settings: { ...args.settings, metadata } });
|
||||
}}
|
||||
onAddEnv={() => {
|
||||
args.onAddEnv();
|
||||
|
|
@ -197,8 +172,6 @@ const meta: Meta<typeof ServerSettingsForm> = {
|
|||
onRemoveEnv: fn(),
|
||||
onEnvChange: fn(),
|
||||
onCwdChange: fn(),
|
||||
onAddMetadata: fn(),
|
||||
onRemoveMetadata: fn(),
|
||||
onMetadataChange: fn(),
|
||||
onTimeoutChange: fn(),
|
||||
onAutoRefreshChange: fn(),
|
||||
|
|
@ -278,10 +251,13 @@ export const AllConfigured: Story = {
|
|||
{ key: "X-Request-Id", value: "req-456" },
|
||||
],
|
||||
env: [],
|
||||
metadata: [
|
||||
{ key: "userId", value: "user-789" },
|
||||
{ key: "sessionId", value: "session-012" },
|
||||
],
|
||||
metadata: {
|
||||
userId: "user-789",
|
||||
sessionId: "session-012",
|
||||
// A structured value — the case a `{ key, value }` row editor could
|
||||
// not express (#1910).
|
||||
client: { name: "inspector", features: ["apps", "tasks"], retries: 3 },
|
||||
},
|
||||
connectionTimeout: 15000,
|
||||
requestTimeout: 45000,
|
||||
taskTtl: 45000,
|
||||
|
|
|
|||
|
|
@ -22,7 +22,7 @@ function clearButtonFor(input: HTMLElement): HTMLElement {
|
|||
const emptySettings: InspectorServerSettings = {
|
||||
headers: [],
|
||||
env: [],
|
||||
metadata: [],
|
||||
metadata: {},
|
||||
connectionTimeout: 30000,
|
||||
requestTimeout: 60000,
|
||||
taskTtl: 60000,
|
||||
|
|
@ -33,7 +33,7 @@ const emptySettings: InspectorServerSettings = {
|
|||
const populatedSettings: InspectorServerSettings = {
|
||||
headers: [{ key: "Authorization", value: "Bearer abc" }],
|
||||
env: [],
|
||||
metadata: [{ key: "userId", value: "u-1" }],
|
||||
metadata: { userId: "u-1", tenant: { id: 7, tags: ["a"] } },
|
||||
connectionTimeout: 30000,
|
||||
requestTimeout: 60000,
|
||||
taskTtl: 60000,
|
||||
|
|
@ -63,8 +63,6 @@ const baseHandlers = {
|
|||
onRemoveEnv: vi.fn(),
|
||||
onEnvChange: vi.fn(),
|
||||
onCwdChange: vi.fn(),
|
||||
onAddMetadata: vi.fn(),
|
||||
onRemoveMetadata: vi.fn(),
|
||||
onMetadataChange: vi.fn(),
|
||||
onTimeoutChange: vi.fn(),
|
||||
onAutoRefreshChange: vi.fn(),
|
||||
|
|
@ -239,7 +237,7 @@ describe("ServerSettingsForm", () => {
|
|||
expect(screen.getByText("Log Level per Request")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("shows empty hints for headers and metadata when no entries exist", () => {
|
||||
it("shows the empty hint for headers, and an empty JSON object for metadata", () => {
|
||||
renderWithMantine(
|
||||
<ServerSettingsForm
|
||||
{...baseHandlers}
|
||||
|
|
@ -250,9 +248,9 @@ describe("ServerSettingsForm", () => {
|
|||
expect(
|
||||
screen.getByText("No custom headers configured"),
|
||||
).toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByText("No request metadata configured"),
|
||||
).toBeInTheDocument();
|
||||
// Metadata has no rows to be absent — the editor is always present, and
|
||||
// "none configured" is spelled `{}` in the box itself.
|
||||
expect(screen.getByLabelText("Request metadata JSON")).toHaveValue("{}");
|
||||
});
|
||||
|
||||
it("invokes onAddHeader when + Add Header is clicked", async () => {
|
||||
|
|
@ -270,21 +268,6 @@ describe("ServerSettingsForm", () => {
|
|||
expect(onAddHeader).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("invokes onAddMetadata when + Add Metadata is clicked", async () => {
|
||||
const user = userEvent.setup();
|
||||
const onAddMetadata = vi.fn();
|
||||
renderWithMantine(
|
||||
<ServerSettingsForm
|
||||
{...baseHandlers}
|
||||
onAddMetadata={onAddMetadata}
|
||||
settings={emptySettings}
|
||||
expandedSections={["metadata"]}
|
||||
/>,
|
||||
);
|
||||
await user.click(screen.getByRole("button", { name: "+ Add Metadata" }));
|
||||
expect(onAddMetadata).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("invokes onHeaderChange when typing in header value input", async () => {
|
||||
const user = userEvent.setup();
|
||||
const onHeaderChange = vi.fn();
|
||||
|
|
@ -340,29 +323,38 @@ describe("ServerSettingsForm", () => {
|
|||
expect(onRemoveHeader).toHaveBeenCalledWith(0);
|
||||
});
|
||||
|
||||
it("invokes onMetadataChange and onRemoveMetadata for metadata rows", async () => {
|
||||
const user = userEvent.setup();
|
||||
const onMetadataChange = vi.fn();
|
||||
const onRemoveMetadata = vi.fn();
|
||||
it("renders the configured metadata as formatted JSON, nested values included", () => {
|
||||
renderWithMantine(
|
||||
<ServerSettingsForm
|
||||
{...baseHandlers}
|
||||
onMetadataChange={onMetadataChange}
|
||||
onRemoveMetadata={onRemoveMetadata}
|
||||
settings={populatedSettings}
|
||||
expandedSections={["metadata"]}
|
||||
/>,
|
||||
);
|
||||
const valueInput = screen.getByDisplayValue("u-1");
|
||||
await user.type(valueInput, "Z");
|
||||
expect(onMetadataChange).toHaveBeenCalled();
|
||||
const editor = screen.getByLabelText("Request metadata JSON");
|
||||
// The point of #1910: a nested object survives into the editor instead of
|
||||
// being flattened into a `{ key, value }` string row.
|
||||
expect(JSON.parse((editor as HTMLTextAreaElement).value)).toEqual({
|
||||
userId: "u-1",
|
||||
tenant: { id: 7, tags: ["a"] },
|
||||
});
|
||||
});
|
||||
|
||||
await user.click(
|
||||
screen.getByRole("button", {
|
||||
name: "Remove metadata entry, userId, row 1",
|
||||
}),
|
||||
it("reports the whole edited object through onMetadataChange", async () => {
|
||||
const user = userEvent.setup();
|
||||
const onMetadataChange = vi.fn();
|
||||
renderWithMantine(
|
||||
<ServerSettingsForm
|
||||
{...baseHandlers}
|
||||
onMetadataChange={onMetadataChange}
|
||||
settings={emptySettings}
|
||||
expandedSections={["metadata"]}
|
||||
/>,
|
||||
);
|
||||
expect(onRemoveMetadata).toHaveBeenCalledWith(0);
|
||||
const editor = screen.getByLabelText("Request metadata JSON");
|
||||
await user.clear(editor);
|
||||
await user.type(editor, '{{"n":[[1,2]}');
|
||||
expect(onMetadataChange).toHaveBeenLastCalledWith({ n: [1, 2] });
|
||||
});
|
||||
|
||||
it("invokes onTimeoutChange when typing in connection timeout", async () => {
|
||||
|
|
@ -1470,23 +1462,6 @@ describe("ServerSettingsForm", () => {
|
|||
expect(onClearStoredOAuth).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("clears a metadata value via its Clear button (onMetadataChange with empty value)", async () => {
|
||||
const user = userEvent.setup();
|
||||
const onMetadataChange = vi.fn();
|
||||
renderWithMantine(
|
||||
<ServerSettingsForm
|
||||
{...baseHandlers}
|
||||
onMetadataChange={onMetadataChange}
|
||||
settings={populatedSettings}
|
||||
expandedSections={["metadata"]}
|
||||
/>,
|
||||
);
|
||||
// populatedSettings has one metadata { key: "userId", value: "u-1" }
|
||||
const valueInput = screen.getByDisplayValue("u-1");
|
||||
await user.click(clearButtonFor(valueInput));
|
||||
expect(onMetadataChange).toHaveBeenCalledWith(0, "userId", "");
|
||||
});
|
||||
|
||||
it("omits the Clear buttons for an empty header key/value row", () => {
|
||||
// A row whose key and value are both empty renders no Clear button in
|
||||
// either field (the null branch of `item.key ?` / `item.value ?`).
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@ import {
|
|||
Alert,
|
||||
Button,
|
||||
Checkbox,
|
||||
Code,
|
||||
Flex,
|
||||
Group,
|
||||
NumberInput,
|
||||
|
|
@ -13,11 +14,13 @@ import {
|
|||
TextInput,
|
||||
} from "@mantine/core";
|
||||
import { ClearButton } from "../../elements/ClearButton/ClearButton";
|
||||
import { JsonObjectInput } from "../../elements/JsonObjectInput/JsonObjectInput";
|
||||
import type { ProtocolEra } from "@modelcontextprotocol/client";
|
||||
import type {
|
||||
InspectorServerSettings,
|
||||
ModernLogLevel,
|
||||
OAuthSettings,
|
||||
RequestMetadata,
|
||||
ServerProtocolEra,
|
||||
ServerType,
|
||||
} from "@inspector/core/mcp/types.js";
|
||||
|
|
@ -78,9 +81,12 @@ export interface ServerSettingsFormProps {
|
|||
onRemoveEnv: (index: number) => void;
|
||||
onEnvChange: (index: number, key: string, value: string) => void;
|
||||
onCwdChange: (value: string) => void;
|
||||
onAddMetadata: () => void;
|
||||
onRemoveMetadata: (index: number) => void;
|
||||
onMetadataChange: (index: number, key: string, value: string) => void;
|
||||
/**
|
||||
* Replace the whole default-`_meta` payload. Not the `(index, key, value)`
|
||||
* row callback the header/env editors use — metadata is edited as a JSON
|
||||
* object, since a `_meta` value may be any JSON (#1910).
|
||||
*/
|
||||
onMetadataChange: (metadata: RequestMetadata) => void;
|
||||
onTimeoutChange: (
|
||||
field: "connectionTimeout" | "requestTimeout" | "taskTtl",
|
||||
value: number,
|
||||
|
|
@ -434,8 +440,6 @@ export function ServerSettingsForm({
|
|||
onRemoveEnv,
|
||||
onEnvChange,
|
||||
onCwdChange,
|
||||
onAddMetadata,
|
||||
onRemoveMetadata,
|
||||
onMetadataChange,
|
||||
onTimeoutChange,
|
||||
onAutoRefreshChange,
|
||||
|
|
@ -724,22 +728,18 @@ export function ServerSettingsForm({
|
|||
<Accordion.Control>Request Metadata</Accordion.Control>
|
||||
<Accordion.Panel>
|
||||
<Stack gap="md">
|
||||
<Group justify="space-between">
|
||||
<HintText>
|
||||
Metadata sent with every MCP request (included in _meta field)
|
||||
</HintText>
|
||||
<AddButton onClick={onAddMetadata}>+ Add Metadata</AddButton>
|
||||
</Group>
|
||||
{settings.metadata.length === 0 ? (
|
||||
<EmptyHint>No request metadata configured</EmptyHint>
|
||||
) : (
|
||||
<KeyValueRows
|
||||
items={settings.metadata}
|
||||
entityLabel="metadata entry"
|
||||
onChange={onMetadataChange}
|
||||
onRemove={onRemoveMetadata}
|
||||
/>
|
||||
)}
|
||||
<HintText>
|
||||
Metadata merged into the `_meta` field of every MCP request to
|
||||
this server, as a JSON object. Values may be any JSON — objects,
|
||||
arrays, numbers, booleans, `null` — not just strings. Leave it as{" "}
|
||||
<Code>{"{}"}</Code> to send none.
|
||||
</HintText>
|
||||
<JsonObjectInput
|
||||
aria-label="Request metadata JSON"
|
||||
placeholder={'{\n "example": { "nested": true }\n}'}
|
||||
value={settings.metadata}
|
||||
onChange={onMetadataChange}
|
||||
/>
|
||||
</Stack>
|
||||
</Accordion.Panel>
|
||||
</Accordion.Item>
|
||||
|
|
|
|||
Loading…
Reference in a new issue