* Tools: x-mcp-header annotation tooling + -32602 unknown-tool rendering (#1632) Card 9/10 of the SDK-v2 + New Spec stack. Adds SEP-2243 `x-mcp-header` tooling to the Tools tab and adjusts result rendering for SDK v2's unknown-tool behavior, and bumps the SDK to 2.0.0-beta.5. - **`core/json/xMcpHeader.ts`**: a faithful port of the SDK's (non-public) `scanXMcpHeaderDeclarations` — validates every SEP-2243 constraint (primitive types incl. `number`, RFC 9110 token, case-insensitive uniqueness, statically-reachable `properties`-only chain) and derives the mirrored `Mcp-Param-{Name}` params for a kept tool. - **Excluded tools**: the SDK silently drops tools with invalid annotations from `tools/list`. InspectorClient now re-lists the RAW (unfiltered) list and scans it to recompute the excluded set with reasons — gated to the modern non-stdio era (matching the SDK's gate), recomputed on every aggregate refresh, surfaced via a new `excludedToolsChange` event + `getExcludedTools()` and `useInspectorClient().excludedTools`. The Tools sidebar lists them struck-through under an "Excluded (SEP-2243)" divider with the reason on hover. - **Mirrored args**: ToolDetailPanel shows a "Mirrored request headers" section listing each arg → `Mcp-Param-{Name}` (with the browser-omits note). - **-32602 rendering**: SDK v2 rejects an unknown-tool call with `-32602` instead of an `isError` result, which previously rendered nothing. New ToolCallErrorPanel renders a thrown tool-call error, with a targeted "Unknown Tool" hint for `-32602`. Adds unit + Storybook + integration coverage (a real modern server with an invalid-header tool fixture) for every path. `npm run ci`-equivalent checks green. Closes #1632 Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01XNdjEPKLG637X8YmhDiEk5 * test-servers: x-mcp-header modern showcase config + preset (#1632) Register the `invalid_header_tool` fixture as a preset and add `xmcpheader-modern-http.json` — a modern HTTP showcase serving echo, a valid-header `get_weather`, and the invalid-header tool — so the Tools-tab x-mcp-header tooling (mirrored headers, excluded-tools surface, -32602 rendering) can be exercised manually. Documented in the README alongside the other per-SEP showcase configs. Smoke-tested end to end in the web client. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01XNdjEPKLG637X8YmhDiEk5 * docs: add #1632 proof screenshots to pr-screenshots End-to-end web-client verification of the x-mcp-header Tools tooling against the modern showcase server: excluded-tools surface + reason, mirrored-headers section, and the -32602 unknown-tool error panel. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01XNdjEPKLG637X8YmhDiEk5 * Respond to review: disambiguate -32602, doc/search nits (#1632) Addresses the review on PR #1735: 1. -32602 "Unknown Tool" mislabel: `-32602` is generic Invalid params, so a known tool called with bad arguments hit the same code and rendered under the definitive "Unknown Tool" heading. Replace `isUnknownToolError` with `classifyToolCallError(code, message)` → "unknown-tool" only when the message names it (not found / unknown tool / …), else "invalid-params" (new "Invalid Parameters" heading + schema hint), else "generic". 2. Double pagination walk: expand the `listAllTools` comment to document that the excluded-tools recompute is a deliberately un-cached second raw walk (doubles list round-trips on modern non-stdio, runs even on a cache-served aggregate) — accepted for a debugging tool, no-op on legacy/stdio. 3. Doc nit: `scanXMcpHeaderDeclarations` no longer claims to visit "$ref targets" — reworded to note `$ref` is never followed (refs resolve into the unreachable `$defs` body). 4. Search nit: the excluded-list filter now matches name AND title, matching the main tools filter. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01XNdjEPKLG637X8YmhDiEk5 * Tighten unknown-tool matcher to be tool-scoped (#1632 re-review nit) The re-review noted the -32602 disambiguation was symmetric: broad markers ("does not exist", "unrecognized") could inversely mislabel a genuine invalid-params message (e.g. "property 'region' does not exist") as "Unknown Tool". Scope the not-found/does-not-exist family to require the word "tool" in the same clause (matching the SDK's "Tool <name> not found"); keep "unknown tool" / "no such tool" as standalone unambiguous markers. Adds a tool-less-"does not exist" test asserting it classifies as invalid-params. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01XNdjEPKLG637X8YmhDiEk5 * Add trigger_invalid_params showcase tool + Invalid Parameters screenshot (#1632) Adds a `trigger_invalid_params` spec-error trigger to the modern showcase server: the injector answers its `tools/call` with a real `-32602 Invalid params` JSON-RPC error whose message does NOT name an unknown tool. This lets the "Invalid Parameters" branch of the tool-call error panel (added in the review response) be exercised live, distinct from the unknown-tool `-32602`. - `SPEC_ERROR_TRIGGERS` gains the `trigger_invalid_params` entry (HTTP 200, in-band -32602) + preset registration. - Enable `injectSpecErrors` on `xmcpheader-modern-http.json` and add the tool. - README + pr-screenshots README document it; adds the live "Invalid Parameters" proof screenshot. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01XNdjEPKLG637X8YmhDiEk5 --------- Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
225 lines
8.5 KiB
TypeScript
225 lines
8.5 KiB
TypeScript
/**
|
|
* SEP-2243 `x-mcp-header` annotation tooling.
|
|
*
|
|
* A modern (≥2026-07-28) Streamable HTTP server may annotate a tool
|
|
* `inputSchema` property with `x-mcp-header: "{Name}"`; a conforming client then
|
|
* mirrors that argument's value into an `Mcp-Param-{Name}` HTTP header on the
|
|
* `tools/call`. The spec places strict constraints on which properties may carry
|
|
* the annotation, and — crucially for a debugging tool — makes a *violating*
|
|
* annotation invalidate the WHOLE tool: a Streamable HTTP client MUST drop such
|
|
* a tool from `tools/list`.
|
|
*
|
|
* The client SDK enforces that exclusion internally (its `listTools()` silently
|
|
* filters invalid tools), but it does not surface *which* tools were dropped or
|
|
* *why*. This module re-implements the SDK's scan so the Inspector can show
|
|
* excluded tools with their reason, and indicate which args mirror to headers on
|
|
* the tools it keeps. It is a faithful port of the SDK's
|
|
* `scanXMcpHeaderDeclarations` (the helper is not part of the SDK's public
|
|
* surface), kept pure and fully unit-testable — no rendering, no I/O.
|
|
*/
|
|
|
|
import type { Tool } from "@modelcontextprotocol/client";
|
|
|
|
/** The schema-extension property name a tool's `inputSchema` carries. */
|
|
export const X_MCP_HEADER_KEY = "x-mcp-header";
|
|
|
|
/** The fixed prefix every mirrored custom-parameter header carries. */
|
|
export const MCP_PARAM_HEADER_PREFIX = "Mcp-Param-";
|
|
|
|
/**
|
|
* RFC 9110 §5.1 `token` syntax (`1*tchar`). Rejects empty, space, control
|
|
* characters (including CR/LF), and the listed HTTP delimiters.
|
|
*/
|
|
const RFC9110_TOKEN = /^[!#$%&'*+\-.^_`|~0-9A-Za-z]+$/;
|
|
|
|
/**
|
|
* JSON Schema `type` values the spec admits on an `x-mcp-header` property.
|
|
*
|
|
* The spec text names `integer`, `string`, `boolean` and explicitly excludes
|
|
* `number`. The published conformance referee at the pinned release ships its
|
|
* `http-custom-headers` scenario with `type: "number"` `x-mcp-header` params and
|
|
* expects the client to mirror them, so the SDK accepts `number` for the
|
|
* conformance gate; this port matches the SDK so exclusions agree exactly.
|
|
* Everything else (`object`, `array`, `null`, absent) is rejected.
|
|
*/
|
|
const PERMITTED_X_MCP_HEADER_TYPES: ReadonlySet<string> = new Set([
|
|
"string",
|
|
"integer",
|
|
"boolean",
|
|
"number",
|
|
]);
|
|
|
|
/**
|
|
* JSON Schema keywords whose subschemas the static-reachability constraint
|
|
* excludes from the `properties`-only chain. An `x-mcp-header` found under any
|
|
* of these invalidates the tool definition.
|
|
*/
|
|
const NON_REACHABLE_SUBSCHEMA_KEYWORDS = [
|
|
"items",
|
|
"prefixItems",
|
|
"contains",
|
|
"additionalProperties",
|
|
"unevaluatedProperties",
|
|
"unevaluatedItems",
|
|
"propertyNames",
|
|
"patternProperties",
|
|
"dependentSchemas",
|
|
"oneOf",
|
|
"anyOf",
|
|
"allOf",
|
|
"not",
|
|
"if",
|
|
"then",
|
|
"else",
|
|
"$defs",
|
|
"definitions",
|
|
] as const;
|
|
|
|
/**
|
|
* Subschema-carrying keywords whose value is a `name → subschema` object (not a
|
|
* single subschema or array of subschemas). The visit branches over
|
|
* `Object.values()` for these.
|
|
*/
|
|
const OBJECT_VALUED_SUBSCHEMA_KEYWORDS: ReadonlySet<string> = new Set([
|
|
"patternProperties",
|
|
"dependentSchemas",
|
|
"$defs",
|
|
"definitions",
|
|
]);
|
|
|
|
/** One validated `x-mcp-header` declaration found on a tool's input schema. */
|
|
export interface XMcpHeaderDeclaration {
|
|
/** The chain of `properties` keys locating the annotated property. */
|
|
path: string[];
|
|
/** The declared header suffix — the `{Name}` in `Mcp-Param-{Name}`. */
|
|
headerName: string;
|
|
/** The property's JSON Schema `type` (a permitted primitive). */
|
|
type: string;
|
|
}
|
|
|
|
/** The result of scanning a tool's input schema for `x-mcp-header` usage. */
|
|
export type XMcpHeaderScan =
|
|
| { valid: true; declarations: XMcpHeaderDeclaration[] }
|
|
| { valid: false; reason: string };
|
|
|
|
function pathName(path: string[]): string {
|
|
return path.length === 0 ? "<root>" : path.join(".");
|
|
}
|
|
|
|
function isRecord(node: unknown): node is Record<string, unknown> {
|
|
return node !== null && typeof node === "object";
|
|
}
|
|
|
|
/**
|
|
* Scan a tool's `inputSchema` for `x-mcp-header` declarations and validate every
|
|
* constraint the spec places on them. Returns the collected declarations
|
|
* (possibly empty) on success, or the first violated constraint's reason.
|
|
*
|
|
* The walk descends through `properties` at any depth (the spec's "any nesting
|
|
* depth" clause). The static-reachability MUST is enforced structurally: every
|
|
* position the chain MUST NOT pass through (`items`/`additionalProperties`,
|
|
* `oneOf`/`anyOf`/`allOf`/`not`, `if`/`then`/`else`, and `$defs`/`definitions`
|
|
* bodies) is visited too, and an `x-mcp-header` found anywhere off the
|
|
* `properties` chain invalidates the schema — "an annotation anywhere else makes
|
|
* the tool definition invalid". `$ref` is never followed: a property reachable
|
|
* only through a `$ref` is therefore correctly treated as non-statically-
|
|
* reachable (its annotation, if any, lives in the unreachable `$defs` body).
|
|
*/
|
|
export function scanXMcpHeaderDeclarations(
|
|
inputSchema: unknown,
|
|
): XMcpHeaderScan {
|
|
const declarations: XMcpHeaderDeclaration[] = [];
|
|
const seenLower = new Map<string, string>();
|
|
|
|
const visit = (
|
|
node: unknown,
|
|
path: string[],
|
|
reachable: boolean,
|
|
): string | undefined => {
|
|
if (!isRecord(node)) return undefined;
|
|
const schema = node;
|
|
|
|
if (X_MCP_HEADER_KEY in schema) {
|
|
if (!reachable || path.length === 0) {
|
|
return `${pathName(path)}: x-mcp-header is only permitted on properties statically reachable via a chain of 'properties' keys (not under items, additionalProperties, oneOf/anyOf/allOf/not, if/then/else, or $ref)`;
|
|
}
|
|
const raw = schema[X_MCP_HEADER_KEY];
|
|
if (typeof raw !== "string" || raw.length === 0) {
|
|
return `${pathName(path)}: x-mcp-header MUST be a non-empty string`;
|
|
}
|
|
if (!RFC9110_TOKEN.test(raw)) {
|
|
return `${pathName(path)}: x-mcp-header '${raw}' is not a valid RFC 9110 token (no spaces, control characters or HTTP delimiters)`;
|
|
}
|
|
const type = typeof schema.type === "string" ? schema.type : undefined;
|
|
if (type === undefined || !PERMITTED_X_MCP_HEADER_TYPES.has(type)) {
|
|
return `${pathName(path)}: x-mcp-header is only permitted on primitive-typed properties (string, integer, boolean); got ${type ?? "<none>"}`;
|
|
}
|
|
const lower = raw.toLowerCase();
|
|
const prior = seenLower.get(lower);
|
|
if (prior !== undefined) {
|
|
return `x-mcp-header '${raw}' is not case-insensitively unique (also declared as '${prior}')`;
|
|
}
|
|
seenLower.set(lower, raw);
|
|
declarations.push({ path, headerName: raw, type });
|
|
}
|
|
|
|
const properties = schema.properties;
|
|
if (isRecord(properties)) {
|
|
for (const [key, child] of Object.entries(properties)) {
|
|
const fault = visit(child, [...path, key], reachable);
|
|
if (fault !== undefined) return fault;
|
|
}
|
|
}
|
|
|
|
for (const k of NON_REACHABLE_SUBSCHEMA_KEYWORDS) {
|
|
const sub = schema[k];
|
|
if (sub === undefined) continue;
|
|
const branches = Array.isArray(sub)
|
|
? sub
|
|
: isRecord(sub) && OBJECT_VALUED_SUBSCHEMA_KEYWORDS.has(k)
|
|
? Object.values(sub)
|
|
: [sub];
|
|
for (const branch of branches) {
|
|
const fault = visit(branch, [...path, `<${k}>`], false);
|
|
if (fault !== undefined) return fault;
|
|
}
|
|
}
|
|
|
|
return undefined;
|
|
};
|
|
|
|
const fault = visit(inputSchema, [], true);
|
|
return fault === undefined
|
|
? { valid: true, declarations }
|
|
: { valid: false, reason: fault };
|
|
}
|
|
|
|
/** A tool the Inspector keeps, paired with its mirrored-header declarations. */
|
|
export interface MirroredHeaderParam {
|
|
/** Dot-joined property path (e.g. `region` or `filter.city`). */
|
|
path: string;
|
|
/** The full header a conforming client sends: `Mcp-Param-{Name}`. */
|
|
header: string;
|
|
/** The declared header suffix. */
|
|
headerName: string;
|
|
/** The property's JSON Schema primitive type. */
|
|
type: string;
|
|
}
|
|
|
|
/**
|
|
* The mirrored-header params for a tool the Inspector kept (its annotations are
|
|
* all valid). Returns `[]` when the tool declares no `x-mcp-header`, and — since
|
|
* a caller only reaches here for *kept* tools — also `[]` for the (unreachable
|
|
* for kept tools) invalid case. Each entry names the arg and the
|
|
* `Mcp-Param-{Name}` header its value mirrors to on a `tools/call`.
|
|
*/
|
|
export function getMirroredHeaderParams(tool: Tool): MirroredHeaderParam[] {
|
|
const scan = scanXMcpHeaderDeclarations(tool.inputSchema);
|
|
if (!scan.valid) return [];
|
|
return scan.declarations.map((d) => ({
|
|
path: d.path.join("."),
|
|
header: `${MCP_PARAM_HEADER_PREFIX}${d.headerName}`,
|
|
headerName: d.headerName,
|
|
type: d.type,
|
|
}));
|
|
}
|