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

View file

@ -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<string, string>;
metadata?: RequestMetadata;
}) => ({ tools: drainPages(this.toolPages, "tools") }),
);
listAllPrompts = vi.fn(
async (_options?: {
cacheMode?: CacheMode;
metadata?: Record<string, string>;
metadata?: RequestMetadata;
}) => ({ prompts: drainPages(this.promptPages, "prompts") }),
);
listAllResources = vi.fn(
async (_options?: {
cacheMode?: CacheMode;
metadata?: Record<string, string>;
metadata?: RequestMetadata;
}) => ({ resources: drainPages(this.resourcePages, "resources") }),
);
listAllResourceTemplates = vi.fn(
async (_options?: {
cacheMode?: CacheMode;
metadata?: Record<string, string>;
metadata?: RequestMetadata;
}) => ({
resourceTemplates: drainPages(
this.resourceTemplatePages,
@ -224,7 +225,7 @@ export class FakeInspectorClient
_argumentName: string,
_argumentValue: string,
_context?: Record<string, string>,
_metadata?: Record<string, string>,
_metadata?: RequestMetadata,
): Promise<{ values: string[]; total?: number; hasMore?: boolean }> => ({
values: [],
}),

View file

@ -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<string, JsonValue>;
generalMetadata?: Record<string, string>;
toolSpecificMetadata?: Record<string, string>;
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<string, string> | 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<string, string>,
callMetadata?: RequestMetadata,
): Record<string, string> | 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<string, string>,
metadata?: RequestMetadata,
): { _meta: Record<string, string> } | 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<string, string>,
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<string, string>;
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<T[]>;
metadata?: Record<string, string>;
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<T>;
resultSchema: z.ZodObject;
metadata?: Record<string, string>;
metadata?: RequestMetadata;
err: unknown;
}): Promise<T[]> {
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<string, string>,
metadata?: RequestMetadata,
): Promise<ExcludedTool[]> {
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<string, string> | 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<string, JsonValue>,
generalMetadata?: Record<string, string>,
toolSpecificMetadata?: Record<string, string>,
generalMetadata?: RequestMetadata,
toolSpecificMetadata?: RequestMetadata,
taskOptions?: { ttl?: number },
options?: { skipOutputValidation?: boolean },
): Promise<ToolCallInvocation> {
@ -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<string, string> | 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<string, JsonValue>,
generalMetadata: Record<string, string> | undefined,
toolSpecificMetadata: Record<string, string> | undefined,
generalMetadata: RequestMetadata | undefined,
toolSpecificMetadata: RequestMetadata | undefined,
errorMessage: string,
): void {
const callMetadata: Record<string, string> | 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<string, JsonValue>,
generalMetadata?: Record<string, string>,
toolSpecificMetadata?: Record<string, string>,
generalMetadata?: RequestMetadata,
toolSpecificMetadata?: RequestMetadata,
taskOptions?: { ttl?: number },
): Promise<ToolCallInvocation> {
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<string, string> | 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<string, string> | 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<string, string>,
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<string, string>;
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<string, string>,
metadata?: RequestMetadata,
): Promise<ResourceReadInvocation> {
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<string, string>,
metadata?: Record<string, string>,
metadata?: RequestMetadata,
): Promise<ResourceTemplateReadInvocation> {
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<string, string>,
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<string, string>;
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<string, string>,
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<string, string>;
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<string, JsonValue>,
metadata?: Record<string, string>,
metadata?: RequestMetadata,
): Promise<PromptGetInvocation> {
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<string, string>,
metadata?: Record<string, string>,
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",

View file

@ -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<string, string>;
metadata?: RequestMetadata;
/** Non-fatal outputSchema mismatch detected on the skipOutputValidation path. */
outputValidationError?: string;
};

View file

@ -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<string, string>,
metadata?: RequestMetadata,
): Promise<{ tools: Tool[]; nextCursor?: string }>;
listPrompts(
cursor?: string,
metadata?: Record<string, string>,
metadata?: RequestMetadata,
): Promise<{ prompts: Prompt[]; nextCursor?: string }>;
listResources(
cursor?: string,
metadata?: Record<string, string>,
metadata?: RequestMetadata,
): Promise<{ resources: Resource[]; nextCursor?: string }>;
listResourceTemplates(
cursor?: string,
metadata?: Record<string, string>,
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<string, string>;
metadata?: RequestMetadata;
}): Promise<{ tools: Tool[] }>;
listAllPrompts(options?: {
cacheMode?: CacheMode;
metadata?: Record<string, string>;
metadata?: RequestMetadata;
}): Promise<{ prompts: Prompt[] }>;
listAllResources(options?: {
cacheMode?: CacheMode;
metadata?: Record<string, string>;
metadata?: RequestMetadata;
}): Promise<{ resources: Resource[] }>;
listAllResourceTemplates(options?: {
cacheMode?: CacheMode;
metadata?: Record<string, string>;
metadata?: RequestMetadata;
}): Promise<{ resourceTemplates: ResourceTemplate[] }>;
// Invocation methods used by paged result panels
callTool(
tool: Tool,
args: Record<string, JsonValue>,
generalMetadata?: Record<string, string>,
toolSpecificMetadata?: Record<string, string>,
generalMetadata?: RequestMetadata,
toolSpecificMetadata?: RequestMetadata,
): Promise<ToolCallInvocation>;
readResource(
uri: string,
metadata?: Record<string, string>,
metadata?: RequestMetadata,
): Promise<ResourceReadInvocation>;
readResourceFromTemplate(
uriTemplate: string,
params: Record<string, string>,
metadata?: Record<string, string>,
metadata?: RequestMetadata,
): Promise<ResourceTemplateReadInvocation>;
getPrompt(
name: string,
params?: Record<string, string>,
metadata?: Record<string, string>,
metadata?: RequestMetadata,
): Promise<PromptGetInvocation>;
// Misc surface required by hooks/state
@ -182,6 +183,6 @@ export interface InspectorClientProtocol extends InspectorClientEventTarget {
argumentName: string,
argumentValue: string,
context?: Record<string, string>,
metadata?: Record<string, string>,
metadata?: RequestMetadata,
): Promise<{ values: string[]; total?: number; hasMore?: boolean }>;
}

View file

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

View file

@ -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<string, string> => {
if (v === null || typeof v !== "object" || Array.isArray(v)) return false;
for (const val of Object.values(v as Record<string, unknown>)) {
@ -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

View file

@ -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) {

View file

@ -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<T, M extends ManagedListEventMap> {
fetchAll: (
client: InspectorClientProtocol,
cacheMode: CacheMode | undefined,
metadata: Record<string, string> | undefined,
metadata: RequestMetadata | undefined,
) => Promise<T[]>;
/**
* 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<string, string> | 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<string, string>): 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<string, string>,
metadata?: RequestMetadata,
cacheMode?: CacheMode,
): Promise<T[]> {
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<string, string> | undefined,
metadata: RequestMetadata | undefined,
cacheMode: CacheMode | undefined,
): Promise<T[] | null> {
const client = this.client;

View file

@ -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<PagedPromptsStateEventMa
async loadPage(
cursor?: string,
metadata?: Record<string, string>,
metadata?: RequestMetadata,
): Promise<LoadPageResult> {
const c = this.client;
if (!c || c.getStatus() !== "connected") {

View file

@ -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<PagedResourceT
async loadPage(
cursor?: string,
metadata?: Record<string, string>,
metadata?: RequestMetadata,
): Promise<LoadPageResult> {
const c = this.client;
if (!c || c.getStatus() !== "connected") {

View file

@ -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<PagedResourcesStateEve
async loadPage(
cursor?: string,
metadata?: Record<string, string>,
metadata?: RequestMetadata,
): Promise<LoadPageResult> {
const c = this.client;
if (!c || c.getStatus() !== "connected") {

View file

@ -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<string, string>;
/**
* 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<string, JsonValue>;
/**
* 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<string, string>;
metadata?: RequestMetadata;
}
/**
@ -382,7 +404,7 @@ export interface ResourceTemplateReadInvocation {
result: ReadResourceResult;
timestamp: Date;
params: Record<string, string>;
metadata?: Record<string, string>;
metadata?: RequestMetadata;
}
/**
@ -394,7 +416,7 @@ export interface PromptGetInvocation {
timestamp: Date;
name: string;
params?: Record<string, string>;
metadata?: Record<string, string>;
metadata?: RequestMetadata;
}
/**
@ -408,7 +430,7 @@ export interface ToolCallInvocation {
timestamp: Date;
success: boolean;
error?: string;
metadata?: Record<string, string>;
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<string, string>;
defaultMetadata?: RequestMetadata;
/**
* Optional per-server runtime settings forwarded to the transport factory