feat: allow complex JSON values in request _meta

Closes #1910

`_meta` was typed `Record<string, string>` end to end in the Inspector —
on disk as a `{ key, value }[]` pair array, in the Server Settings form as
key/value rows, and in every request verb's per-call metadata parameter.
Nothing in the MCP spec restricts a `_meta` value to a string, and the SDK
models the field as a `z.looseObject`, so the restriction was ours alone.

Widen the whole path to `RequestMetadata` (`Record<string, JsonValue>`):

- Per-server default metadata is a JSON **object** on disk and in memory.
  The pre-#1910 pair array is still read (`normalizeStoredMetadata`) so an
  existing `mcp.json` keeps working; it is never written back, so a save
  migrates the entry.
- The Server Settings → Request Metadata section is a JSON editor
  (`JsonObjectInput`) instead of key/value rows — rows cannot express an
  object value. It keeps the typed text as the display source and emits
  only parsed JSON, the same draft split `SchemaJsonField` uses, so the box
  stays typeable; invalid text is flagged inline and not applied.
- The CLI stops flattening `--metadata` / `--tool-metadata` through
  `metaValueToString`. Those values were already JSON-parsed, so
  `trace={"id":"abc"}` used to reach the wire as the *string* `{"id":"abc"}`
  and now reaches it as an object.
- `servers/show` redaction replaces a sensitive key's whole value rather
  than a string, so a structured value cannot leak through its members.
- `progressTokenOf` narrows the reserved `progressToken` key back to
  `string | number` before it reaches the SDK; any other JSON still travels
  inside `_meta` but wires up no progress callback.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0177mPHAdECD18nLLCTwR5rh
Signed-off-by: cliffhall <cliff@futurescale.com>
This commit is contained in:
cliffhall 2026-08-23 12:23:40 -04:00
commit b33532c024
7 changed files with 1004 additions and 495 deletions

View file

@ -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,

View file

@ -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,

View file

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

View file

@ -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,

View file

@ -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",

View file

@ -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,

View file

@ -412,7 +412,7 @@ describe("Remote transport e2e", () => {
{ key: "X-Trace", value: "abc123" },
],
env: [],
metadata: [],
metadata: {},
connectionTimeout: 0,
requestTimeout: 0,
taskTtl: 0,