diff --git a/AGENTS.md b/AGENTS.md index 4f5a91393..6a90172d3 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -31,6 +31,9 @@ inspector/ │ │ │ # runner-interactive-oauth loopback callback flow) │ │ └── remote/ # Remote OAuth storage (delegates to the remote server) │ ├── json/ # JSON utilities and parameter/argument conversion +│ │ # (xMcpHeader.ts: SEP-2243 `x-mcp-header` +│ │ # annotation scan/validation + mirrored-param +│ │ # derivation, used by the Tools tab — #1632) │ ├── logging/ # Silent pino logger singleton │ ├── mcp/ # InspectorClient runtime + state stores │ │ # (modernTaskSchemas.ts: SEP-2663 modern Tasks diff --git a/README.md b/README.md index 148202b31..07b0afbf7 100644 --- a/README.md +++ b/README.md @@ -114,11 +114,13 @@ A streamable-HTTP server can also serve the **modern (2026-07-28) protocol era** `test-servers/configs/modern-network-http.json` is the **Network-tab showcase** for the standardized HTTP headers and new error taxonomy (SEP-2243 / SEP-2575). It serves a `get_weather` tool whose `city` argument carries an `x-mcp-header: "City"` annotation (so a modern client mirrors it to `Mcp-Param-City`), plus four `trigger_*` tools that the modern leg's spec-error injector (`transport.modern.injectSpecErrors: true`) answers with a real HTTP status + JSON-RPC error body: `trigger_header_mismatch` → `400 / -32020`, `trigger_missing_capability` → `400 / -32021`, `trigger_unsupported_version` → `400 / -32022` (with `data.supported`), `trigger_method_not_found` → `404 / -32601`. Connect to it with **Protocol Era = Modern** and open the Network tab to see the mirrored `Mcp-*` headers highlighted, sentinel values decoded, and each error rendered distinctly. Note: `Mcp-Param-*` mirroring is **skipped by the SDK in the browser** (`detectProbeEnvironment() !== "browser"`), so calling `get_weather` from the **web** client omits `Mcp-Param-City` and the strict server answers `-32020` — the same tool is callable from the Node CLI/TUI, where mirroring is active. +`test-servers/configs/xmcpheader-modern-http.json` is the **`x-mcp-header` Tools-tab showcase** (#1632). It serves `echo`, a `get_weather` tool with a **valid** `x-mcp-header: "City"` annotation on its `city` argument, an `invalid_header_tool` whose annotation uses the header name `"Bad Header"` (a space makes it an invalid RFC 9110 token, so the whole tool definition is invalid), and a `trigger_invalid_params` tool that the modern leg's spec-error injector (`transport.modern.injectSpecErrors: true`) answers with a real `-32602 Invalid params` JSON-RPC error whose message is not about a missing tool. Connect with **Protocol Era = Modern** and open the Tools tab: `get_weather`'s detail panel shows a **"Mirrored request headers (SEP-2243)"** section (`city → Mcp-Param-City`), and `invalid_header_tool` appears struck-through under an **"Excluded (SEP-2243)"** divider in the sidebar with the reason on hover (a conforming Streamable HTTP client MUST drop it from `tools/list`; the Inspector surfaces _why_). Under SDK v2 a `tools/call` that rejects with **`-32602`** now renders as a distinct error panel rather than an `isError` result — headed **"Unknown Tool"** when the message names a missing tool (reproduce by calling a tool the server dropped from its list), or **"Invalid Parameters"** for any other `-32602` (run `trigger_invalid_params`). + `test-servers/configs/pagination-http.json` is the **page-by-page fetch showcase** (#1721). It serves 12 tools, 12 resources, and 12 prompts (presets `numbered_tools` / `numbered_resources` / `numbered_prompts`, `count: 12`) with `maxPageSize` of 4 for each, so every list paginates into three pages. Turn on **"Fetch Lists One Page at a Time"** (Server Settings — the `paginatedLists` setting, or the **Paginated** switch in a list sidebar) and the Tools/Resources/Prompts lists load page 1 only (4 items) with a **Load next page** control and an _N pages loaded_ status; each click fetches the next 4 and appends them, and Refresh resets to page 1. With the switch off (the default), the same lists auto-aggregate all three pages on connect. `test-servers/configs/logging-legacy-http.json` and `test-servers/configs/logging-modern-http.json` are the **logging era-fork showcase** (#1629). Both serve `logging: true` plus a `send_notification` tool that emits a `notifications/message` at a chosen level; the legacy one is a plain streamable-HTTP server (`logging/setLevel` era) and the modern one sets `transport.modern: true`. Connect to the legacy server and open the **Logs** tab to get the session-scoped **Set Active Level** selector + **Set** button; calling `send_notification` streams the log into the panel. Connect to the modern one with **Protocol Era = Modern** and the same tab instead shows the **Log Level per Request** control — pick a level to opt in and the client stamps `_meta["io.modelcontextprotocol/logLevel"]` on every subsequent request (verify in the Network tab's request body); calling `send_notification` then streams the log into the panel over the request's SSE response. Set the control back to **Off** and the same call is silently gated — the request omits the `logLevel` key, so the log never arrives. That gating is faithful to the spec ("a server MUST NOT emit `notifications/message` for a request that didn't opt in") because `send_notification` emits through the SDK's request-scoped, threshold-aware `extra.log` (`ctx.mcpReq.log`): on the modern leg it reads the per-request `logLevel` opt-in from the request envelope and drops the message when the client didn't opt in or the level is below the requested severity; on legacy it honors the session level from `logging/setLevel`. Because it emits through the request's `notify`, the modern response upgrades to SSE and the log rides the originating request's stream. -`test-servers/configs/subscriptions-legacy-http.json` and `test-servers/configs/subscriptions-modern-http.json` are the **resource-subscription era-fork showcase** (#1630). Both serve three `numbered_resources` with `subscriptions: true`; the legacy one also serves an `update_resource` tool, and the modern one sets `transport.modern: true`. Connect to the **legacy** server, open a resource in the **Resources** tab and click **Subscribe** — the client sends `resources/subscribe` (Network/Protocol view) and the Subscriptions section lists the URI with no stream chrome; call `update_resource` with that URI and the server updates the content and emits `notifications/resources/updated`, stamping the subscribed tile's last-updated time. Connect to the **modern** server with **Protocol Era = Modern** and the same Subscribe instead sends **`subscriptions/listen`** (its filter carries `resourceSubscriptions` + the `resourcesListChanged` opt-in) and resolves on `notifications/subscriptions/acknowledged`; the Subscriptions section then shows the stream-status badge (`Connecting…` → `Listening`) in its header, and if the long-lived stream drops it reconnects by re-listing. The modern config deliberately **omits** `update_resource`: the SDK's modern leg is stateless/per-request (`createMcpHandler(() => createMcpServer(config))`), so the tool would run against a throwaway server instance — the content change wouldn't persist for the next `resources/read`, and its `resources/updated` wouldn't reach the (separate) listen stream — which is more confusing than useful. The live update-notification round-trip is therefore demonstrated on the legacy (stateful-session) server; the modern server is for the subscribe/listen/badge behavior. (The Inspector's *receive* path is era-transparent, so a real stateful modern server that routes `resources/updated` onto the listen stream drives the subscribed tile the same way.) +`test-servers/configs/subscriptions-legacy-http.json` and `test-servers/configs/subscriptions-modern-http.json` are the **resource-subscription era-fork showcase** (#1630). Both serve three `numbered_resources` with `subscriptions: true`; the legacy one also serves an `update_resource` tool, and the modern one sets `transport.modern: true`. Connect to the **legacy** server, open a resource in the **Resources** tab and click **Subscribe** — the client sends `resources/subscribe` (Network/Protocol view) and the Subscriptions section lists the URI with no stream chrome; call `update_resource` with that URI and the server updates the content and emits `notifications/resources/updated`, stamping the subscribed tile's last-updated time. Connect to the **modern** server with **Protocol Era = Modern** and the same Subscribe instead sends **`subscriptions/listen`** (its filter carries `resourceSubscriptions` + the `resourcesListChanged` opt-in) and resolves on `notifications/subscriptions/acknowledged`; the Subscriptions section then shows the stream-status badge (`Connecting…` → `Listening`) in its header, and if the long-lived stream drops it reconnects by re-listing. The modern config deliberately **omits** `update_resource`: the SDK's modern leg is stateless/per-request (`createMcpHandler(() => createMcpServer(config))`), so the tool would run against a throwaway server instance — the content change wouldn't persist for the next `resources/read`, and its `resources/updated` wouldn't reach the (separate) listen stream — which is more confusing than useful. The live update-notification round-trip is therefore demonstrated on the legacy (stateful-session) server; the modern server is for the subscribe/listen/badge behavior. (The Inspector's _receive_ path is era-transparent, so a real stateful modern server that routes `resources/updated` onto the listen stream drives the subscribed tile the same way.) `test-servers/configs/tasks-legacy-http.json` and `test-servers/configs/tasks-modern-http.json` are the **Tasks era-fork showcase** (#1631). The legacy server advertises `capabilities.tasks` (`tasks: { list, cancel }`) with the `simple_task` / `progress_task` / `elicitation_task` presets — connect to it, run one of those tools with **Run as task** on, and the **Tasks** tab lists it (populated via `tasks/list`), polls `tasks/get`, fetches the payload with the blocking `tasks/result`, and cancels with `tasks/cancel`. The modern server sets `transport.modern: true` and `tasksExtension: true`, advertising the `io.modelcontextprotocol/tasks` extension (SEP-2663) and serving the `modern_task` / `modern_input_task` tools. Connect with **Protocol Era = Modern**: the **Tasks** tab is now gated on the negotiated extension (not `capabilities.tasks`). Run `modern_task` as a task — the `tools/call` returns a `CreateTaskResult` (`resultType: "task"`, visible in the Protocol/Network tabs), the client polls **`tasks/get`** (no `tasks/list`), and the completed task inlines its result (no blocking `tasks/result`). Run `modern_input_task` and the task moves to `input_required`, surfacing an embedded elicitation through the pending-request modal; answering it sends **`tasks/update`** with the `inputResponses`, and the next poll completes. SDK v2 removed all tasks support **and** era-gates the `tasks/*` spec methods out of the modern era on both sides — so the Inspector drives the extension itself (the `resultType: "task"` frame is rewritten at the transport into a `CallToolResult` carrying the handle; `tasks/get`/`update`/`cancel` ride a raw-wire request channel with the full modern envelope), and the test server serves `tasks/*` from an Express interceptor ahead of the SDK handler (the SDK's modern leg would answer them `-32601`). The Tasks tab's **Refresh** re-polls the handles already known to the client (modern has no server-side task list). diff --git a/clients/cli/package-lock.json b/clients/cli/package-lock.json index b53941412..2760136d3 100644 --- a/clients/cli/package-lock.json +++ b/clients/cli/package-lock.json @@ -8,10 +8,10 @@ "name": "@modelcontextprotocol/inspector-cli", "license": "MIT", "dependencies": { - "@modelcontextprotocol/client": "2.0.0-beta.4", - "@modelcontextprotocol/core": "2.0.0-beta.4", - "@modelcontextprotocol/server": "2.0.0-beta.4", - "@modelcontextprotocol/server-legacy": "2.0.0-beta.4", + "@modelcontextprotocol/client": "2.0.0-beta.5", + "@modelcontextprotocol/core": "2.0.0-beta.5", + "@modelcontextprotocol/server": "2.0.0-beta.5", + "@modelcontextprotocol/server-legacy": "2.0.0-beta.5", "@napi-rs/keyring": "^1.3.0", "ajv": "^8.17.1", "atomically": "^2.1.1", @@ -859,12 +859,12 @@ } }, "node_modules/@modelcontextprotocol/client": { - "version": "2.0.0-beta.4", - "resolved": "https://registry.npmjs.org/@modelcontextprotocol/client/-/client-2.0.0-beta.4.tgz", - "integrity": "sha512-VNHA/UXDk7mCpVl+jOg5B4WMRRD2OEl+it360Lhi6HiCbrIB2f6pZ0cqSDKXQVUtZvqnhdQHzZAM9h8EKWhq/A==", + "version": "2.0.0-beta.5", + "resolved": "https://registry.npmjs.org/@modelcontextprotocol/client/-/client-2.0.0-beta.5.tgz", + "integrity": "sha512-YuuNm5f2TMoFQRje1UqVP8TJRjijCXMz4ckvoVpx1cUXuBEmykWQ2d8R536pek6UKcXT41T5nWc4qR1JFIbEmg==", "license": "MIT", "dependencies": { - "@modelcontextprotocol/core": "2.0.0-beta.4", + "@modelcontextprotocol/core": "2.0.0-beta.5", "cross-spawn": "^7.0.5", "eventsource": "^3.0.2", "eventsource-parser": "^3.0.0", @@ -877,9 +877,9 @@ } }, "node_modules/@modelcontextprotocol/core": { - "version": "2.0.0-beta.4", - "resolved": "https://registry.npmjs.org/@modelcontextprotocol/core/-/core-2.0.0-beta.4.tgz", - "integrity": "sha512-nsMXd4wQBKzmph6r+WOhum+mXjDYljTAqwY/XUg3hLtvNOQ8+JVqBSJOVCMJvx9lXhTpTOPrGZ3BuNiaNPjSvg==", + "version": "2.0.0-beta.5", + "resolved": "https://registry.npmjs.org/@modelcontextprotocol/core/-/core-2.0.0-beta.5.tgz", + "integrity": "sha512-HKbY9XTbsDy1Y6r2I55TGE3JEapM0vg96e1MUmBIF9LGjos5gjhcIrTz1yvBPLg2aFKHjwhUAQfRdrCEnPxNew==", "license": "MIT", "dependencies": { "zod": "^4.2.0" @@ -889,12 +889,12 @@ } }, "node_modules/@modelcontextprotocol/server": { - "version": "2.0.0-beta.4", - "resolved": "https://registry.npmjs.org/@modelcontextprotocol/server/-/server-2.0.0-beta.4.tgz", - "integrity": "sha512-pjMZcNEt1dOq0aJCcY3b7w1Ayh+qmQN2xlfTELcGV9yiAjEGIczBPBLWWW0k0zzo5T8kiv15jtKPsWeHGxLvLg==", + "version": "2.0.0-beta.5", + "resolved": "https://registry.npmjs.org/@modelcontextprotocol/server/-/server-2.0.0-beta.5.tgz", + "integrity": "sha512-i1E5l75rQKsgY/AKAIspgMBH1vEL7dqiK7tHr0L+raYcb0SWOziqNGJXGIG6NY4AlXDWIKGJQGB7Nqfs3oUi5g==", "license": "MIT", "dependencies": { - "@modelcontextprotocol/core": "2.0.0-beta.4", + "@modelcontextprotocol/core": "2.0.0-beta.5", "zod": "^4.2.0" }, "engines": { @@ -902,13 +902,13 @@ } }, "node_modules/@modelcontextprotocol/server-legacy": { - "version": "2.0.0-beta.4", - "resolved": "https://registry.npmjs.org/@modelcontextprotocol/server-legacy/-/server-legacy-2.0.0-beta.4.tgz", - "integrity": "sha512-GaEa7mfjTqevGuz2taFj2fqb3FthxFvWOmoI/wbLmiK1XRnxdYsAaaJG/lgSlA+W6XWJ1+u+6oZ7610TowkjMA==", + "version": "2.0.0-beta.5", + "resolved": "https://registry.npmjs.org/@modelcontextprotocol/server-legacy/-/server-legacy-2.0.0-beta.5.tgz", + "integrity": "sha512-8BemN4avQnG6Fu660fZCqnPGpeyL7gg5kxUceZQh7JCt8oqzX1bwJkNV+cKS01LPAaPbl93INneD+mBtJWKWvQ==", "deprecated": "This package is a frozen copy of v1's SSE transport and OAuth Authorization Server helpers for migration purposes only. Use StreamableHTTP from @modelcontextprotocol/server and a dedicated OAuth server in production. Will not receive new features.", "license": "MIT", "dependencies": { - "@modelcontextprotocol/core": "2.0.0-beta.4", + "@modelcontextprotocol/core": "2.0.0-beta.5", "content-type": "^1.0.5", "cors": "^2.8.5", "express-rate-limit": "^8.2.1", diff --git a/clients/cli/package.json b/clients/cli/package.json index e3042d708..05667e5ca 100644 --- a/clients/cli/package.json +++ b/clients/cli/package.json @@ -31,10 +31,10 @@ "format:check": "prettier --check src __tests__" }, "dependencies": { - "@modelcontextprotocol/client": "2.0.0-beta.4", - "@modelcontextprotocol/core": "2.0.0-beta.4", - "@modelcontextprotocol/server": "2.0.0-beta.4", - "@modelcontextprotocol/server-legacy": "2.0.0-beta.4", + "@modelcontextprotocol/client": "2.0.0-beta.5", + "@modelcontextprotocol/core": "2.0.0-beta.5", + "@modelcontextprotocol/server": "2.0.0-beta.5", + "@modelcontextprotocol/server-legacy": "2.0.0-beta.5", "@napi-rs/keyring": "^1.3.0", "ajv": "^8.17.1", "atomically": "^2.1.1", diff --git a/clients/tui/package-lock.json b/clients/tui/package-lock.json index eafc93123..1f24c6f61 100644 --- a/clients/tui/package-lock.json +++ b/clients/tui/package-lock.json @@ -8,8 +8,8 @@ "name": "@modelcontextprotocol/inspector-tui", "license": "MIT", "dependencies": { - "@modelcontextprotocol/client": "2.0.0-beta.4", - "@modelcontextprotocol/core": "2.0.0-beta.4", + "@modelcontextprotocol/client": "2.0.0-beta.5", + "@modelcontextprotocol/core": "2.0.0-beta.5", "@napi-rs/keyring": "^1.3.0", "ajv": "^8.17.1", "atomically": "^2.1.1", @@ -1108,12 +1108,12 @@ } }, "node_modules/@modelcontextprotocol/client": { - "version": "2.0.0-beta.4", - "resolved": "https://registry.npmjs.org/@modelcontextprotocol/client/-/client-2.0.0-beta.4.tgz", - "integrity": "sha512-VNHA/UXDk7mCpVl+jOg5B4WMRRD2OEl+it360Lhi6HiCbrIB2f6pZ0cqSDKXQVUtZvqnhdQHzZAM9h8EKWhq/A==", + "version": "2.0.0-beta.5", + "resolved": "https://registry.npmjs.org/@modelcontextprotocol/client/-/client-2.0.0-beta.5.tgz", + "integrity": "sha512-YuuNm5f2TMoFQRje1UqVP8TJRjijCXMz4ckvoVpx1cUXuBEmykWQ2d8R536pek6UKcXT41T5nWc4qR1JFIbEmg==", "license": "MIT", "dependencies": { - "@modelcontextprotocol/core": "2.0.0-beta.4", + "@modelcontextprotocol/core": "2.0.0-beta.5", "cross-spawn": "^7.0.5", "eventsource": "^3.0.2", "eventsource-parser": "^3.0.0", @@ -1126,9 +1126,9 @@ } }, "node_modules/@modelcontextprotocol/core": { - "version": "2.0.0-beta.4", - "resolved": "https://registry.npmjs.org/@modelcontextprotocol/core/-/core-2.0.0-beta.4.tgz", - "integrity": "sha512-nsMXd4wQBKzmph6r+WOhum+mXjDYljTAqwY/XUg3hLtvNOQ8+JVqBSJOVCMJvx9lXhTpTOPrGZ3BuNiaNPjSvg==", + "version": "2.0.0-beta.5", + "resolved": "https://registry.npmjs.org/@modelcontextprotocol/core/-/core-2.0.0-beta.5.tgz", + "integrity": "sha512-HKbY9XTbsDy1Y6r2I55TGE3JEapM0vg96e1MUmBIF9LGjos5gjhcIrTz1yvBPLg2aFKHjwhUAQfRdrCEnPxNew==", "license": "MIT", "dependencies": { "zod": "^4.2.0" diff --git a/clients/tui/package.json b/clients/tui/package.json index 7da8e824f..265aee96b 100644 --- a/clients/tui/package.json +++ b/clients/tui/package.json @@ -26,8 +26,8 @@ "format:check": "prettier --check src __tests__ index.ts tui.tsx dev.ts" }, "dependencies": { - "@modelcontextprotocol/client": "2.0.0-beta.4", - "@modelcontextprotocol/core": "2.0.0-beta.4", + "@modelcontextprotocol/client": "2.0.0-beta.5", + "@modelcontextprotocol/core": "2.0.0-beta.5", "@napi-rs/keyring": "^1.3.0", "ajv": "^8.17.1", "atomically": "^2.1.1", diff --git a/clients/web/package-lock.json b/clients/web/package-lock.json index eeca14daa..3fa91d91f 100644 --- a/clients/web/package-lock.json +++ b/clients/web/package-lock.json @@ -16,11 +16,11 @@ "@mantine/form": "^8.3.17", "@mantine/hooks": "^8.3.17", "@mantine/notifications": "^8.3.17", - "@modelcontextprotocol/client": "2.0.0-beta.4", - "@modelcontextprotocol/core": "2.0.0-beta.4", + "@modelcontextprotocol/client": "2.0.0-beta.5", + "@modelcontextprotocol/core": "2.0.0-beta.5", "@modelcontextprotocol/ext-apps": "^1.7.4", - "@modelcontextprotocol/server": "2.0.0-beta.4", - "@modelcontextprotocol/server-legacy": "2.0.0-beta.4", + "@modelcontextprotocol/server": "2.0.0-beta.5", + "@modelcontextprotocol/server-legacy": "2.0.0-beta.5", "@napi-rs/keyring": "^1.3.0", "ajv": "^8.17.1", "atomically": "^2.1.1", @@ -1441,12 +1441,12 @@ } }, "node_modules/@modelcontextprotocol/client": { - "version": "2.0.0-beta.4", - "resolved": "https://registry.npmjs.org/@modelcontextprotocol/client/-/client-2.0.0-beta.4.tgz", - "integrity": "sha512-VNHA/UXDk7mCpVl+jOg5B4WMRRD2OEl+it360Lhi6HiCbrIB2f6pZ0cqSDKXQVUtZvqnhdQHzZAM9h8EKWhq/A==", + "version": "2.0.0-beta.5", + "resolved": "https://registry.npmjs.org/@modelcontextprotocol/client/-/client-2.0.0-beta.5.tgz", + "integrity": "sha512-YuuNm5f2TMoFQRje1UqVP8TJRjijCXMz4ckvoVpx1cUXuBEmykWQ2d8R536pek6UKcXT41T5nWc4qR1JFIbEmg==", "license": "MIT", "dependencies": { - "@modelcontextprotocol/core": "2.0.0-beta.4", + "@modelcontextprotocol/core": "2.0.0-beta.5", "cross-spawn": "^7.0.5", "eventsource": "^3.0.2", "eventsource-parser": "^3.0.0", @@ -1459,9 +1459,9 @@ } }, "node_modules/@modelcontextprotocol/core": { - "version": "2.0.0-beta.4", - "resolved": "https://registry.npmjs.org/@modelcontextprotocol/core/-/core-2.0.0-beta.4.tgz", - "integrity": "sha512-nsMXd4wQBKzmph6r+WOhum+mXjDYljTAqwY/XUg3hLtvNOQ8+JVqBSJOVCMJvx9lXhTpTOPrGZ3BuNiaNPjSvg==", + "version": "2.0.0-beta.5", + "resolved": "https://registry.npmjs.org/@modelcontextprotocol/core/-/core-2.0.0-beta.5.tgz", + "integrity": "sha512-HKbY9XTbsDy1Y6r2I55TGE3JEapM0vg96e1MUmBIF9LGjos5gjhcIrTz1yvBPLg2aFKHjwhUAQfRdrCEnPxNew==", "license": "MIT", "dependencies": { "zod": "^4.2.0" @@ -1541,12 +1541,12 @@ } }, "node_modules/@modelcontextprotocol/server": { - "version": "2.0.0-beta.4", - "resolved": "https://registry.npmjs.org/@modelcontextprotocol/server/-/server-2.0.0-beta.4.tgz", - "integrity": "sha512-pjMZcNEt1dOq0aJCcY3b7w1Ayh+qmQN2xlfTELcGV9yiAjEGIczBPBLWWW0k0zzo5T8kiv15jtKPsWeHGxLvLg==", + "version": "2.0.0-beta.5", + "resolved": "https://registry.npmjs.org/@modelcontextprotocol/server/-/server-2.0.0-beta.5.tgz", + "integrity": "sha512-i1E5l75rQKsgY/AKAIspgMBH1vEL7dqiK7tHr0L+raYcb0SWOziqNGJXGIG6NY4AlXDWIKGJQGB7Nqfs3oUi5g==", "license": "MIT", "dependencies": { - "@modelcontextprotocol/core": "2.0.0-beta.4", + "@modelcontextprotocol/core": "2.0.0-beta.5", "zod": "^4.2.0" }, "engines": { @@ -1554,13 +1554,13 @@ } }, "node_modules/@modelcontextprotocol/server-legacy": { - "version": "2.0.0-beta.4", - "resolved": "https://registry.npmjs.org/@modelcontextprotocol/server-legacy/-/server-legacy-2.0.0-beta.4.tgz", - "integrity": "sha512-GaEa7mfjTqevGuz2taFj2fqb3FthxFvWOmoI/wbLmiK1XRnxdYsAaaJG/lgSlA+W6XWJ1+u+6oZ7610TowkjMA==", + "version": "2.0.0-beta.5", + "resolved": "https://registry.npmjs.org/@modelcontextprotocol/server-legacy/-/server-legacy-2.0.0-beta.5.tgz", + "integrity": "sha512-8BemN4avQnG6Fu660fZCqnPGpeyL7gg5kxUceZQh7JCt8oqzX1bwJkNV+cKS01LPAaPbl93INneD+mBtJWKWvQ==", "deprecated": "This package is a frozen copy of v1's SSE transport and OAuth Authorization Server helpers for migration purposes only. Use StreamableHTTP from @modelcontextprotocol/server and a dedicated OAuth server in production. Will not receive new features.", "license": "MIT", "dependencies": { - "@modelcontextprotocol/core": "2.0.0-beta.4", + "@modelcontextprotocol/core": "2.0.0-beta.5", "content-type": "^1.0.5", "cors": "^2.8.5", "express-rate-limit": "^8.2.1", diff --git a/clients/web/package.json b/clients/web/package.json index b76b8c361..684fa2796 100644 --- a/clients/web/package.json +++ b/clients/web/package.json @@ -41,11 +41,11 @@ "@mantine/form": "^8.3.17", "@mantine/hooks": "^8.3.17", "@mantine/notifications": "^8.3.17", - "@modelcontextprotocol/client": "2.0.0-beta.4", - "@modelcontextprotocol/core": "2.0.0-beta.4", + "@modelcontextprotocol/client": "2.0.0-beta.5", + "@modelcontextprotocol/core": "2.0.0-beta.5", "@modelcontextprotocol/ext-apps": "^1.7.4", - "@modelcontextprotocol/server": "2.0.0-beta.4", - "@modelcontextprotocol/server-legacy": "2.0.0-beta.4", + "@modelcontextprotocol/server": "2.0.0-beta.5", + "@modelcontextprotocol/server-legacy": "2.0.0-beta.5", "@napi-rs/keyring": "^1.3.0", "ajv": "^8.17.1", "atomically": "^2.1.1", diff --git a/clients/web/src/App.test.tsx b/clients/web/src/App.test.tsx index 1ffd72eea..b1d650093 100644 --- a/clients/web/src/App.test.tsx +++ b/clients/web/src/App.test.tsx @@ -563,6 +563,7 @@ const DEFAULT_USE_INSPECTOR_CLIENT: ReturnType = { clientCapabilities: {}, serverInfo: undefined, instructions: undefined, + excludedTools: [], appRendererClient: null, connect: vi.fn().mockResolvedValue(undefined), disconnect: vi.fn().mockResolvedValue(undefined), diff --git a/clients/web/src/App.tsx b/clients/web/src/App.tsx index ce2fe159a..985bed1c8 100644 --- a/clients/web/src/App.tsx +++ b/clients/web/src/App.tsx @@ -463,6 +463,18 @@ function errorMessage(err: unknown): string { return err instanceof Error ? err.message : String(err); } +// The numeric JSON-RPC code of a thrown protocol error (e.g. a `ProtocolError` +// carrying `-32602`), or undefined for a plain Error. Duck-typed like +// `formatErrorDetails` so we don't couple to the SDK's error class here — the +// only consumer is the Tools error panel's unknown-tool (`-32602`) hint (#1632). +function errorCodeOf(err: unknown): number | undefined { + if (err && typeof err === "object") { + const code = (err as { code?: unknown }).code; + if (typeof code === "number") return code; + } + return undefined; +} + // Pretty-print a thrown error for the URL-elicitation details modal: a ProtocolError // carries a `code`/`data` worth showing alongside the message, so include them // when present; otherwise fall back to the plain message. @@ -935,6 +947,7 @@ function App() { protocolVersion, protocolEra, discoverResult, + excludedTools, lastError, } = useInspectorClient(inspectorClient); const { @@ -2933,6 +2946,7 @@ function App() { setToolCallState({ status: "error", error: errorMessage(err), + errorCode: errorCodeOf(err), }); } }, @@ -4198,6 +4212,7 @@ function App() { initializeResult={initializeResult} latencyMs={latencyMs} tools={tools} + excludedTools={excludedTools} prompts={prompts} resources={resources} resourceTemplates={resourceTemplates} diff --git a/clients/web/src/components/groups/ToolControls/ToolControls.test.tsx b/clients/web/src/components/groups/ToolControls/ToolControls.test.tsx index 1e0290aac..c586e12c7 100644 --- a/clients/web/src/components/groups/ToolControls/ToolControls.test.tsx +++ b/clients/web/src/components/groups/ToolControls/ToolControls.test.tsx @@ -135,4 +135,52 @@ describe("ToolControls", () => { expect(screen.getByText("Tools")).toBeInTheDocument(); expect(screen.queryByText("git_status")).not.toBeInTheDocument(); }); + + const excludedFixture = [ + { + tool: { + name: "invalid_header_tool", + inputSchema: { type: "object" as const }, + }, + reason: + "value: x-mcp-header 'Bad Header' is not a valid RFC 9110 token (no spaces, control characters or HTTP delimiters)", + }, + ]; + + it("renders excluded tools with a header and the tool name (#1632)", () => { + renderWithMantine( + , + ); + expect(screen.getByText("Excluded (SEP-2243)")).toBeInTheDocument(); + expect(screen.getByText("invalid_header_tool")).toBeInTheDocument(); + }); + + it("does not render the excluded section when there are none", () => { + renderWithMantine(); + expect(screen.queryByText("Excluded (SEP-2243)")).not.toBeInTheDocument(); + }); + + it("filters excluded tools by the search text (#1632)", () => { + renderWithMantine( + , + ); + // The search matches no excluded tool, so the section is hidden. + expect(screen.queryByText("Excluded (SEP-2243)")).not.toBeInTheDocument(); + expect(screen.queryByText("invalid_header_tool")).not.toBeInTheDocument(); + }); + + it("keeps a matching excluded tool visible under search (#1632)", () => { + renderWithMantine( + , + ); + expect(screen.getByText("invalid_header_tool")).toBeInTheDocument(); + }); }); diff --git a/clients/web/src/components/groups/ToolControls/ToolControls.tsx b/clients/web/src/components/groups/ToolControls/ToolControls.tsx index dd7ca5c83..805ce90c5 100644 --- a/clients/web/src/components/groups/ToolControls/ToolControls.tsx +++ b/clients/web/src/components/groups/ToolControls/ToolControls.tsx @@ -1,6 +1,18 @@ -import { Group, ScrollArea, Stack, TextInput, Title } from "@mantine/core"; +import { + Divider, + Group, + ScrollArea, + Stack, + Text, + TextInput, + ThemeIcon, + Title, + Tooltip, +} from "@mantine/core"; +import { RiErrorWarningLine } from "react-icons/ri"; import { ClearButton } from "../../elements/ClearButton/ClearButton"; import type { Tool } from "@modelcontextprotocol/client"; +import type { ExcludedTool } from "@inspector/core/mcp/types.js"; import { ListChangedIndicator } from "../../elements/ListChangedIndicator/ListChangedIndicator"; import { ListPaginationControls, @@ -11,6 +23,9 @@ import { useScrollMemory } from "../../../hooks/useScrollMemory"; export interface ToolControlsProps { tools: Tool[]; + /** Tools the SDK excluded from `tools/list` for invalid `x-mcp-header` + * annotations (SEP-2243), shown below the list with the reason (#1632). */ + excludedTools?: ExcludedTool[]; selectedName?: string; // Search text is controlled by the parent (App, via ToolsScreen) so it // persists across tab navigation within a live session — see #1417. @@ -23,8 +38,31 @@ export interface ToolControlsProps { onSelectTool: (name: string) => void; } +// One excluded tool: a warning icon, the tool name (struck through, since it is +// not callable), and its reason on hover. `wrap: nowrap` keeps the icon pinned. +const ExcludedRow = Group.withProps({ + gap: "xs", + wrap: "nowrap", + align: "center", +}); + +const ExcludedWarningIcon = ThemeIcon.withProps({ + size: "sm", + variant: "transparent", + c: "var(--inspector-log-warning)", + "aria-hidden": true, +}); + +const ExcludedName = Text.withProps({ + size: "sm", + td: "line-through", + c: "var(--inspector-text-secondary)", + truncate: "end", +}); + export function ToolControls({ tools, + excludedTools = [], selectedName, searchText = "", listChanged, @@ -42,6 +80,15 @@ export function ToolControls({ (tool.title?.toLowerCase().includes(query) ?? false), ) : tools; + // Excluded tools are searchable too, matching name AND title like the main + // list above, so a filtered view stays consistent. + const filteredExcluded = searchText + ? excludedTools.filter( + ({ tool }) => + tool.name.toLowerCase().includes(query) || + (tool.title?.toLowerCase().includes(query) ?? false), + ) + : excludedTools; return ( // Fill the full-height `sidebar` Card (a flex column) so the scroll region @@ -81,6 +128,32 @@ export function ToolControls({ }} /> ))} + {filteredExcluded.length > 0 && ( + <> + + {filteredExcluded.map(({ tool, reason }) => ( + + + + + + {tool.name} + + + ))} + + )} diff --git a/clients/web/src/components/groups/ToolDetailPanel/ToolDetailPanel.test.tsx b/clients/web/src/components/groups/ToolDetailPanel/ToolDetailPanel.test.tsx index e1a4591e7..feb434f2b 100644 --- a/clients/web/src/components/groups/ToolDetailPanel/ToolDetailPanel.test.tsx +++ b/clients/web/src/components/groups/ToolDetailPanel/ToolDetailPanel.test.tsx @@ -469,4 +469,37 @@ describe("ToolDetailPanel", () => { expect(onExecute).toHaveBeenCalledWith(false); }); }); + + describe("mirrored request headers (SEP-2243, #1632)", () => { + const mirroredTool: Tool = { + name: "get_weather", + inputSchema: { + type: "object", + properties: { + city: { type: "string", "x-mcp-header": "City" }, + country: { type: "string", "x-mcp-header": "Country" }, + }, + }, + }; + + it("lists each mirrored arg and its Mcp-Param header", () => { + renderWithMantine(); + expect( + screen.getByText("Mirrored request headers (SEP-2243)"), + ).toBeInTheDocument(); + // The header names are unique to this section (the arg names `city` / + // `country` also appear as form-field labels below). + expect(screen.getByText("Mcp-Param-City")).toBeInTheDocument(); + expect(screen.getByText("Mcp-Param-Country")).toBeInTheDocument(); + // The arg path renders in a alongside its header. + expect(screen.getAllByText("city").length).toBeGreaterThanOrEqual(1); + }); + + it("omits the section for a tool without x-mcp-header annotations", () => { + renderWithMantine(); + expect( + screen.queryByText("Mirrored request headers (SEP-2243)"), + ).not.toBeInTheDocument(); + }); + }); }); diff --git a/clients/web/src/components/groups/ToolDetailPanel/ToolDetailPanel.tsx b/clients/web/src/components/groups/ToolDetailPanel/ToolDetailPanel.tsx index 6ff8a3bb7..64b0ef12a 100644 --- a/clients/web/src/components/groups/ToolDetailPanel/ToolDetailPanel.tsx +++ b/clients/web/src/components/groups/ToolDetailPanel/ToolDetailPanel.tsx @@ -1,6 +1,7 @@ import { ActionIcon, Button, + Code, Collapse, Divider, Group, @@ -19,6 +20,7 @@ import type { } from "@modelcontextprotocol/client"; import { resolveDisplayLabel } from "../../../utils/toolUtils"; import { toFormSchema } from "../../../utils/jsonUtils"; +import { getMirroredHeaderParams } from "@inspector/core/json/xMcpHeader.js"; import { AnnotationBadge } from "../../elements/AnnotationBadge/AnnotationBadge"; import { ProgressDisplay } from "../../elements/ProgressDisplay/ProgressDisplay"; import { SchemaForm } from "../SchemaForm/SchemaForm"; @@ -139,6 +141,34 @@ const RunAsTaskSwitch = Switch.withProps({ label: "Run as task", }); +// Header-mirroring section (SEP-2243): lists which args mirror their value into +// an `Mcp-Param-{Name}` header on `tools/call`. `Stack` (not `Box`) so the +// constant can carry props; the heading + note pin above the mapping rows. +const HeaderParamsSection = Stack.withProps({ + gap: "xs", +}); + +const HeaderParamsTitle = Text.withProps({ + size: "sm", + fw: 600, +}); + +const HeaderParamsNote = Text.withProps({ + size: "xs", + c: "var(--inspector-text-secondary)", +}); + +// One `arg → Mcp-Param-{Name}` mapping row. +const HeaderParamRow = Group.withProps({ + gap: "xs", + wrap: "nowrap", +}); + +const HeaderParamArrow = Text.withProps({ + size: "sm", + c: "var(--inspector-text-secondary)", +}); + // A tool's per-tool task support, defaulting to "forbidden" (the SDK default // when `execution` is absent) so tools that say nothing can't be run as tasks. type TaskSupport = "forbidden" | "optional" | "required"; @@ -173,6 +203,9 @@ export function ToolDetailPanel({ // Narrow the SDK protocol schema to the form renderer's schema type. const formSchema = toFormSchema(inputSchema) ?? {}; const iconSrc = icons?.[0]?.src; + // SEP-2243: args this tool declares as `x-mcp-header` — their values mirror + // into `Mcp-Param-{Name}` headers on a `tools/call` (#1632). + const mirroredParams = getMirroredHeaderParams(tool); // Descriptions are shown by default (most are short); the chevron lets the // user hide a long one to keep the form and Execute footer in view. Reset to @@ -259,6 +292,26 @@ export function ToolDetailPanel({ + {mirroredParams.length > 0 && ( + + + Mirrored request headers (SEP-2243) + + {mirroredParams.map((param) => ( + + {param.path} + + {param.header} + + ))} + + These argument values are mirrored into HTTP headers on the + call. The SDK sends them only on a Node/proxy transport — the + browser omits Mcp-Param-* headers. + + + )} + ( + + + + + + ), +]; + +const meta: Meta = { + title: "Groups/ToolCallErrorPanel", + component: ToolCallErrorPanel, + args: { + onClear: fn(), + }, + decorators: inCardDecorators, +}; + +export default meta; +type Story = StoryObj; + +// A generic thrown protocol/SDK error (no special code). +export const GenericError: Story = { + args: { + error: "MCP error -32603: Internal error while executing the tool", + }, +}; + +// SDK v2's unknown-tool rejection: `-32602` arrives as a thrown error, not an +// `isError` result, so it renders here with the targeted "Unknown Tool" hint. +export const UnknownTool: Story = { + args: { + error: "MCP error -32602: Invalid params: unknown tool 'ghost_tool'", + errorCode: -32602, + }, +}; diff --git a/clients/web/src/components/groups/ToolResultPanel/ToolCallErrorPanel.test.tsx b/clients/web/src/components/groups/ToolResultPanel/ToolCallErrorPanel.test.tsx new file mode 100644 index 000000000..cb1e2ec07 --- /dev/null +++ b/clients/web/src/components/groups/ToolResultPanel/ToolCallErrorPanel.test.tsx @@ -0,0 +1,78 @@ +import { describe, it, expect, vi } from "vitest"; +import userEvent from "@testing-library/user-event"; +import { renderWithMantine, screen } from "../../../test/renderWithMantine"; +import { ToolCallErrorPanel } from "./ToolCallErrorPanel"; +import { classifyToolCallError } from "./toolResultUtils"; + +describe("ToolCallErrorPanel", () => { + it("renders a generic thrown error with the plain title and message", () => { + renderWithMantine( + , + ); + expect(screen.getByText("Tool Call Failed")).toBeInTheDocument(); + expect(screen.getByText("Tool Error")).toBeInTheDocument(); + expect(screen.getByText("Internal error")).toBeInTheDocument(); + // No -32602 hints for a generic error. + expect(screen.queryByText(/does not recognize this tool/)).toBeNull(); + expect(screen.queryByText(/against the tool/)).toBeNull(); + }); + + it("renders the unknown-tool heading + hint when the message names it (#1632)", () => { + renderWithMantine( + , + ); + expect(screen.getByText("Unknown Tool")).toBeInTheDocument(); + expect( + screen.getByText(/does not recognize this tool/), + ).toBeInTheDocument(); + }); + + it("renders Invalid Parameters for a -32602 that is NOT an unknown tool (#1632)", () => { + // Same code, but the message is about bad arguments for a known tool — must + // not be mislabelled "Unknown Tool". + renderWithMantine( + , + ); + expect(screen.getByText("Invalid Parameters")).toBeInTheDocument(); + expect(screen.getByText(/against the tool/)).toBeInTheDocument(); + expect(screen.queryByText(/does not recognize this tool/)).toBeNull(); + }); + + it("invokes onClear when the close button is clicked", async () => { + const user = userEvent.setup(); + const onClear = vi.fn(); + renderWithMantine(); + await user.click(screen.getByRole("button", { name: "Close error" })); + expect(onClear).toHaveBeenCalledTimes(1); + }); + + it("classifyToolCallError narrows -32602 by message", () => { + expect(classifyToolCallError(-32602, "Tool foo not found")).toBe( + "unknown-tool", + ); + expect(classifyToolCallError(-32602, "unknown tool: foo")).toBe( + "unknown-tool", + ); + // -32602 without an unknown-tool marker → invalid params, not unknown tool. + expect(classifyToolCallError(-32602, "bad argument type")).toBe( + "invalid-params", + ); + expect(classifyToolCallError(-32602)).toBe("invalid-params"); + // A tool-less "does not exist" (arg-validation message) must NOT read as an + // unknown tool — the match is tool-scoped. + expect( + classifyToolCallError(-32602, "property 'region' does not exist in enum"), + ).toBe("invalid-params"); + // Any other code (or none) is generic. + expect(classifyToolCallError(-32601, "Tool not found")).toBe("generic"); + expect(classifyToolCallError(undefined)).toBe("generic"); + }); +}); diff --git a/clients/web/src/components/groups/ToolResultPanel/ToolCallErrorPanel.tsx b/clients/web/src/components/groups/ToolResultPanel/ToolCallErrorPanel.tsx new file mode 100644 index 000000000..821750a8e --- /dev/null +++ b/clients/web/src/components/groups/ToolResultPanel/ToolCallErrorPanel.tsx @@ -0,0 +1,99 @@ +import { + Alert, + CloseButton, + Code, + Group, + Stack, + Text, + Title, +} from "@mantine/core"; +import { classifyToolCallError } from "./toolResultUtils"; + +export interface ToolCallErrorPanelProps { + /** The thrown error's message (already stringified in App). */ + error: string; + /** + * The JSON-RPC error code, when the throw was a `ProtocolError`. Under SDK v2 + * an unknown-tool `tools/call` REJECTS with `-32602 Invalid params` instead of + * resolving an `isError` result, so it arrives here as a thrown error rather + * than a `CallToolResult` (which the ToolResultPanel would render). The same + * `-32602` is also thrown for a known tool called with invalid arguments, so + * the heading/hint are chosen from the message, not the code alone. + */ + errorCode?: number; + /** Dismiss the error and return to the input form (mirrors ToolResultPanel). */ + onClear: () => void; +} + +// Mirrors ToolResultPanel's column so an error dismisses the same way a result +// does: header with the close X pins, the alert fills and scrolls below it. +const PanelStack = Stack.withProps({ + gap: "md", + miw: 0, + mih: 0, + flex: 1, +}); + +const HeaderRow = Group.withProps({ + gap: "xs", + wrap: "nowrap", + flex: "0 0 auto", +}); + +const HintText = Text.withProps({ + size: "sm", + c: "var(--inspector-text-secondary)", +}); + +/** + * Renders a thrown tool-call error (a protocol/SDK-level rejection) as a + * distinct error panel. This is separate from ToolResultPanel, which renders a + * `CallToolResult` (including a tool-level `isError` result). An `-32602` + * rejection carries no result, so it would otherwise be invisible. + */ +const ERROR_TITLES: Record = { + "unknown-tool": "Unknown Tool", + "invalid-params": "Invalid Parameters", + generic: "Tool Error", +}; + +export function ToolCallErrorPanel({ + error, + errorCode, + onClear, +}: ToolCallErrorPanelProps) { + const kind = classifyToolCallError(errorCode, error); + return ( + + + + {/* h3 (not h4), size h4: request modals open over the Tools screen with + an `h2` `Modal.Title`, so an `h4` here would skip a level (axe + `heading-order`); `size="h4"` keeps the visual size. */} + + Tool Call Failed + + + + + {error} + {kind === "unknown-tool" && ( + + The server rejected this call with -32602 (Invalid + params) — it does not recognize this tool. It may have been + excluded for an invalid x-mcp-header annotation or + removed since the list was last fetched. Try refreshing the tools + list. + + )} + {kind === "invalid-params" && ( + + The server rejected this call with -32602 (Invalid + params). Check the argument values against the tool's schema. + + )} + + + + ); +} diff --git a/clients/web/src/components/groups/ToolResultPanel/toolResultUtils.ts b/clients/web/src/components/groups/ToolResultPanel/toolResultUtils.ts index 0ec0f6f78..b5f78c79f 100644 --- a/clients/web/src/components/groups/ToolResultPanel/toolResultUtils.ts +++ b/clients/web/src/components/groups/ToolResultPanel/toolResultUtils.ts @@ -1,4 +1,45 @@ import type { CallToolResult } from "@modelcontextprotocol/client"; +import { ProtocolErrorCode } from "@modelcontextprotocol/client"; + +/** How a thrown `tools/call` error should be presented in the error panel. */ +export type ToolCallErrorKind = "unknown-tool" | "invalid-params" | "generic"; + +/** + * Whether a `-32602` message names the *tool itself* as unrecognized, as + * opposed to reporting bad arguments for a known tool. Both reject with the same + * `-32602 Invalid params` code under SDK v2, so the code alone can't tell them + * apart — matching the message lets us pick the right heading instead of + * labelling every `-32602` "Unknown Tool" (which would mislabel a known tool + * called with invalid arguments). + * + * The match is deliberately tool-scoped so it doesn't INVERSELY mislabel: an + * argument-validation message like `"property 'region' does not exist"` must + * NOT read as "Unknown Tool". So the "not found / does not exist / unknown / + * unrecognized" family only counts when the word "tool" is in the same clause + * (the SDK's own message is `Tool not found`); `unknown tool` / `no such + * tool` are unambiguous on their own. Case-insensitive; best-effort — the fully + * unambiguous signal would be a tool name in `error.data`, which the SDK does + * not currently surface here. + */ +const UNKNOWN_TOOL_MESSAGE = + /\b(unknown tool|no such tool)\b|\btool\b[^.!?]*\b(not found|not recognized|does not exist|is unknown|unrecognized)\b/i; + +/** + * Classify a thrown tool-call error for display (#1632). Under SDK v2 an + * unknown-tool `tools/call` REJECTS with `-32602 Invalid params` instead of + * resolving an `isError` result — but so does a *known* tool called with + * invalid arguments (server-side schema validation). We narrow the ambiguous + * `-32602` to `"unknown-tool"` only when the message says so; any other + * `-32602` is `"invalid-params"`, and every other code is `"generic"`. + */ +export function classifyToolCallError( + errorCode?: number, + message?: string, +): ToolCallErrorKind { + if (errorCode !== ProtocolErrorCode.InvalidParams) return "generic"; + if (message && UNKNOWN_TOOL_MESSAGE.test(message)) return "unknown-tool"; + return "invalid-params"; +} /** * Whether a result renders a "Resource Links" box — i.e. a non-error result diff --git a/clients/web/src/components/screens/ToolsScreen/ToolsScreen.test.tsx b/clients/web/src/components/screens/ToolsScreen/ToolsScreen.test.tsx index 6643d949f..24cf06c26 100644 --- a/clients/web/src/components/screens/ToolsScreen/ToolsScreen.test.tsx +++ b/clients/web/src/components/screens/ToolsScreen/ToolsScreen.test.tsx @@ -319,4 +319,54 @@ describe("ToolsScreen", () => { await user.click(screen.getByRole("button", { name: "Close results" })); expect(screen.getByText("Results")).toBeInTheDocument(); }); + + it("renders a thrown error (no result) as an error panel (#1632)", () => { + renderWithMantine( + , + ); + expect(screen.getByText("Tool Call Failed")).toBeInTheDocument(); + expect(screen.getByText("boom")).toBeInTheDocument(); + }); + + it("renders the unknown-tool hint for a -32602 rejection (#1632)", async () => { + const user = userEvent.setup(); + const onClearResult = vi.fn(); + renderWithMantine( + , + ); + expect(screen.getByText("Unknown Tool")).toBeInTheDocument(); + await user.click(screen.getByRole("button", { name: "Close error" })); + expect(onClearResult).toHaveBeenCalledTimes(1); + }); + + it("renders excluded tools in the sidebar with the reason (#1632)", () => { + renderWithMantine( + , + ); + expect(screen.getByText("Excluded (SEP-2243)")).toBeInTheDocument(); + expect(screen.getByText("invalid_header_tool")).toBeInTheDocument(); + }); }); diff --git a/clients/web/src/components/screens/ToolsScreen/ToolsScreen.tsx b/clients/web/src/components/screens/ToolsScreen/ToolsScreen.tsx index d4570d365..7309df33a 100644 --- a/clients/web/src/components/screens/ToolsScreen/ToolsScreen.tsx +++ b/clients/web/src/components/screens/ToolsScreen/ToolsScreen.tsx @@ -4,6 +4,7 @@ import type { ReadResourceResult, Tool, } from "@modelcontextprotocol/client"; +import type { ExcludedTool } from "@inspector/core/mcp/types.js"; import { ToolControls } from "../../groups/ToolControls/ToolControls"; import type { ListPaginationControlsProps } from "../../elements/ListPaginationControls/ListPaginationControls"; import { @@ -11,6 +12,7 @@ import { type ToolProgress, } from "../../groups/ToolDetailPanel/ToolDetailPanel"; import { ToolResultPanel } from "../../groups/ToolResultPanel/ToolResultPanel"; +import { ToolCallErrorPanel } from "../../groups/ToolResultPanel/ToolCallErrorPanel"; import { resultHasResourceLinks } from "../../groups/ToolResultPanel/toolResultUtils"; import { collectSchemaDefaults, toFormSchema } from "../../../utils/jsonUtils"; @@ -18,6 +20,13 @@ export interface ToolCallState { status: "idle" | "pending" | "ok" | "error"; result?: CallToolResult; error?: string; + /** + * JSON-RPC error code when the call REJECTED (a thrown `ProtocolError`) rather + * than resolving a result. SDK v2 rejects an unknown-tool call with `-32602` + * instead of returning an `isError` result, so this drives the distinct + * "Unknown Tool" rendering in the error panel (#1632). + */ + errorCode?: number; progress?: ToolProgress; } @@ -38,6 +47,9 @@ export interface ToolsUiState { export interface ToolsScreenProps { tools: Tool[]; + /** Tools the SDK excluded from `tools/list` for invalid `x-mcp-header` + * annotations (SEP-2243), shown in the sidebar with the reason (#1632). */ + excludedTools?: ExcludedTool[]; callState?: ToolCallState; ui: ToolsUiState; listChanged: boolean; @@ -132,6 +144,7 @@ const EmptyState = Text.withProps({ export function ToolsScreen({ tools, + excludedTools, callState, ui, listChanged, @@ -157,6 +170,7 @@ export function ToolsScreen({ + ) : callState?.status === "error" && callState.error ? ( + // A thrown rejection (no result) — e.g. SDK v2's `-32602` unknown-tool + // reject, which no longer arrives as an `isError` CallToolResult. The X + // dismisses back to the form, like a result (#1632). + + + onClearResult?.()} + /> + + ) : selectedTool ? ( diff --git a/clients/web/src/components/views/InspectorView/InspectorView.tsx b/clients/web/src/components/views/InspectorView/InspectorView.tsx index 46672930c..f96e96bee 100644 --- a/clients/web/src/components/views/InspectorView/InspectorView.tsx +++ b/clients/web/src/components/views/InspectorView/InspectorView.tsx @@ -29,6 +29,7 @@ import type { } from "@modelcontextprotocol/client"; import type { ConnectionStatus, + ExcludedTool, FetchRequestEntry, InspectorResourceSubscription, MessageEntry, @@ -392,6 +393,9 @@ export interface InspectorViewProps { // Primitive lists, log streams, task state — all sourced from the // per-primitive `useManaged*` / `useMessageLog` hooks in the parent. tools: Tool[]; + /** Tools excluded from `tools/list` for invalid `x-mcp-header` annotations + * (SEP-2243); shown in the Tools sidebar with the reason (#1632). */ + excludedTools?: ExcludedTool[]; prompts: Prompt[]; resources: Resource[]; resourceTemplates: ResourceTemplate[]; @@ -594,6 +598,7 @@ export function InspectorView({ initializeResult, latencyMs, tools, + excludedTools = [], prompts, resources, resourceTemplates, @@ -1319,6 +1324,7 @@ export function InspectorView({ { expect(result.current.discoverResult).toEqual(discoverResult); }); + it("subscribes to excludedToolsChange and updates (#1632)", () => { + const client = new FakeInspectorClient(); + const { result } = renderHook(() => useInspectorClient(client)); + expect(result.current.excludedTools).toEqual([]); + const excluded = [ + { + tool: { name: "bad", inputSchema: { type: "object" as const } }, + reason: + "value: x-mcp-header 'Bad Header' is not a valid RFC 9110 token", + }, + ]; + act(() => { + client.setExcludedTools(excluded); + }); + expect(result.current.excludedTools).toEqual(excluded); + }); + + it("resets excludedTools to [] when client becomes null (#1632)", () => { + const client = new FakeInspectorClient({ status: "connected" }); + client.setExcludedTools([ + { + tool: { name: "bad", inputSchema: { type: "object" as const } }, + reason: "invalid", + }, + ]); + const { result, rerender } = renderHook(({ c }) => useInspectorClient(c), { + initialProps: { c: client as FakeInspectorClient | null }, + }); + expect(result.current.excludedTools.length).toBe(1); + rerender({ c: null }); + expect(result.current.excludedTools).toEqual([]); + }); + it("resets protocolEra / discoverResult to defaults when client becomes null", () => { const client = new FakeInspectorClient({ status: "connected", diff --git a/clients/web/src/test/core/xMcpHeader.test.ts b/clients/web/src/test/core/xMcpHeader.test.ts new file mode 100644 index 000000000..0f043da05 --- /dev/null +++ b/clients/web/src/test/core/xMcpHeader.test.ts @@ -0,0 +1,281 @@ +import { describe, it, expect } from "vitest"; +import { + scanXMcpHeaderDeclarations, + getMirroredHeaderParams, + MCP_PARAM_HEADER_PREFIX, + X_MCP_HEADER_KEY, +} from "@inspector/core/json/xMcpHeader.js"; +import type { Tool } from "@modelcontextprotocol/client"; + +function tool(inputSchema: Tool["inputSchema"]): Tool { + return { name: "t", inputSchema }; +} + +describe("scanXMcpHeaderDeclarations", () => { + it("returns valid with no declarations for a schema without annotations", () => { + const scan = scanXMcpHeaderDeclarations({ + type: "object", + properties: { a: { type: "string" }, b: { type: "integer" } }, + }); + expect(scan).toEqual({ valid: true, declarations: [] }); + }); + + it("returns valid for a non-object schema (nothing to scan)", () => { + expect(scanXMcpHeaderDeclarations(null)).toEqual({ + valid: true, + declarations: [], + }); + expect(scanXMcpHeaderDeclarations("nope")).toEqual({ + valid: true, + declarations: [], + }); + expect(scanXMcpHeaderDeclarations(undefined)).toEqual({ + valid: true, + declarations: [], + }); + }); + + it("collects a valid string declaration with its path and header name", () => { + const scan = scanXMcpHeaderDeclarations({ + type: "object", + properties: { + region: { type: "string", [X_MCP_HEADER_KEY]: "Region" }, + }, + }); + expect(scan).toEqual({ + valid: true, + declarations: [ + { path: ["region"], headerName: "Region", type: "string" }, + ], + }); + }); + + it("accepts integer, boolean, and number typed properties", () => { + const scan = scanXMcpHeaderDeclarations({ + type: "object", + properties: { + count: { type: "integer", [X_MCP_HEADER_KEY]: "Count" }, + flag: { type: "boolean", [X_MCP_HEADER_KEY]: "Flag" }, + ratio: { type: "number", [X_MCP_HEADER_KEY]: "Ratio" }, + }, + }); + expect(scan.valid).toBe(true); + if (scan.valid) { + expect(scan.declarations.map((d) => d.headerName)).toEqual([ + "Count", + "Flag", + "Ratio", + ]); + } + }); + + it("collects declarations nested at any depth via properties chains", () => { + const scan = scanXMcpHeaderDeclarations({ + type: "object", + properties: { + filter: { + type: "object", + properties: { + city: { type: "string", [X_MCP_HEADER_KEY]: "City" }, + }, + }, + }, + }); + expect(scan).toEqual({ + valid: true, + declarations: [ + { path: ["filter", "city"], headerName: "City", type: "string" }, + ], + }); + }); + + it("rejects an annotation on the root schema (empty path)", () => { + const scan = scanXMcpHeaderDeclarations({ + type: "object", + [X_MCP_HEADER_KEY]: "Root", + }); + expect(scan.valid).toBe(false); + if (!scan.valid) expect(scan.reason).toContain(""); + }); + + it("rejects a non-string annotation value", () => { + const scan = scanXMcpHeaderDeclarations({ + type: "object", + properties: { a: { type: "string", [X_MCP_HEADER_KEY]: 42 } }, + }); + expect(scan.valid).toBe(false); + if (!scan.valid) expect(scan.reason).toContain("non-empty string"); + }); + + it("rejects an empty-string annotation value", () => { + const scan = scanXMcpHeaderDeclarations({ + type: "object", + properties: { a: { type: "string", [X_MCP_HEADER_KEY]: "" } }, + }); + expect(scan.valid).toBe(false); + if (!scan.valid) expect(scan.reason).toContain("non-empty string"); + }); + + it("rejects a header name that is not an RFC 9110 token", () => { + const scan = scanXMcpHeaderDeclarations({ + type: "object", + properties: { + a: { type: "string", [X_MCP_HEADER_KEY]: "Not A Token" }, + }, + }); + expect(scan.valid).toBe(false); + if (!scan.valid) expect(scan.reason).toContain("RFC 9110 token"); + }); + + it("rejects a non-primitive typed property", () => { + const scan = scanXMcpHeaderDeclarations({ + type: "object", + properties: { + a: { type: "object", [X_MCP_HEADER_KEY]: "Obj" }, + }, + }); + expect(scan.valid).toBe(false); + if (!scan.valid) + expect(scan.reason).toContain("primitive-typed properties"); + }); + + it("rejects a property with a missing type", () => { + const scan = scanXMcpHeaderDeclarations({ + type: "object", + properties: { a: { [X_MCP_HEADER_KEY]: "NoType" } }, + }); + expect(scan.valid).toBe(false); + if (!scan.valid) expect(scan.reason).toContain(""); + }); + + it("rejects two headers that collide case-insensitively", () => { + const scan = scanXMcpHeaderDeclarations({ + type: "object", + properties: { + a: { type: "string", [X_MCP_HEADER_KEY]: "Region" }, + b: { type: "string", [X_MCP_HEADER_KEY]: "region" }, + }, + }); + expect(scan.valid).toBe(false); + if (!scan.valid) + expect(scan.reason).toContain("not case-insensitively unique"); + }); + + it("rejects an annotation reachable only under items (array element)", () => { + const scan = scanXMcpHeaderDeclarations({ + type: "object", + properties: { + list: { + type: "array", + items: { + type: "object", + properties: { + x: { type: "string", [X_MCP_HEADER_KEY]: "X" }, + }, + }, + }, + }, + }); + expect(scan.valid).toBe(false); + if (!scan.valid) expect(scan.reason).toContain("statically reachable"); + }); + + it("rejects an annotation under a oneOf branch (array-valued keyword)", () => { + const scan = scanXMcpHeaderDeclarations({ + type: "object", + properties: { + a: { + oneOf: [{ type: "string", [X_MCP_HEADER_KEY]: "A" }], + }, + }, + }); + expect(scan.valid).toBe(false); + if (!scan.valid) expect(scan.reason).toContain("statically reachable"); + }); + + it("rejects an annotation under additionalProperties (single subschema)", () => { + const scan = scanXMcpHeaderDeclarations({ + type: "object", + additionalProperties: { + type: "string", + [X_MCP_HEADER_KEY]: "Extra", + }, + }); + expect(scan.valid).toBe(false); + if (!scan.valid) expect(scan.reason).toContain("statically reachable"); + }); + + it("rejects an annotation under $defs (object-valued keyword)", () => { + const scan = scanXMcpHeaderDeclarations({ + type: "object", + $defs: { + Thing: { type: "string", [X_MCP_HEADER_KEY]: "Thing" }, + }, + }); + expect(scan.valid).toBe(false); + if (!scan.valid) expect(scan.reason).toContain("statically reachable"); + }); + + it("ignores non-schema (null) branch values in subschema keywords", () => { + const scan = scanXMcpHeaderDeclarations({ + type: "object", + properties: { + a: { type: "string", [X_MCP_HEADER_KEY]: "A" }, + }, + not: null, + }); + expect(scan.valid).toBe(true); + }); +}); + +describe("getMirroredHeaderParams", () => { + it("maps valid declarations to Mcp-Param-{Name} headers", () => { + const params = getMirroredHeaderParams( + tool({ + type: "object", + properties: { + region: { type: "string", [X_MCP_HEADER_KEY]: "Region" }, + filter: { + type: "object", + properties: { + city: { type: "string", [X_MCP_HEADER_KEY]: "City" }, + }, + }, + }, + }), + ); + expect(params).toEqual([ + { + path: "region", + header: `${MCP_PARAM_HEADER_PREFIX}Region`, + headerName: "Region", + type: "string", + }, + { + path: "filter.city", + header: `${MCP_PARAM_HEADER_PREFIX}City`, + headerName: "City", + type: "string", + }, + ]); + }); + + it("returns [] for a tool without annotations", () => { + expect( + getMirroredHeaderParams( + tool({ type: "object", properties: { a: { type: "string" } } }), + ), + ).toEqual([]); + }); + + it("returns [] for a tool whose annotations are invalid", () => { + expect( + getMirroredHeaderParams( + tool({ + type: "object", + properties: { a: { type: "object", [X_MCP_HEADER_KEY]: "A" } }, + }), + ), + ).toEqual([]); + }); +}); diff --git a/clients/web/src/test/integration/mcp/inspectorClient-excluded-tools.test.ts b/clients/web/src/test/integration/mcp/inspectorClient-excluded-tools.test.ts new file mode 100644 index 000000000..fa3f9843e --- /dev/null +++ b/clients/web/src/test/integration/mcp/inspectorClient-excluded-tools.test.ts @@ -0,0 +1,175 @@ +import { describe, it, expect, afterEach, vi } from "vitest"; +import { InspectorClient } from "@inspector/core/mcp/inspectorClient.js"; +import { createTransportNode } from "@inspector/core/mcp/node/transport.js"; +import { eraToVersionNegotiation } from "@inspector/core/mcp/types.js"; +import type { ExcludedTool } from "@inspector/core/mcp/types.js"; +import { + createTestServerHttp, + type TestServerHttp, + createTestServerInfo, + createEchoTool, + createGetWeatherTool, + createInvalidHeaderTool, +} from "@modelcontextprotocol/inspector-test-server"; +import type { ServerConfig } from "@modelcontextprotocol/inspector-test-server"; + +/** + * Live coverage of the SEP-2243 excluded-tools surface (#1632). A modern + * Streamable HTTP client MUST drop a tool whose `x-mcp-header` annotation + * violates the spec; the SDK does so silently. `refreshExcludedTools()` re-lists + * the RAW (unfiltered) `tools/list` and reports the dropped tools with the + * constraint they broke, so the Tools tab can show *why* a tool vanished. + */ +describe("excluded tools (SEP-2243 x-mcp-header)", () => { + let client: InspectorClient | null = null; + let server: TestServerHttp | null = null; + + afterEach(async () => { + if (client) { + try { + await client.disconnect(); + } catch { + // ignore + } + client = null; + } + if (server) { + try { + await server.stop(); + } catch { + // ignore + } + server = null; + } + }); + + async function start( + modern: ServerConfig["modern"] | undefined, + maxPageSize?: number, + ): Promise { + const started = createTestServerHttp({ + serverInfo: createTestServerInfo("excluded-tools-test", "1.0.0"), + // A valid tool, a valid-header tool, and an invalid-header tool. + tools: [ + createEchoTool(), + createGetWeatherTool(), + createInvalidHeaderTool(), + ], + ...(modern ? { modern } : {}), + ...(maxPageSize ? { maxPageSize: { tools: maxPageSize } } : {}), + }); + await started.start(); + server = started; + return started; + } + + async function connect( + url: string, + era: "legacy" | "modern", + ): Promise { + const connected = new InspectorClient( + { type: "streamable-http", url }, + { + environment: { transport: createTransportNode }, + versionNegotiation: eraToVersionNegotiation(era), + }, + ); + await connected.connect(); + client = connected; + return connected; + } + + it("excludes the invalid-header tool from the managed list on a modern connection", async () => { + const started = await start({}); + const connected = await connect(started.url, "modern"); + + const { tools } = await connected.listAllTools(); + const names = tools.map((t) => t.name); + expect(names).toContain("echo"); + expect(names).toContain("get_weather"); + // The SDK drops the invalid-header tool from the aggregated list. + expect(names).not.toContain("invalid_header_tool"); + }); + + it("surfaces the excluded tool with its reason after listAllTools (modern)", async () => { + const started = await start({}); + const connected = await connect(started.url, "modern"); + + const events: ExcludedTool[][] = []; + connected.addEventListener("excludedToolsChange", (e) => { + events.push(e.detail); + }); + + // listAllTools recomputes excluded tools as a side effect. + await connected.listAllTools(); + + const excluded = connected.getExcludedTools(); + expect(excluded.map((x) => x.tool.name)).toEqual(["invalid_header_tool"]); + expect(excluded[0]?.reason).toContain("RFC 9110 token"); + // The change event fired with the same set. + expect(events.at(-1)?.map((x) => x.tool.name)).toEqual([ + "invalid_header_tool", + ]); + }); + + it("refreshExcludedTools returns the excluded set directly (modern)", async () => { + const started = await start({}); + const connected = await connect(started.url, "modern"); + + const excluded = await connected.refreshExcludedTools(); + expect(excluded.map((x) => x.tool.name)).toEqual(["invalid_header_tool"]); + }); + + it("reports no excluded tools on a legacy connection (no exclusion there)", async () => { + const started = await start(undefined); + const connected = await connect(started.url, "legacy"); + + // Legacy servers don't exclude — the invalid-header tool stays in the list. + const { tools } = await connected.listAllTools(); + expect(tools.map((t) => t.name)).toContain("invalid_header_tool"); + expect(connected.getExcludedTools()).toEqual([]); + expect(await connected.refreshExcludedTools()).toEqual([]); + }); + + it("clears excluded tools on disconnect", async () => { + const started = await start({}); + const connected = await connect(started.url, "modern"); + await connected.listAllTools(); + expect(connected.getExcludedTools().length).toBe(1); + + await connected.disconnect(); + client = null; + expect(connected.getExcludedTools()).toEqual([]); + }); + + it("walks every raw page to find the excluded tool (pagination)", async () => { + // Page size 1 → the raw `tools/list` walk spans three pages; the invalid + // tool is on the last one, so this exercises the cursor loop. + const started = await start({}, 1); + const connected = await connect(started.url, "modern"); + + const excluded = await connected.refreshExcludedTools(); + expect(excluded.map((x) => x.tool.name)).toEqual(["invalid_header_tool"]); + }); + + it("listAllTools still returns tools if the excluded recompute throws", async () => { + const started = await start({}); + const connected = await connect(started.url, "modern"); + // The excluded-tools recompute is best-effort: a failure must not fail the + // tools list itself. + vi.spyOn(connected, "refreshExcludedTools").mockRejectedValueOnce( + new Error("boom"), + ); + const { tools } = await connected.listAllTools(); + expect(tools.map((t) => t.name)).toContain("echo"); + }); + + it("is a no-op returning [] before connect (not modern)", async () => { + const fresh = new InspectorClient( + { type: "streamable-http", url: "http://localhost:1/never" }, + { environment: { transport: createTransportNode } }, + ); + // No round trip: the gate is false (era not modern), so it returns []. + expect(await fresh.refreshExcludedTools()).toEqual([]); + }); +}); diff --git a/clients/web/vite.config.ts b/clients/web/vite.config.ts index eeaee2ea0..78e8a639c 100644 --- a/clients/web/vite.config.ts +++ b/clients/web/vite.config.ts @@ -105,6 +105,7 @@ export default defineConfig(({ command }) => { 'src/lib/**/*.{ts,tsx}', 'clients/web/server/**/*.{ts,tsx}', path.join(repoRoot, 'core/mcp/**/*.{ts,tsx}'), + path.join(repoRoot, 'core/json/xMcpHeader.ts'), path.join(repoRoot, 'core/react/**/*.{ts,tsx}'), path.join(repoRoot, 'core/auth/**/*.{ts,tsx}'), path.join(repoRoot, 'core/storage/**/*.{ts,tsx}'), diff --git a/core/json/xMcpHeader.ts b/core/json/xMcpHeader.ts new file mode 100644 index 000000000..97000dd83 --- /dev/null +++ b/core/json/xMcpHeader.ts @@ -0,0 +1,225 @@ +/** + * 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 = 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 = 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 ? "" : path.join("."); +} + +function isRecord(node: unknown): node is Record { + 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(); + + 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 ?? ""}`; + } + 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, + })); +} diff --git a/core/mcp/__tests__/fakeInspectorClient.ts b/core/mcp/__tests__/fakeInspectorClient.ts index 35111b6aa..c9fc9cdbd 100644 --- a/core/mcp/__tests__/fakeInspectorClient.ts +++ b/core/mcp/__tests__/fakeInspectorClient.ts @@ -35,6 +35,7 @@ import type { ResourceTemplateReadInvocation, ResourceSubscriptionStreamState, ToolCallInvocation, + ExcludedTool, } from "../types.js"; import { INACTIVE_SUBSCRIPTION_STREAM_STATE } from "../types.js"; import type { JsonValue } from "../../json/jsonUtils.js"; @@ -265,6 +266,18 @@ export class FakeInspectorClient return this.protocolEra; } + private excludedTools: ExcludedTool[] = []; + + getExcludedTools(): ExcludedTool[] { + return this.excludedTools; + } + + /** Test helper: set the excluded-tools set and emit `excludedToolsChange`. */ + setExcludedTools(excluded: ExcludedTool[]): void { + this.excludedTools = excluded; + this.dispatchTypedEvent("excludedToolsChange", excluded); + } + getDiscoverResult(): DiscoverResult | undefined { return this.discoverResult; } diff --git a/core/mcp/inspectorClient.ts b/core/mcp/inspectorClient.ts index e594b89f3..89d0edeac 100644 --- a/core/mcp/inspectorClient.ts +++ b/core/mcp/inspectorClient.ts @@ -16,7 +16,9 @@ import type { InspectorClientOptions, PendingRequestOrigin, ResourceSubscriptionStreamState, + ExcludedTool, } from "./types.js"; +import { scanXMcpHeaderDeclarations } from "../json/xMcpHeader.js"; // Re-export so v1.5 tests that do `import { InspectorClientOptions } from // "@inspector/core/mcp/inspectorClient.js"` keep resolving. export type { @@ -342,6 +344,10 @@ export class InspectorClient extends InspectorClientEventTarget { // probed/pinned connection (undefined on a plain legacy connect). private protocolEra?: ProtocolEra; private discoverResult?: DiscoverResult; + // Tools the SDK excludes from `tools/list` for invalid `x-mcp-header` + // annotations (SEP-2243), recomputed on every aggregate tools refresh and + // surfaced so the Tools tab can show why a tool vanished (#1632). + private excludedTools: ExcludedTool[] = []; // The capabilities this Inspector client advertises to the server during the // initialize handshake. Built once in setupClient() and snapshotted here so // UI surfaces (Server Info modal) can display them without poking at the @@ -1661,6 +1667,7 @@ export class InspectorClient extends InspectorClientEventTarget { this.protocolVersion = undefined; this.protocolEra = undefined; this.discoverResult = undefined; + this.excludedTools = []; // Drop the modern per-request log-level opt-in so it doesn't leak into the // next connection's `_meta` (#1629). this.modernLogLevel = undefined; @@ -1675,6 +1682,7 @@ export class InspectorClient extends InspectorClientEventTarget { this.dispatchTypedEvent("protocolVersionChange", this.protocolVersion); this.dispatchTypedEvent("protocolEraChange", this.protocolEra); this.dispatchTypedEvent("discoverResultChange", this.discoverResult); + this.dispatchTypedEvent("excludedToolsChange", this.excludedTools); } /** @@ -2564,9 +2572,73 @@ export class InspectorClient extends InspectorClientEventTarget { this.getCacheableRequestOptions(options?.cacheMode), ), ); + // Recompute the SEP-2243 excluded-tools set alongside the aggregate. The + // SDK already filtered `response.tools`, so it can't tell us what it + // dropped — {@link refreshExcludedTools} re-lists the RAW `tools/list` to + // find out. This is a SECOND, deliberately un-cached walk: on a modern + // non-stdio connection it roughly doubles the list round-trips per refresh + // (and runs even when the aggregate above was served from cache), because + // the raw per-page path has no response cache and the excluded set must + // reflect the current wire truth. Accepted for a debugging tool where the + // list is small and correctness of "why did this tool vanish" matters more + // than the extra request; it's a no-op (no round trip) on legacy/stdio. + // Kept best-effort: an error here must never fail the tools list itself. + await this.refreshExcludedTools(options?.metadata).catch(() => {}); return { tools: [...response.tools] }; } + /** + * Whether this connection excludes tools with invalid `x-mcp-header` + * annotations from `tools/list`, matching the SDK's gate: only the modern + * (2026-07-28) era on a non-stdio (Streamable HTTP / SSE) transport. Legacy + * and stdio keep such tools in the list, so there is nothing to surface. + */ + private excludesInvalidXMcpHeaderTools(): boolean { + return this.isModernEra() && this.getServerType() !== "stdio"; + } + + /** The current SEP-2243 excluded-tools set (empty on legacy/stdio). */ + getExcludedTools(): ExcludedTool[] { + return this.excludedTools; + } + + /** + * Recompute the tools the SDK excludes from `tools/list` for invalid + * `x-mcp-header` annotations (SEP-2243), and emit `excludedToolsChange`. + * Returns `[]` without any round trip on connections that don't exclude + * (legacy/stdio). Otherwise walks every page of the RAW `tools/list` (which, + * unlike the SDK's high-level `listTools()`, is NOT filtered) and keeps the + * tools whose annotation scan fails, each with its reason. A repeating cursor + * stops the walk (non-converging-server guard, mirroring the SDK). + */ + async refreshExcludedTools( + metadata?: Record, + ): Promise { + const excluded: ExcludedTool[] = []; + // Gated to connections that actually exclude; otherwise this is a pure + // no-op (no round trip). The raw `listTools` below guards the connection. + if (this.excludesInvalidXMcpHeaderTools()) { + const seenCursors = new Set(); + let cursor: string | undefined; + do { + const page = await this.listTools(cursor, metadata); + for (const tool of page.tools) { + const scan = scanXMcpHeaderDeclarations(tool.inputSchema); + if (!scan.valid) excluded.push({ tool, reason: scan.reason }); + } + cursor = page.nextCursor; + if (cursor !== undefined) { + /* v8 ignore next -- defensive: a spec-compliant server never repeats a cursor; this guards a non-converging server from an infinite walk (mirrors the SDK's drainList guard) */ + if (seenCursors.has(cursor)) break; + seenCursors.add(cursor); + } + } while (cursor !== undefined); + } + this.excludedTools = excluded; + this.dispatchTypedEvent("excludedToolsChange", excluded); + return excluded; + } + /** * Call a tool. Caller must provide the Tool (e.g. from a state manager). * @param tool The tool to call (use tool.name for the request) diff --git a/core/mcp/inspectorClientEventTarget.ts b/core/mcp/inspectorClientEventTarget.ts index 59d9b96bd..a4ccafe0b 100644 --- a/core/mcp/inspectorClientEventTarget.ts +++ b/core/mcp/inspectorClientEventTarget.ts @@ -24,6 +24,7 @@ import type { ResourceReadInvocation, ResourceTemplateReadInvocation, ResourceSubscriptionStreamState, + ExcludedTool, } from "./types.js"; import type { Tool, @@ -55,6 +56,12 @@ export type TaskWithOptionalCreatedAt = Omit & { export interface InspectorClientEventMap { statusChange: ConnectionStatus; toolsChange: Tool[]; + /** + * Tools the SDK excluded from `tools/list` for invalid `x-mcp-header` + * annotations (SEP-2243), each with its reason. Empty on legacy/stdio + * connections (which don't exclude) and before connect (#1632). + */ + excludedToolsChange: ExcludedTool[]; capabilitiesChange: ServerCapabilities | undefined; serverInfoChange: Implementation | undefined; instructionsChange: string | undefined; diff --git a/core/mcp/inspectorClientProtocol.ts b/core/mcp/inspectorClientProtocol.ts index d2e44cc75..3bed52235 100644 --- a/core/mcp/inspectorClientProtocol.ts +++ b/core/mcp/inspectorClientProtocol.ts @@ -20,6 +20,7 @@ import type { PromptGetInvocation, ToolCallInvocation, ResourceSubscriptionStreamState, + ExcludedTool, } from "./types.js"; import type { CacheMode, @@ -61,6 +62,9 @@ export interface InspectorClientProtocol extends InspectorClientEventTarget { getInstructions(): string | undefined; getProtocolVersion(): string | undefined; getProtocolEra(): ProtocolEra | undefined; + /** Tools excluded from `tools/list` for invalid `x-mcp-header` annotations + * (SEP-2243); empty on legacy/stdio connections (#1632). */ + getExcludedTools(): ExcludedTool[]; getResourceSubscriptionStreamState(): ResourceSubscriptionStreamState; getDiscoverResult(): DiscoverResult | undefined; getServerSettings(): InspectorServerSettings | undefined; diff --git a/core/mcp/types.ts b/core/mcp/types.ts index a38f013e6..87b83a51e 100644 --- a/core/mcp/types.ts +++ b/core/mcp/types.ts @@ -769,6 +769,19 @@ export interface CreateTransportResult { transport: Transport; } +/** + * A tool a conforming Streamable HTTP client MUST exclude from `tools/list` + * because its `x-mcp-header` annotations violate SEP-2243 (the whole tool + * definition is invalidated). The SDK's `listTools()` drops these silently; the + * Inspector surfaces them — with the constraint they broke — so a user can see + * *why* a tool vanished (#1632). Only modern non-stdio connections exclude. + */ +export interface ExcludedTool { + tool: Tool; + /** The first violated constraint, from the `x-mcp-header` scan. */ + reason: string; +} + /** * Factory that creates a client transport for an MCP server configuration. * Required by InspectorClient; caller provides the implementation for their diff --git a/core/react/useInspectorClient.ts b/core/react/useInspectorClient.ts index 32909ae46..db0910c37 100644 --- a/core/react/useInspectorClient.ts +++ b/core/react/useInspectorClient.ts @@ -10,6 +10,7 @@ import type { ProtocolEra, DiscoverResult, } from "@modelcontextprotocol/client"; +import type { ExcludedTool } from "../mcp/types.js"; // Module-scope frozen object so the `?? EMPTY_CLIENT_CAPABILITIES` // fallback below doesn't return a fresh literal on every render — @@ -37,6 +38,12 @@ export interface UseInspectorClientResult { * handshake. Undefined on a legacy connect. (#1626) */ discoverResult?: DiscoverResult; + /** + * Tools the SDK excluded from `tools/list` for invalid `x-mcp-header` + * annotations (SEP-2243), each with its reason. Empty on legacy/stdio + * connections and before connect (#1632). + */ + excludedTools: ExcludedTool[]; /** * Message from the most recent mid-session transport failure (the client's * `error` event — stdio crash, SSE drop, HTTP 5xx). Stays set until the next @@ -87,6 +94,9 @@ export function useInspectorClient( const [discoverResult, setDiscoverResult] = useState< DiscoverResult | undefined >(inspectorClient?.getDiscoverResult()); + const [excludedTools, setExcludedTools] = useState( + inspectorClient?.getExcludedTools() ?? [], + ); const [lastError, setLastError] = useState(undefined); useEffect(() => { @@ -98,6 +108,7 @@ export function useInspectorClient( setProtocolVersion(undefined); setProtocolEra(undefined); setDiscoverResult(undefined); + setExcludedTools([]); setLastError(undefined); return; } @@ -109,6 +120,7 @@ export function useInspectorClient( setProtocolVersion(inspectorClient.getProtocolVersion()); setProtocolEra(inspectorClient.getProtocolEra()); setDiscoverResult(inspectorClient.getDiscoverResult()); + setExcludedTools(inspectorClient.getExcludedTools()); setLastError(undefined); const onStatusChange = (event: TypedEvent<"statusChange">) => { @@ -144,6 +156,11 @@ export function useInspectorClient( ) => { setDiscoverResult(event.detail); }; + const onExcludedToolsChange = ( + event: TypedEvent<"excludedToolsChange">, + ) => { + setExcludedTools(event.detail); + }; inspectorClient.addEventListener("statusChange", onStatusChange); inspectorClient.addEventListener("error", onError); @@ -165,6 +182,10 @@ export function useInspectorClient( "discoverResultChange", onDiscoverResultChange, ); + inspectorClient.addEventListener( + "excludedToolsChange", + onExcludedToolsChange, + ); return () => { inspectorClient.removeEventListener("statusChange", onStatusChange); @@ -193,6 +214,10 @@ export function useInspectorClient( "discoverResultChange", onDiscoverResultChange, ); + inspectorClient.removeEventListener( + "excludedToolsChange", + onExcludedToolsChange, + ); }; }, [inspectorClient]); @@ -221,6 +246,7 @@ export function useInspectorClient( protocolVersion, protocolEra, discoverResult, + excludedTools, lastError, appRendererClient: inspectorClient?.getAppRendererClient() ?? null, connect, diff --git a/package-lock.json b/package-lock.json index 83c5a9bb0..ba22f189c 100644 --- a/package-lock.json +++ b/package-lock.json @@ -11,11 +11,11 @@ "license": "MIT", "dependencies": { "@hono/node-server": "^1.19.14", - "@modelcontextprotocol/client": "2.0.0-beta.4", - "@modelcontextprotocol/core": "2.0.0-beta.4", + "@modelcontextprotocol/client": "2.0.0-beta.5", + "@modelcontextprotocol/core": "2.0.0-beta.5", "@modelcontextprotocol/ext-apps": "^1.7.4", - "@modelcontextprotocol/server": "2.0.0-beta.4", - "@modelcontextprotocol/server-legacy": "2.0.0-beta.4", + "@modelcontextprotocol/server": "2.0.0-beta.5", + "@modelcontextprotocol/server-legacy": "2.0.0-beta.5", "@napi-rs/keyring": "^1.3.0", "@vitejs/plugin-react": "^6.0.0", "ajv": "^8.17.1", @@ -98,12 +98,12 @@ } }, "node_modules/@modelcontextprotocol/client": { - "version": "2.0.0-beta.4", - "resolved": "https://registry.npmjs.org/@modelcontextprotocol/client/-/client-2.0.0-beta.4.tgz", - "integrity": "sha512-VNHA/UXDk7mCpVl+jOg5B4WMRRD2OEl+it360Lhi6HiCbrIB2f6pZ0cqSDKXQVUtZvqnhdQHzZAM9h8EKWhq/A==", + "version": "2.0.0-beta.5", + "resolved": "https://registry.npmjs.org/@modelcontextprotocol/client/-/client-2.0.0-beta.5.tgz", + "integrity": "sha512-YuuNm5f2TMoFQRje1UqVP8TJRjijCXMz4ckvoVpx1cUXuBEmykWQ2d8R536pek6UKcXT41T5nWc4qR1JFIbEmg==", "license": "MIT", "dependencies": { - "@modelcontextprotocol/core": "2.0.0-beta.4", + "@modelcontextprotocol/core": "2.0.0-beta.5", "cross-spawn": "^7.0.5", "eventsource": "^3.0.2", "eventsource-parser": "^3.0.0", @@ -116,9 +116,9 @@ } }, "node_modules/@modelcontextprotocol/core": { - "version": "2.0.0-beta.4", - "resolved": "https://registry.npmjs.org/@modelcontextprotocol/core/-/core-2.0.0-beta.4.tgz", - "integrity": "sha512-nsMXd4wQBKzmph6r+WOhum+mXjDYljTAqwY/XUg3hLtvNOQ8+JVqBSJOVCMJvx9lXhTpTOPrGZ3BuNiaNPjSvg==", + "version": "2.0.0-beta.5", + "resolved": "https://registry.npmjs.org/@modelcontextprotocol/core/-/core-2.0.0-beta.5.tgz", + "integrity": "sha512-HKbY9XTbsDy1Y6r2I55TGE3JEapM0vg96e1MUmBIF9LGjos5gjhcIrTz1yvBPLg2aFKHjwhUAQfRdrCEnPxNew==", "license": "MIT", "dependencies": { "zod": "^4.2.0" @@ -198,12 +198,12 @@ } }, "node_modules/@modelcontextprotocol/server": { - "version": "2.0.0-beta.4", - "resolved": "https://registry.npmjs.org/@modelcontextprotocol/server/-/server-2.0.0-beta.4.tgz", - "integrity": "sha512-pjMZcNEt1dOq0aJCcY3b7w1Ayh+qmQN2xlfTELcGV9yiAjEGIczBPBLWWW0k0zzo5T8kiv15jtKPsWeHGxLvLg==", + "version": "2.0.0-beta.5", + "resolved": "https://registry.npmjs.org/@modelcontextprotocol/server/-/server-2.0.0-beta.5.tgz", + "integrity": "sha512-i1E5l75rQKsgY/AKAIspgMBH1vEL7dqiK7tHr0L+raYcb0SWOziqNGJXGIG6NY4AlXDWIKGJQGB7Nqfs3oUi5g==", "license": "MIT", "dependencies": { - "@modelcontextprotocol/core": "2.0.0-beta.4", + "@modelcontextprotocol/core": "2.0.0-beta.5", "zod": "^4.2.0" }, "engines": { @@ -211,13 +211,13 @@ } }, "node_modules/@modelcontextprotocol/server-legacy": { - "version": "2.0.0-beta.4", - "resolved": "https://registry.npmjs.org/@modelcontextprotocol/server-legacy/-/server-legacy-2.0.0-beta.4.tgz", - "integrity": "sha512-GaEa7mfjTqevGuz2taFj2fqb3FthxFvWOmoI/wbLmiK1XRnxdYsAaaJG/lgSlA+W6XWJ1+u+6oZ7610TowkjMA==", + "version": "2.0.0-beta.5", + "resolved": "https://registry.npmjs.org/@modelcontextprotocol/server-legacy/-/server-legacy-2.0.0-beta.5.tgz", + "integrity": "sha512-8BemN4avQnG6Fu660fZCqnPGpeyL7gg5kxUceZQh7JCt8oqzX1bwJkNV+cKS01LPAaPbl93INneD+mBtJWKWvQ==", "deprecated": "This package is a frozen copy of v1's SSE transport and OAuth Authorization Server helpers for migration purposes only. Use StreamableHTTP from @modelcontextprotocol/server and a dedicated OAuth server in production. Will not receive new features.", "license": "MIT", "dependencies": { - "@modelcontextprotocol/core": "2.0.0-beta.4", + "@modelcontextprotocol/core": "2.0.0-beta.5", "content-type": "^1.0.5", "cors": "^2.8.5", "express-rate-limit": "^8.2.1", diff --git a/package.json b/package.json index 4416e35fc..3b6e32493 100644 --- a/package.json +++ b/package.json @@ -60,11 +60,11 @@ }, "dependencies": { "@hono/node-server": "^1.19.14", - "@modelcontextprotocol/client": "2.0.0-beta.4", - "@modelcontextprotocol/core": "2.0.0-beta.4", + "@modelcontextprotocol/client": "2.0.0-beta.5", + "@modelcontextprotocol/core": "2.0.0-beta.5", "@modelcontextprotocol/ext-apps": "^1.7.4", - "@modelcontextprotocol/server": "2.0.0-beta.4", - "@modelcontextprotocol/server-legacy": "2.0.0-beta.4", + "@modelcontextprotocol/server": "2.0.0-beta.5", + "@modelcontextprotocol/server-legacy": "2.0.0-beta.5", "@napi-rs/keyring": "^1.3.0", "@vitejs/plugin-react": "^6.0.0", "ajv": "^8.17.1", diff --git a/pr-screenshots/README.md b/pr-screenshots/README.md index 86055088f..b7b612e26 100644 --- a/pr-screenshots/README.md +++ b/pr-screenshots/README.md @@ -1,3 +1,47 @@ +# x-mcp-header Tools tooling (#1632) — proof screenshots + +End-to-end verification of the SEP-2243 `x-mcp-header` Tools tooling against a +real modern (2026-07-28) HTTP test server +(`test-servers/configs/xmcpheader-modern-http.json` on port 3120), driven in a +real browser through the web client's remote-proxy transport. + +![Excluded tools in the sidebar](xmcpheader-excluded-tools.png) + +Connected with **Protocol Era = Modern**. `invalid_header_tool` is dropped from +`tools/list` by the SDK (its `x-mcp-header` annotation is invalid), so the +Inspector re-lists the raw list and surfaces it struck-through under an +**"Excluded (SEP-2243)"** divider — showing _why_ a tool vanished rather than +silently omitting it. + +![Exclusion reason on hover](xmcpheader-excluded-reason.png) + +Hovering the excluded tool shows the exact scan reason: the header name +`"Bad Header"` contains a space, so it is not a valid RFC 9110 token. + +![Mirrored request headers](xmcpheader-mirrored-headers.png) + +`get_weather`'s detail panel shows the **"Mirrored request headers (SEP-2243)"** +section: its `city` argument mirrors to `Mcp-Param-City`, with the note that the +SDK omits `Mcp-Param-*` on the browser transport. + +![Unknown tool -32602](xmcpheader-unknown-tool.png) + +Calling a tool the server no longer recognizes rejects with **`-32602`** (SDK v2) +instead of an `isError` result, and renders as an **"Unknown Tool"** error panel +with a targeted hint (reproduced by swapping the sessionless server to one +without `echo` while the cached list still showed it). + +![Invalid params -32602](xmcpheader-invalid-params.png) + +`-32602` is the generic _Invalid params_ code, so a **known** tool rejected for +bad arguments throws the same code as an unknown tool. The panel disambiguates +from the message: a `-32602` that does not name an unknown tool renders under +**"Invalid Parameters"** (with a schema hint) rather than "Unknown Tool". +Triggered live via the `trigger_invalid_params` tool, which returns a real +`-32602` JSON-RPC error whose message is not about a missing tool. + +--- + # Tasks extension era fork (#1631) — proof screenshots End-to-end verification of the Tasks era fork against two real test servers @@ -27,8 +71,8 @@ blocking `tasks/result`) — shown both in the Results panel and the Tasks card. `modern_input_task` moves to **`input_required`**: the `tasks/get` response's `inputRequests` map (visible in the task's Full Task Object) carries an embedded `elicitation/create`, surfaced through the same pending-request modal the MRTR -path uses — note the accurate wording *"your answer is submitted via a -tasks/update request (SEP-2663), not a retry"*. Answering it sends +path uses — note the accurate wording _"your answer is submitted via a +tasks/update request (SEP-2663), not a retry"_. Answering it sends **`tasks/update`** with the `inputResponses`, and the next poll completes the task: diff --git a/pr-screenshots/xmcpheader-excluded-reason.png b/pr-screenshots/xmcpheader-excluded-reason.png new file mode 100644 index 000000000..f8a0c6308 Binary files /dev/null and b/pr-screenshots/xmcpheader-excluded-reason.png differ diff --git a/pr-screenshots/xmcpheader-excluded-tools.png b/pr-screenshots/xmcpheader-excluded-tools.png new file mode 100644 index 000000000..31c826ef9 Binary files /dev/null and b/pr-screenshots/xmcpheader-excluded-tools.png differ diff --git a/pr-screenshots/xmcpheader-invalid-params.png b/pr-screenshots/xmcpheader-invalid-params.png new file mode 100644 index 000000000..ee4d7f8c6 Binary files /dev/null and b/pr-screenshots/xmcpheader-invalid-params.png differ diff --git a/pr-screenshots/xmcpheader-mirrored-headers.png b/pr-screenshots/xmcpheader-mirrored-headers.png new file mode 100644 index 000000000..34399e1d6 Binary files /dev/null and b/pr-screenshots/xmcpheader-mirrored-headers.png differ diff --git a/pr-screenshots/xmcpheader-unknown-tool.png b/pr-screenshots/xmcpheader-unknown-tool.png new file mode 100644 index 000000000..8e8e14062 Binary files /dev/null and b/pr-screenshots/xmcpheader-unknown-tool.png differ diff --git a/test-servers/configs/xmcpheader-modern-http.json b/test-servers/configs/xmcpheader-modern-http.json new file mode 100644 index 000000000..ccb76a64b --- /dev/null +++ b/test-servers/configs/xmcpheader-modern-http.json @@ -0,0 +1,17 @@ +{ + "serverInfo": { + "name": "xmcpheader-showcase", + "version": "1.0.0" + }, + "tools": [ + { "preset": "echo" }, + { "preset": "get_weather" }, + { "preset": "trigger_invalid_params" }, + { "preset": "invalid_header_tool" } + ], + "transport": { + "type": "streamable-http", + "port": 3120, + "modern": { "legacy": "stateless", "injectSpecErrors": true } + } +} diff --git a/test-servers/src/preset-registry.ts b/test-servers/src/preset-registry.ts index 5fe149c53..6c7e6679b 100644 --- a/test-servers/src/preset-registry.ts +++ b/test-servers/src/preset-registry.ts @@ -16,6 +16,7 @@ import { createAddTool, createGetSumTool, createGetWeatherTool, + createInvalidHeaderTool, createSpecErrorTriggerTool, createWriteToStderrTool, createCollectSampleTool, @@ -98,6 +99,8 @@ function resolveToolPreset( return createGetSumTool(); case "get_weather": return createGetWeatherTool(); + case "invalid_header_tool": + return createInvalidHeaderTool(); case "trigger_header_mismatch": return createSpecErrorTriggerTool( "trigger_header_mismatch", @@ -118,6 +121,11 @@ function resolveToolPreset( "trigger_method_not_found", "Returns -32601 MethodNotFound (HTTP 404)", ); + case "trigger_invalid_params": + return createSpecErrorTriggerTool( + "trigger_invalid_params", + "Returns a generic -32602 Invalid params (not an unknown tool)", + ); case "write_to_stderr": return createWriteToStderrTool(); case "collect_sample": diff --git a/test-servers/src/test-server-fixtures.ts b/test-servers/src/test-server-fixtures.ts index 4a5419799..3e9a87345 100644 --- a/test-servers/src/test-server-fixtures.ts +++ b/test-servers/src/test-server-fixtures.ts @@ -239,6 +239,29 @@ export function createGetWeatherTool(): ToolDefinition { }; } +/** + * Create a tool whose SEP-2243 `x-mcp-header` annotation is INVALID: the header + * name `"Bad Header"` contains a space, so it is not a valid RFC 9110 token. + * The whole tool definition is therefore invalid, and a conforming Streamable + * HTTP client MUST exclude it from `tools/list`. The server still serves it in + * the raw list (it only warns), so the Inspector can re-list raw and surface it + * as excluded with the reason (#1632). + */ +export function createInvalidHeaderTool(): ToolDefinition { + return { + name: "invalid_header_tool", + description: + "A tool with an invalid x-mcp-header annotation; conforming clients exclude it.", + inputSchema: { + value: z + .string() + .describe("A value") + .meta({ "x-mcp-header": "Bad Header" }), + }, + handler: async () => toToolResult("should have been excluded"), + }; +} + /** * Create a no-op "trigger" tool whose `tools/call` is intercepted by the modern * leg's spec-error injector (`injectSpecErrors`) to return a crafted diff --git a/test-servers/src/test-server-http.ts b/test-servers/src/test-server-http.ts index 6776ae01a..692c8b4fb 100644 --- a/test-servers/src/test-server-http.ts +++ b/test-servers/src/test-server-http.ts @@ -161,6 +161,16 @@ const SPEC_ERROR_TRIGGERS: Record< code: -32601, message: "Method not found", }, + // A generic `-32602 Invalid params` delivered as an in-band JSON-RPC error + // (HTTP 200) whose message does NOT name an unknown tool — so the client + // throws it as a `ProtocolError(-32602)` and the Inspector renders the + // "Invalid Parameters" branch of the tool-call error panel, distinct from the + // unknown-tool `-32602` (#1632). + trigger_invalid_params: { + httpStatus: 200, + code: -32602, + message: "Invalid params: 'code' must be a positive integer", + }, }; interface JsonRpcCallBody { @@ -200,8 +210,7 @@ export class TestServerHttp { private recordedRequests: RecordedRequest[] = []; private httpServer?: HttpServer; private transport?: - | WebStandardStreamableHTTPServerTransport - | SSEServerTransport; + WebStandardStreamableHTTPServerTransport | SSEServerTransport; private baseUrl?: string; private currentRequestHeaders?: Record; private currentLogLevel: string | null = null;