inspector/core/json/xMcpHeader.ts
Cliff Hall e70a11360c
Tools: x-mcp-header annotation tooling + -32602 unknown-tool rendering (#1632) (#1735)
* 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>
2026-07-22 11:42:54 -04:00

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,
}));
}