inspector/core/mcp/inspectorClientEventTarget.ts
Cliff Hall e70a11360c
Tools: x-mcp-header annotation tooling + -32602 unknown-tool rendering (#1632) (#1735)
* Tools: x-mcp-header annotation tooling + -32602 unknown-tool rendering (#1632)

Card 9/10 of the SDK-v2 + New Spec stack. Adds SEP-2243 `x-mcp-header`
tooling to the Tools tab and adjusts result rendering for SDK v2's
unknown-tool behavior, and bumps the SDK to 2.0.0-beta.5.

- **`core/json/xMcpHeader.ts`**: a faithful port of the SDK's (non-public)
  `scanXMcpHeaderDeclarations` — validates every SEP-2243 constraint
  (primitive types incl. `number`, RFC 9110 token, case-insensitive
  uniqueness, statically-reachable `properties`-only chain) and derives the
  mirrored `Mcp-Param-{Name}` params for a kept tool.

- **Excluded tools**: the SDK silently drops tools with invalid annotations
  from `tools/list`. InspectorClient now re-lists the RAW (unfiltered) list
  and scans it to recompute the excluded set with reasons — gated to the
  modern non-stdio era (matching the SDK's gate), recomputed on every
  aggregate refresh, surfaced via a new `excludedToolsChange` event +
  `getExcludedTools()` and `useInspectorClient().excludedTools`. The Tools
  sidebar lists them struck-through under an "Excluded (SEP-2243)" divider
  with the reason on hover.

- **Mirrored args**: ToolDetailPanel shows a "Mirrored request headers"
  section listing each arg → `Mcp-Param-{Name}` (with the browser-omits note).

- **-32602 rendering**: SDK v2 rejects an unknown-tool call with `-32602`
  instead of an `isError` result, which previously rendered nothing. New
  ToolCallErrorPanel renders a thrown tool-call error, with a targeted
  "Unknown Tool" hint for `-32602`.

Adds unit + Storybook + integration coverage (a real modern server with an
invalid-header tool fixture) for every path. `npm run ci`-equivalent checks
green.

Closes #1632

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XNdjEPKLG637X8YmhDiEk5

* test-servers: x-mcp-header modern showcase config + preset (#1632)

Register the `invalid_header_tool` fixture as a preset and add
`xmcpheader-modern-http.json` — a modern HTTP showcase serving echo, a
valid-header `get_weather`, and the invalid-header tool — so the Tools-tab
x-mcp-header tooling (mirrored headers, excluded-tools surface, -32602
rendering) can be exercised manually. Documented in the README alongside the
other per-SEP showcase configs. Smoke-tested end to end in the web client.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XNdjEPKLG637X8YmhDiEk5

* docs: add #1632 proof screenshots to pr-screenshots

End-to-end web-client verification of the x-mcp-header Tools tooling against
the modern showcase server: excluded-tools surface + reason, mirrored-headers
section, and the -32602 unknown-tool error panel.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XNdjEPKLG637X8YmhDiEk5

* Respond to review: disambiguate -32602, doc/search nits (#1632)

Addresses the review on PR #1735:

1. -32602 "Unknown Tool" mislabel: `-32602` is generic Invalid params, so a
   known tool called with bad arguments hit the same code and rendered under
   the definitive "Unknown Tool" heading. Replace `isUnknownToolError` with
   `classifyToolCallError(code, message)` → "unknown-tool" only when the
   message names it (not found / unknown tool / …), else "invalid-params"
   (new "Invalid Parameters" heading + schema hint), else "generic".

2. Double pagination walk: expand the `listAllTools` comment to document that
   the excluded-tools recompute is a deliberately un-cached second raw walk
   (doubles list round-trips on modern non-stdio, runs even on a cache-served
   aggregate) — accepted for a debugging tool, no-op on legacy/stdio.

3. Doc nit: `scanXMcpHeaderDeclarations` no longer claims to visit "$ref
   targets" — reworded to note `$ref` is never followed (refs resolve into the
   unreachable `$defs` body).

4. Search nit: the excluded-list filter now matches name AND title, matching
   the main tools filter.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XNdjEPKLG637X8YmhDiEk5

* Tighten unknown-tool matcher to be tool-scoped (#1632 re-review nit)

The re-review noted the -32602 disambiguation was symmetric: broad markers
("does not exist", "unrecognized") could inversely mislabel a genuine
invalid-params message (e.g. "property 'region' does not exist") as "Unknown
Tool". Scope the not-found/does-not-exist family to require the word "tool" in
the same clause (matching the SDK's "Tool <name> not found"); keep "unknown
tool" / "no such tool" as standalone unambiguous markers. Adds a
tool-less-"does not exist" test asserting it classifies as invalid-params.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XNdjEPKLG637X8YmhDiEk5

* Add trigger_invalid_params showcase tool + Invalid Parameters screenshot (#1632)

Adds a `trigger_invalid_params` spec-error trigger to the modern showcase
server: the injector answers its `tools/call` with a real `-32602 Invalid
params` JSON-RPC error whose message does NOT name an unknown tool. This lets
the "Invalid Parameters" branch of the tool-call error panel (added in the
review response) be exercised live, distinct from the unknown-tool `-32602`.

- `SPEC_ERROR_TRIGGERS` gains the `trigger_invalid_params` entry (HTTP 200,
  in-band -32602) + preset registration.
- Enable `injectSpecErrors` on `xmcpheader-modern-http.json` and add the tool.
- README + pr-screenshots README document it; adds the live "Invalid
  Parameters" proof screenshot.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XNdjEPKLG637X8YmhDiEk5

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-22 11:42:54 -04:00

195 lines
7.4 KiB
TypeScript

/**
* Type-safe EventTarget for InspectorClient events.
* Extends the generic TypedEventTarget with InspectorClientEventMap; TypedEvent
* is provided as a type alias for use in listener signatures.
*
* v2 note: the v1.5 event map referenced SamplingCreateMessage / ElicitationCreateMessage
* classes (with embedded promise resolvers) and OAuth events from the auth subsystem.
* The auth subsystem and the runtime promise-based pending classes are not yet ported to
* v2 — see #1243 scope. Pending requests are typed with the v2 wrapper types
* (InspectorPendingSampling / InspectorPendingElicitation); OAuth events are deliberately
* omitted here and will be added when the auth subsystem is ported.
*/
import {
TypedEventTarget,
type TypedEventGeneric,
} from "./typedEventTarget.js";
import type {
ConnectionStatus,
MessageEntry,
StderrLogEntry,
FetchRequestEntry,
PromptGetInvocation,
ResourceReadInvocation,
ResourceTemplateReadInvocation,
ResourceSubscriptionStreamState,
ExcludedTool,
} from "./types.js";
import type {
Tool,
ServerCapabilities,
Implementation,
Root,
Progress,
ProgressToken,
Task,
CallToolResult,
ProtocolError,
ProtocolEra,
DiscoverResult,
} from "@modelcontextprotocol/client";
import type { SamplingCreateMessage } from "./samplingCreateMessage.js";
import type { ElicitationCreateMessage } from "./elicitationCreateMessage.js";
import type { JsonValue } from "../json/jsonUtils.js";
import type { OAuthTokens } from "@modelcontextprotocol/client";
import type { AuthChallenge } from "../auth/challenge.js";
/** Task with createdAt optional so we can emit synthetic tasks (e.g. on result/error) that omit it. */
export type TaskWithOptionalCreatedAt = Omit<Task, "createdAt"> & {
createdAt?: string;
};
/**
* Maps event names to their detail types for CustomEvents
*/
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;
protocolVersionChange: string | undefined;
/** Protocol era negotiated after connect (SEP §7.8); `"legacy"` on a legacy connect, undefined when not connected. */
protocolEraChange: ProtocolEra | undefined;
/** `server/discover` result on a probed/pinned connect; undefined on legacy. */
discoverResultChange: DiscoverResult | undefined;
message: MessageEntry;
stderrLog: StderrLogEntry;
fetchRequest: FetchRequestEntry;
/** Fired when an in-flight fetch's response body is read asynchronously. */
fetchRequestBodyUpdate: { id: string; responseBody: string };
/**
* Fired whenever the client transitions `status` to `"error"` from a path
* that is NOT an awaited promise — i.e. a mid-session transport failure
* (stdio subprocess crash, SSE stream drop, HTTP 5xx) surfaced via the
* transport's `onerror`. The `Error` carries the reason (`.message`,
* optional `.cause`). Handshake failures are NOT dispatched here: they
* reject the awaited `connect()` promise, so the caller already has the
* error and a second surface would double-report it. Consumers that don't
* subscribe directly can read the last error via `useInspectorClient`'s
* `lastError`.
*/
error: Error;
resourceUpdated: { uri: string };
progressNotification: Progress & { progressToken?: ProgressToken };
toolCallResultChange: {
toolName: string;
params: Record<string, JsonValue>;
result: CallToolResult | null;
timestamp: Date;
success: boolean;
error?: string;
metadata?: Record<string, string>;
/** Non-fatal outputSchema mismatch detected on the skipOutputValidation path. */
outputValidationError?: string;
};
resourceContentChange: {
uri: string;
content: ResourceReadInvocation;
timestamp: Date;
};
resourceTemplateContentChange: {
uriTemplate: string;
content: ResourceTemplateReadInvocation;
params: Record<string, string>;
timestamp: Date;
};
promptContentChange: {
name: string;
content: PromptGetInvocation;
timestamp: Date;
};
pendingSamplesChange: SamplingCreateMessage[];
newPendingSample: SamplingCreateMessage;
pendingElicitationsChange: ElicitationCreateMessage[];
newPendingElicitation: ElicitationCreateMessage;
rootsChange: Root[];
resourceSubscriptionsChange: string[];
/**
* Fired when the modern-era `subscriptions/listen` stream that backs resource
* subscriptions changes lifecycle state (#1630): opened+acknowledged, dropped
* and reconnecting, or ended. Legacy connections never dispatch an `active`
* state (they have no persistent stream).
*/
resourceSubscriptionStreamChange: ResourceSubscriptionStreamState;
// Task events
/** Fired only from server notification notifications/tasks/status. */
taskStatusChange: { taskId: string; task: Task };
/** Fired from callToolStream for each task update. */
toolCallTaskUpdated: {
taskId: string;
task: TaskWithOptionalCreatedAt;
result?: CallToolResult;
error?: ProtocolError;
};
/** Fired from getRequestorTask() and callToolStream (client-origin task updates). */
requestorTaskUpdated: {
taskId: string;
task: TaskWithOptionalCreatedAt;
result?: CallToolResult;
error?: ProtocolError;
};
taskCancelled: { taskId: string };
/**
* Fired from callToolStream when a progress notification arrives for a
* task-augmented tool call, tagged with the taskId callToolStream owns so
* consumers can correlate progress → task (the generic progressNotification
* event carries only the caller's progressToken, not the taskId).
*/
requestorTaskProgress: { taskId: string; progress: Progress };
tasksChange: Task[];
// Signal events (no payload)
connect: void;
disconnect: void;
// List changed notification events
toolsListChanged: void;
resourcesListChanged: void;
resourceTemplatesListChanged: void;
promptsListChanged: void;
tasksListChanged: void;
// Session persistence (dispatched by client; FetchRequestLogState listens and saves)
saveSession: { sessionId: string };
// OAuth events (#1302 — fired by the ported oauthManager / InspectorClient)
oauthComplete: { tokens: OAuthTokens };
oauthAuthorizationRequired: { url: URL };
oauthError: { error: Error };
/** Ambient (SSE) auth challenge while no command-scoped send is active. */
authChallengeAmbient: { challenge: AuthChallenge };
/** Command-scoped direct-transport auth challenge (no ambient toast). */
authChallengeCommand: { challenge: AuthChallenge };
/** Ambient auth recovery completed (remote auth state pushed). */
authChallengeRecovered: { challenge: AuthChallenge };
/** Interactive auth required; App orchestrates redirect (step-up modal or reauth). */
authChallengeInteractive: {
challenge: AuthChallenge;
authorizationUrl: URL;
};
}
/**
* Type alias for InspectorClient typed events (for listener signatures).
*/
export type TypedEvent<K extends keyof InspectorClientEventMap> =
TypedEventGeneric<InspectorClientEventMap, K>;
/**
* Type-safe EventTarget for InspectorClient events.
*/
export class InspectorClientEventTarget extends TypedEventTarget<InspectorClientEventMap> {}