From dd8fa7a19081282c35e49d2d1dd7894eb9c25c2d Mon Sep 17 00:00:00 2001 From: cliffhall Date: Fri, 17 Jul 2026 21:47:21 -0400 Subject: [PATCH 1/3] MRTR: drive input_required manually to keep pending-request UX (#1704) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Follow-up to #1627/#1705. Instead of letting the SDK auto-fulfil the modern (2026-07-28) MRTR retry loop, InspectorClient now drives it itself so the Inspector keeps its pending-request UX, owns the loop, and shows era-accurate semantics. Core (core/mcp/inspectorClient.ts): - Construct the SDK Client with `inputRequired: { autoFulfill: false }` (unconditional; a no-op on legacy, which never returns input_required). - New `requestWithInputRequired()` driver: issues the request with `allowInputRequired` + `withInputRequired(schema)`, and while the result is `input_required`, fulfils each embedded request through the SAME pending sampling/elicitation plumbing, then retries the original call with `inputResponses` + the echoed `requestState` on a fresh id, bounded by MRTR_MAX_ROUNDS. - Route tools/call, prompts/get, and resources/read through the driver. - Embedded roots/list is auto-answered from configured roots (no pending UX); decline/cancel is echoed to the server (not treated as an abort); a tool-call abort mid-round settles the pending request and surfaces ToolCallCancelledError. - Extract shared `enqueuePendingSample`/`enqueuePendingElicitation` helpers used by both the legacy inbound handlers and the driver; add a per-request `origin` ("server-request" | "input-required") to the pending wrappers. Web UI: - New `MrtrOriginNote` element shows an "input_required — your answer returns as a retry" note on a modern MRTR round; legacy panels are unchanged. - Thread `origin` App -> PendingClientRequestModal -> the sampling/elicitation panels (and the inline panels). Test servers: new MRTR presets (multi-round, roots, sampling, loop, edge-case param shaping). Integration tests cover the manual round-trip (asserting the pending UI pauses and the retry carries inputResponses + requestState on a new id), multi-round, roots auto-answer, sampling, decline-echoed, maxRounds bound, abort mid-round, and requestState-only/inputRequests-only param shaping. Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_01XNdjEPKLG637X8YmhDiEk5 --- README.md | 2 +- clients/web/src/App.tsx | 3 + .../MrtrOriginNote/MrtrOriginNote.stories.tsx | 18 + .../MrtrOriginNote/MrtrOriginNote.test.tsx | 23 ++ .../MrtrOriginNote/MrtrOriginNote.tsx | 48 +++ .../ElicitationFormPanel.tsx | 10 + .../ElicitationUrlPanel.tsx | 10 + .../InlineElicitationRequest.test.tsx | 15 + .../InlineElicitationRequest.tsx | 10 + .../InlineSamplingRequest.test.tsx | 14 + .../InlineSamplingRequest.tsx | 11 + .../PendingClientRequestModal.stories.tsx | 30 +- .../PendingClientRequestModal.test.tsx | 17 + .../PendingClientRequestModal.tsx | 39 ++- .../SamplingRequestPanel.tsx | 10 + .../mcp/inspectorClientUrlElicitation.test.ts | 42 ++- .../inspectorClient-coverage-backfill.test.ts | 10 +- .../mcp/inspectorClient-modern-era.test.ts | 260 +++++++++++++- core/mcp/elicitationCreateMessage.ts | 17 +- core/mcp/inspectorClient.ts | 331 +++++++++++++++--- core/mcp/samplingCreateMessage.ts | 11 + core/mcp/types.ts | 11 + test-servers/src/preset-registry.ts | 15 + test-servers/src/test-server-fixtures.ts | 234 ++++++++++++- 24 files changed, 1099 insertions(+), 92 deletions(-) create mode 100644 clients/web/src/components/elements/MrtrOriginNote/MrtrOriginNote.stories.tsx create mode 100644 clients/web/src/components/elements/MrtrOriginNote/MrtrOriginNote.test.tsx create mode 100644 clients/web/src/components/elements/MrtrOriginNote/MrtrOriginNote.tsx diff --git a/README.md b/README.md index d59ab2ace..a7d5e4592 100644 --- a/README.md +++ b/README.md @@ -110,7 +110,7 @@ npm run test-servers:build # (from clients/web) → tsc -p test-servers, emits The Vite alias `@modelcontextprotocol/inspector-test-server` (in `clients/web/vite.config.ts`) points at `test-servers/build/index.js` so `getTestMcpServerPath()` resolves to a real `.js` path. -A streamable-HTTP server can also serve the **modern (2026-07-28) protocol era** via the SDK's `createMcpHandler` — set `transport.modern` in the JSON config (`true` for dual-era stateless serving, or `{ "legacy": "reject" }` for modern-only strict), or pass `modern` on the `ServerConfig` for an in-process `createTestServerHttp`. This is what lets an Inspector connection negotiating `protocolEra: "auto" | "modern"` reach the modern leg (populated `server/discover`, sessionless). See `test-servers/configs/modern-http.json`. `test-servers/configs/modern-mrtr-http.json` additionally serves the `mrtr_confirm` tool (preset `mrtr_confirm`, `createMrtrTool`) over the modern leg: its handler returns `inputRequired(...)` embedding a form elicitation, so invoking it produces a real MRTR round-trip (`input_required` → the client fulfils the embedded elicitation and retries with a new id → `complete`) — useful for eyeballing the Protocol view's MRTR conversation grouping. (The legacy `collect_elicitation` preset calls `server.elicitInput`, which errors on the 2026-07-28 leg — server→client requests aren't allowed there; MRTR is the modern replacement.) +A streamable-HTTP server can also serve the **modern (2026-07-28) protocol era** via the SDK's `createMcpHandler` — set `transport.modern` in the JSON config (`true` for dual-era stateless serving, or `{ "legacy": "reject" }` for modern-only strict), or pass `modern` on the `ServerConfig` for an in-process `createTestServerHttp`. This is what lets an Inspector connection negotiating `protocolEra: "auto" | "modern"` reach the modern leg (populated `server/discover`, sessionless). See `test-servers/configs/modern-http.json`. `test-servers/configs/modern-mrtr-http.json` additionally serves the `mrtr_confirm` tool (preset `mrtr_confirm`, `createMrtrTool`) over the modern leg: its handler returns `inputRequired(...)` embedding a form elicitation, so invoking it produces a real MRTR round-trip (`input_required` → the client fulfils the embedded elicitation and retries with a new id → `complete`). The Inspector drives MRTR manually (`inputRequired: { autoFulfill: false }`), so the embedded elicitation pauses at the pending-request modal (tagged "input_required") for you to answer, then the retry completes — useful for eyeballing both that pending-request UX and the Protocol view's MRTR conversation grouping. (The legacy `collect_elicitation` preset calls `server.elicitInput`, which errors on the 2026-07-28 leg — server→client requests aren't allowed there; MRTR is the modern replacement.) ## Building diff --git a/clients/web/src/App.tsx b/clients/web/src/App.tsx index 4bee0a652..600a7f5bf 100644 --- a/clients/web/src/App.tsx +++ b/clients/web/src/App.tsx @@ -3640,6 +3640,7 @@ function App() { kind: "sampling", id: activeSample.id, request: activeSample.request.params, + origin: activeSample.origin, }; } const activeElicitation = pendingElicitations[0]; @@ -3651,12 +3652,14 @@ function App() { id: activeElicitation.id, message: params.message, url: params.url, + origin: activeElicitation.origin, }; } return { kind: "elicitation-form", id: activeElicitation.id, request: params, + origin: activeElicitation.origin, }; } return null; diff --git a/clients/web/src/components/elements/MrtrOriginNote/MrtrOriginNote.stories.tsx b/clients/web/src/components/elements/MrtrOriginNote/MrtrOriginNote.stories.tsx new file mode 100644 index 000000000..e9e4345fd --- /dev/null +++ b/clients/web/src/components/elements/MrtrOriginNote/MrtrOriginNote.stories.tsx @@ -0,0 +1,18 @@ +import type { Meta, StoryObj } from "@storybook/react-vite"; +import { MrtrOriginNote } from "./MrtrOriginNote"; + +const meta: Meta = { + title: "Elements/MrtrOriginNote", + component: MrtrOriginNote, +}; + +export default meta; +type Story = StoryObj; + +export const InputRequired: Story = { + args: { origin: "input-required" }, +}; + +export const ServerRequest: Story = { + args: { origin: "server-request" }, +}; diff --git a/clients/web/src/components/elements/MrtrOriginNote/MrtrOriginNote.test.tsx b/clients/web/src/components/elements/MrtrOriginNote/MrtrOriginNote.test.tsx new file mode 100644 index 000000000..558ab6207 --- /dev/null +++ b/clients/web/src/components/elements/MrtrOriginNote/MrtrOriginNote.test.tsx @@ -0,0 +1,23 @@ +import { describe, it, expect } from "vitest"; +import { renderWithMantine, screen } from "../../../test/renderWithMantine"; +import { MrtrOriginNote } from "./MrtrOriginNote"; + +describe("MrtrOriginNote", () => { + it("renders the MRTR note for a modern input-required round", () => { + renderWithMantine(); + expect(screen.getByText("input_required")).toBeInTheDocument(); + expect(screen.getByText(/sent back as a retry/i)).toBeInTheDocument(); + }); + + it("renders nothing for a legacy server request", () => { + renderWithMantine(); + expect(screen.queryByText("input_required")).not.toBeInTheDocument(); + expect(screen.queryByText(/sent back as a retry/i)).not.toBeInTheDocument(); + }); + + it("defaults to rendering nothing when origin is omitted", () => { + renderWithMantine(); + expect(screen.queryByText("input_required")).not.toBeInTheDocument(); + expect(screen.queryByText(/sent back as a retry/i)).not.toBeInTheDocument(); + }); +}); diff --git a/clients/web/src/components/elements/MrtrOriginNote/MrtrOriginNote.tsx b/clients/web/src/components/elements/MrtrOriginNote/MrtrOriginNote.tsx new file mode 100644 index 000000000..8219f4356 --- /dev/null +++ b/clients/web/src/components/elements/MrtrOriginNote/MrtrOriginNote.tsx @@ -0,0 +1,48 @@ +import { Badge, Group, Text } from "@mantine/core"; +import type { PendingRequestOrigin } from "@inspector/core/mcp/types.js"; + +export interface MrtrOriginNoteProps { + /** + * How the pending request reached the Inspector. `"server-request"` (the + * default) is a legacy server→client request and renders nothing, keeping the + * panel visually identical to its pre-MRTR form. `"input-required"` is a + * modern (2026-07-28) MRTR round and renders the era-accurate note. + */ + origin?: PendingRequestOrigin; +} + +const NoteRow = Group.withProps({ + gap: "xs", + align: "center", + wrap: "nowrap", +}); + +const NoteText = Text.withProps({ + size: "xs", + c: "dimmed", +}); + +/** + * Era-accurate note for a pending sampling/elicitation request. On a modern + * MRTR round (`origin: "input-required"`, SEP-2322) the request was embedded in + * a tool-call/prompt/resource `input_required` result rather than sent as a + * server→client JSON-RPC request, and the user's answer is echoed back to the + * server as a retry of the original call. Legacy requests + * (`origin: "server-request"`) render nothing so their panels are unchanged. + */ +export function MrtrOriginNote({ + origin = "server-request", +}: MrtrOriginNoteProps) { + if (origin !== "input-required") return null; + return ( + + + input_required + + + The server returned input_required; your answer is sent back as a retry + of the original request (MRTR). + + + ); +} diff --git a/clients/web/src/components/groups/ElicitationFormPanel/ElicitationFormPanel.tsx b/clients/web/src/components/groups/ElicitationFormPanel/ElicitationFormPanel.tsx index f0f1cf5b3..5ada87890 100644 --- a/clients/web/src/components/groups/ElicitationFormPanel/ElicitationFormPanel.tsx +++ b/clients/web/src/components/groups/ElicitationFormPanel/ElicitationFormPanel.tsx @@ -8,11 +8,13 @@ import { Text, } from "@mantine/core"; import type { ElicitRequestFormParams } from "@modelcontextprotocol/client"; +import type { PendingRequestOrigin } from "@inspector/core/mcp/types.js"; import { hasMissingRequiredFields, type InspectorFormSchema, } from "../../../utils/jsonUtils"; import { SchemaForm } from "../SchemaForm/SchemaForm"; +import { MrtrOriginNote } from "../../elements/MrtrOriginNote/MrtrOriginNote"; export interface ElicitationFormPanelProps { request: ElicitRequestFormParams; @@ -24,6 +26,12 @@ export interface ElicitationFormPanelProps { onDecline: () => void; /** Dismissal without an explicit choice (maps to the spec's `cancel`). */ onCancel: () => void; + /** + * How the request reached the Inspector — a legacy server→client request or a + * modern MRTR `input_required` round. Drives the era-accurate note; defaults + * to `"server-request"` (legacy, note hidden). (#1704) + */ + origin?: PendingRequestOrigin; /** * A response has been dispatched; lock the actions so a second click can't * resolve the request twice (the underlying handler throws if called again). @@ -66,6 +74,7 @@ export function ElicitationFormPanel({ onSubmit, onDecline, onCancel, + origin = "server-request", busy = false, }: ElicitationFormPanelProps) { const requestedSchema = request.requestedSchema as InspectorFormSchema; @@ -74,6 +83,7 @@ export function ElicitationFormPanel({ return ( {formatQuoted(request.message)} + {message} + The server is requesting you visit: {url} diff --git a/clients/web/src/components/groups/InlineElicitationRequest/InlineElicitationRequest.test.tsx b/clients/web/src/components/groups/InlineElicitationRequest/InlineElicitationRequest.test.tsx index 7502faf72..ea065596a 100644 --- a/clients/web/src/components/groups/InlineElicitationRequest/InlineElicitationRequest.test.tsx +++ b/clients/web/src/components/groups/InlineElicitationRequest/InlineElicitationRequest.test.tsx @@ -41,6 +41,21 @@ describe("InlineElicitationRequest", () => { expect(screen.getByText("elicitation/create (form)")).toBeInTheDocument(); expect(screen.getByText(formRequest.message)).toBeInTheDocument(); expect(screen.getByText("1 of 1")).toBeInTheDocument(); + // Default origin is a legacy server request — no MRTR note. + expect(screen.queryByText("input_required")).not.toBeInTheDocument(); + }); + + it("shows the MRTR note for a modern input_required round", () => { + renderWithMantine( + , + ); + expect(screen.getByText("input_required")).toBeInTheDocument(); + expect(screen.getByText(/sent back as a retry/i)).toBeInTheDocument(); }); it("renders schema fields and a Submit button in form mode", () => { diff --git a/clients/web/src/components/groups/InlineElicitationRequest/InlineElicitationRequest.tsx b/clients/web/src/components/groups/InlineElicitationRequest/InlineElicitationRequest.tsx index 227390d57..14bc6fe82 100644 --- a/clients/web/src/components/groups/InlineElicitationRequest/InlineElicitationRequest.tsx +++ b/clients/web/src/components/groups/InlineElicitationRequest/InlineElicitationRequest.tsx @@ -12,14 +12,22 @@ import type { ElicitRequest, ElicitRequestFormParams, } from "@modelcontextprotocol/client"; +import type { PendingRequestOrigin } from "@inspector/core/mcp/types.js"; import type { InspectorFormSchema } from "../../../utils/jsonUtils"; import { SchemaForm } from "../SchemaForm/SchemaForm"; +import { MrtrOriginNote } from "../../elements/MrtrOriginNote/MrtrOriginNote"; export interface InlineElicitationRequestProps { request: ElicitRequest["params"]; queuePosition: string; values?: Record; isWaiting?: boolean; + /** + * How the request reached the Inspector — a legacy server→client request or a + * modern MRTR `input_required` round. Drives the era-accurate note; defaults + * to `"server-request"` (legacy, note hidden). (#1704) + */ + origin?: PendingRequestOrigin; onChange: (values: Record) => void; onSubmit: () => void; onCancel: () => void; @@ -73,6 +81,7 @@ export function InlineElicitationRequest({ queuePosition, values, isWaiting, + origin = "server-request", onChange, onSubmit, onCancel, @@ -86,6 +95,7 @@ export function InlineElicitationRequest({ {request.message} + {isFormMode(request) && ( { expect(screen.getByText("sampling/createMessage")).toBeInTheDocument(); expect(screen.getByText("1 of 1")).toBeInTheDocument(); expect(screen.getByText("Please analyze this code.")).toBeInTheDocument(); + // Default origin is a legacy server request — no MRTR note. + expect(screen.queryByText("input_required")).not.toBeInTheDocument(); + }); + + it("shows the MRTR note for a modern input_required round", () => { + renderWithMantine( + , + ); + expect(screen.getByText("input_required")).toBeInTheDocument(); + expect(screen.getByText(/sent back as a retry/i)).toBeInTheDocument(); }); it("renders model hints when provided", () => { diff --git a/clients/web/src/components/groups/InlineSamplingRequest/InlineSamplingRequest.tsx b/clients/web/src/components/groups/InlineSamplingRequest/InlineSamplingRequest.tsx index b1544c4db..815e3887a 100644 --- a/clients/web/src/components/groups/InlineSamplingRequest/InlineSamplingRequest.tsx +++ b/clients/web/src/components/groups/InlineSamplingRequest/InlineSamplingRequest.tsx @@ -3,11 +3,19 @@ import type { CreateMessageRequestParams, CreateMessageResult, } from "@modelcontextprotocol/client"; +import type { PendingRequestOrigin } from "@inspector/core/mcp/types.js"; +import { MrtrOriginNote } from "../../elements/MrtrOriginNote/MrtrOriginNote"; export interface InlineSamplingRequestProps { request: CreateMessageRequestParams; queuePosition: string; draftResult?: CreateMessageResult; + /** + * How the request reached the Inspector — a legacy server→client request or a + * modern MRTR `input_required` round. Drives the era-accurate note; defaults + * to `"server-request"` (legacy, note hidden). (#1704) + */ + origin?: PendingRequestOrigin; onAutoRespond: () => void; onEditAndSend: () => void; onReject: () => void; @@ -94,6 +102,7 @@ export function InlineSamplingRequest({ request, queuePosition, draftResult, + origin = "server-request", onAutoRespond, onEditAndSend, onReject, @@ -111,6 +120,8 @@ export function InlineSamplingRequest({ {queuePosition} + + {modelHints && {formatModelHints(modelHints)}} {messagePreview} diff --git a/clients/web/src/components/groups/PendingClientRequestModal/PendingClientRequestModal.stories.tsx b/clients/web/src/components/groups/PendingClientRequestModal/PendingClientRequestModal.stories.tsx index d16fd5d89..5130e98c1 100644 --- a/clients/web/src/components/groups/PendingClientRequestModal/PendingClientRequestModal.stories.tsx +++ b/clients/web/src/components/groups/PendingClientRequestModal/PendingClientRequestModal.stories.tsx @@ -112,7 +112,12 @@ type Story = StoryObj; export const Sampling: Story = { args: { - request: { kind: "sampling", id: "sampling-1", request: samplingRequest }, + request: { + kind: "sampling", + id: "sampling-1", + request: samplingRequest, + origin: "server-request", + }, }, play: async ({ canvasElement, args }) => { const body = within(canvasElement.ownerDocument.body); @@ -125,12 +130,32 @@ export const Sampling: Story = { }, }; +// A modern MRTR round: the sampling request was embedded in a tool-call +// `input_required` result, so the panel shows the era-accurate note. +export const SamplingInputRequired: Story = { + args: { + request: { + kind: "sampling", + id: "sampling-mrtr", + request: samplingRequest, + origin: "input-required", + }, + }, + play: async ({ canvasElement }) => { + const body = within(canvasElement.ownerDocument.body); + await body.findByText("Sampling Request"); + expect(body.getByText("input_required")).toBeInTheDocument(); + expect(body.getByText(/sent back as a retry/i)).toBeInTheDocument(); + }, +}; + export const SamplingTall: Story = { args: { request: { kind: "sampling", id: "sampling-tall", request: tallSamplingRequest, + origin: "server-request", }, }, }; @@ -141,6 +166,7 @@ export const ElicitationForm: Story = { kind: "elicitation-form", id: "elicitation-1", request: formRequest, + origin: "server-request", }, }, play: async ({ canvasElement, args }) => { @@ -163,6 +189,7 @@ export const ElicitationFormTall: Story = { kind: "elicitation-form", id: "elicitation-tall", request: tallFormRequest, + origin: "server-request", }, }, }; @@ -174,6 +201,7 @@ export const ElicitationUrl: Story = { id: "elicitation-2", message: "Authorize access in your browser to continue.", url: "https://example.com/authorize?token=abc123", + origin: "server-request", }, }, play: async ({ canvasElement }) => { diff --git a/clients/web/src/components/groups/PendingClientRequestModal/PendingClientRequestModal.test.tsx b/clients/web/src/components/groups/PendingClientRequestModal/PendingClientRequestModal.test.tsx index 482373999..247854679 100644 --- a/clients/web/src/components/groups/PendingClientRequestModal/PendingClientRequestModal.test.tsx +++ b/clients/web/src/components/groups/PendingClientRequestModal/PendingClientRequestModal.test.tsx @@ -32,12 +32,14 @@ const samplingContent: PendingClientRequestContent = { kind: "sampling", id: "sampling-1", request: samplingRequest, + origin: "server-request", }; const formContent: PendingClientRequestContent = { kind: "elicitation-form", id: "elicitation-1", request: formRequest, + origin: "server-request", }; const urlContent: PendingClientRequestContent = { @@ -45,6 +47,7 @@ const urlContent: PendingClientRequestContent = { id: "elicitation-2", message: "Authorize access in your browser.", url: "https://example.com/authorize", + origin: "server-request", }; const baseProps = { @@ -77,6 +80,19 @@ describe("PendingClientRequestModal", () => { expect( screen.getByText("The server is requesting an LLM completion."), ).toBeInTheDocument(); + // Legacy server-request origin: no MRTR note. + expect(screen.queryByText("input_required")).not.toBeInTheDocument(); + }); + + it("shows the MRTR note when the request is a modern input_required round", () => { + renderWithMantine( + , + ); + expect(screen.getByText("input_required")).toBeInTheDocument(); + expect(screen.getByText(/sent back as a retry/i)).toBeInTheDocument(); }); it("sends the default stub sampling result when the draft is untouched", async () => { @@ -176,6 +192,7 @@ describe("PendingClientRequestModal", () => { }, }, }, + origin: "server-request", }; renderWithMantine( , diff --git a/clients/web/src/components/groups/PendingClientRequestModal/PendingClientRequestModal.tsx b/clients/web/src/components/groups/PendingClientRequestModal/PendingClientRequestModal.tsx index ab388181d..3131c9b60 100644 --- a/clients/web/src/components/groups/PendingClientRequestModal/PendingClientRequestModal.tsx +++ b/clients/web/src/components/groups/PendingClientRequestModal/PendingClientRequestModal.tsx @@ -6,6 +6,7 @@ import type { ElicitRequestFormParams, ElicitResult, } from "@modelcontextprotocol/client"; +import type { PendingRequestOrigin } from "@inspector/core/mcp/types.js"; import { SamplingRequestPanel } from "../SamplingRequestPanel/SamplingRequestPanel"; import { ElicitationFormPanel } from "../ElicitationFormPanel/ElicitationFormPanel"; import { ElicitationUrlPanel } from "../ElicitationUrlPanel/ElicitationUrlPanel"; @@ -17,12 +18,30 @@ import { /** * The server-initiated request currently shown in the modal. `id` is the * client-side request id; it keys the modal body so per-request draft state - * resets when the active request changes. + * resets when the active request changes. `origin` distinguishes a legacy + * server→client request from a modern MRTR `input_required` round so the panels + * can show era-accurate semantics (#1704). */ export type PendingClientRequestContent = - | { kind: "sampling"; id: string; request: CreateMessageRequestParams } - | { kind: "elicitation-form"; id: string; request: ElicitRequestFormParams } - | { kind: "elicitation-url"; id: string; message: string; url: string }; + | { + kind: "sampling"; + id: string; + request: CreateMessageRequestParams; + origin: PendingRequestOrigin; + } + | { + kind: "elicitation-form"; + id: string; + request: ElicitRequestFormParams; + origin: PendingRequestOrigin; + } + | { + kind: "elicitation-url"; + id: string; + message: string; + url: string; + origin: PendingRequestOrigin; + }; export interface PendingClientRequestModalProps { /** The active request to display, or null when nothing is pending. */ @@ -103,10 +122,12 @@ function useRespondOnce(): { function SamplingModalBody({ request, + origin, onRespond, onReject, }: { request: CreateMessageRequestParams; + origin: PendingRequestOrigin; onRespond: (result: CreateMessageResult) => void; onReject: () => void; }) { @@ -117,6 +138,7 @@ function SamplingModalBody({ return ( onRespond(draftResult))} @@ -129,10 +151,12 @@ function SamplingModalBody({ function ElicitationFormModalBody({ request, serverName, + origin, onRespond, }: { request: ElicitRequestFormParams; serverName: string; + origin: PendingRequestOrigin; onRespond: (result: ElicitResult) => void; }) { // Seed with the schema's defaults so default-only fields the user never @@ -147,6 +171,7 @@ function ElicitationFormModalBody({ @@ -166,11 +191,13 @@ function ElicitationUrlModalBody({ id, message, url, + origin, onRespond, }: { id: string; message: string; url: string; + origin: PendingRequestOrigin; onRespond: (result: ElicitResult) => void; }) { const [isWaiting, setIsWaiting] = useState(false); @@ -179,6 +206,7 @@ function ElicitationUrlModalBody({ { @@ -237,6 +265,7 @@ export function PendingClientRequestModal({ @@ -246,6 +275,7 @@ export function PendingClientRequestModal({ key={request.id} request={request.request} serverName={serverName} + origin={request.origin} onRespond={onElicitationRespond} /> )} @@ -255,6 +285,7 @@ export function PendingClientRequestModal({ id={request.id} message={request.message} url={request.url} + origin={request.origin} onRespond={onElicitationRespond} /> )} diff --git a/clients/web/src/components/groups/SamplingRequestPanel/SamplingRequestPanel.tsx b/clients/web/src/components/groups/SamplingRequestPanel/SamplingRequestPanel.tsx index 0ba5cc9b2..79e1d211d 100644 --- a/clients/web/src/components/groups/SamplingRequestPanel/SamplingRequestPanel.tsx +++ b/clients/web/src/components/groups/SamplingRequestPanel/SamplingRequestPanel.tsx @@ -17,7 +17,9 @@ import type { CreateMessageRequestParams, CreateMessageResult, } from "@modelcontextprotocol/client"; +import type { PendingRequestOrigin } from "@inspector/core/mcp/types.js"; import { MessageBubble } from "../../elements/MessageBubble/MessageBubble"; +import { MrtrOriginNote } from "../../elements/MrtrOriginNote/MrtrOriginNote"; export interface SamplingRequestPanelProps { request: CreateMessageRequestParams; @@ -25,6 +27,12 @@ export interface SamplingRequestPanelProps { onResultChange: (result: CreateMessageResult) => void; onSend: () => void; onReject: () => void; + /** + * How the request reached the Inspector — a legacy server→client request or a + * modern MRTR `input_required` round. Drives the era-accurate note; defaults + * to `"server-request"` (legacy, note hidden). (#1704) + */ + origin?: PendingRequestOrigin; /** * A response has been dispatched; lock the actions so a second click can't * resolve the request twice (the underlying handler throws if called again). @@ -90,6 +98,7 @@ export function SamplingRequestPanel({ onResultChange, onSend, onReject, + origin = "server-request", busy = false, }: SamplingRequestPanelProps) { const { @@ -110,6 +119,7 @@ export function SamplingRequestPanel({ The server is requesting an LLM completion. + Messages: {messages.map((message, index) => ( diff --git a/clients/web/src/test/core/mcp/inspectorClientUrlElicitation.test.ts b/clients/web/src/test/core/mcp/inspectorClientUrlElicitation.test.ts index 0a3093848..764c6baf2 100644 --- a/clients/web/src/test/core/mcp/inspectorClientUrlElicitation.test.ts +++ b/clients/web/src/test/core/mcp/inspectorClientUrlElicitation.test.ts @@ -42,6 +42,10 @@ type FakeClient = { * we can drive `callTool` through the URL-elicitation error path without a live * server. The client is never connected; `callTool` only needs the injected * `callTool`/`request` methods plus the (connection-independent) helpers. + * + * The tool-call path issues `client.request({ method: "tools/call", … })` + * (through the MRTR driver, #1704), so these fakes drive the error path from + * `request`; `callTool` is left as an unused stub. */ function makeClient(fake: FakeClient): InspectorClient { const client = new InspectorClient( @@ -72,14 +76,14 @@ describe("InspectorClient URL-elicitation error path", () => { it("surfaces the URL elicitation, then retries the call on accept", async () => { let attempt = 0; const fake: FakeClient = { - callTool: vi.fn(async () => { + callTool: vi.fn(), + request: vi.fn(async () => { attempt += 1; if (attempt === 1) { throw new UrlElicitationRequiredError([elicitation]); } return okResult; }), - request: vi.fn(), }; const client = makeClient(fake); @@ -99,7 +103,7 @@ describe("InspectorClient URL-elicitation error path", () => { const invocation = await pending; expect(invocation.success).toBe(true); - expect(fake.callTool).toHaveBeenCalledTimes(2); + expect(fake.request).toHaveBeenCalledTimes(2); expect(client.getPendingElicitations()).toHaveLength(0); }); @@ -111,14 +115,14 @@ describe("InspectorClient URL-elicitation error path", () => { }; let attempt = 0; const fake: FakeClient = { - callTool: vi.fn(async () => { + callTool: vi.fn(), + request: vi.fn(async () => { attempt += 1; if (attempt === 1) { throw new UrlElicitationRequiredError([elicitation, second]); } return okResult; }), - request: vi.fn(), }; const client = makeClient(fake); @@ -143,15 +147,15 @@ describe("InspectorClient URL-elicitation error path", () => { const invocation = await pending; expect(invocation.success).toBe(true); - expect(fake.callTool).toHaveBeenCalledTimes(2); + expect(fake.request).toHaveBeenCalledTimes(2); }); it("aborts the call (no retry) when the user cancels a required elicitation", async () => { const fake: FakeClient = { - callTool: vi.fn(async () => { + callTool: vi.fn(), + request: vi.fn(async () => { throw new UrlElicitationRequiredError([elicitation]); }), - request: vi.fn(), }; const client = makeClient(fake); const failedDispatches = trackFailedDispatches(client); @@ -163,7 +167,7 @@ describe("InspectorClient URL-elicitation error path", () => { await client.getPendingElicitations()[0].respond({ action: "cancel" }); await expect(pending).rejects.toThrow(/cancelled/i); - expect(fake.callTool).toHaveBeenCalledTimes(1); + expect(fake.request).toHaveBeenCalledTimes(1); // The abort records exactly one failed history entry — not zero, not a // duplicate from the generic catch. expect(failedDispatches()).toBe(1); @@ -172,10 +176,10 @@ describe("InspectorClient URL-elicitation error path", () => { it("aborts with a loop error when the server re-requests a completed URL", async () => { // The server keeps returning the same URL after the user completes it. const fake: FakeClient = { - callTool: vi.fn(async () => { + callTool: vi.fn(), + request: vi.fn(async () => { throw new UrlElicitationRequiredError([elicitation]); }), - request: vi.fn(), }; const client = makeClient(fake); const failedDispatches = trackFailedDispatches(client); @@ -192,7 +196,7 @@ describe("InspectorClient URL-elicitation error path", () => { await expect(pending).rejects.toBeInstanceOf(UrlElicitationLoopError); await expect(pending).rejects.toMatchObject({ url: elicitation.url }); // Only the initial call + one retry ran; the URL was presented just once. - expect(fake.callTool).toHaveBeenCalledTimes(2); + expect(fake.request).toHaveBeenCalledTimes(2); expect(client.getPendingElicitations()).toHaveLength(0); // The loop abort records exactly one failed history entry (the accepted // first round records nothing). @@ -201,10 +205,10 @@ describe("InspectorClient URL-elicitation error path", () => { it("settles a pending error-path elicitation as cancelled on disconnect (no hang)", async () => { const fake: FakeClient = { - callTool: vi.fn(async () => { + callTool: vi.fn(), + request: vi.fn(async () => { throw new UrlElicitationRequiredError([elicitation]); }), - request: vi.fn(), }; const client = makeClient(fake); @@ -223,13 +227,13 @@ describe("InspectorClient URL-elicitation error path", () => { it("rethrows a -32042 error with no elicitations without queuing anything", async () => { const fake: FakeClient = { - callTool: vi.fn(async () => { + callTool: vi.fn(), + request: vi.fn(async () => { throw new ProtocolError( ProtocolErrorCode.UrlElicitationRequired, "This request requires browser-based authorization.", ); }), - request: vi.fn(), }; const client = makeClient(fake); @@ -237,15 +241,15 @@ describe("InspectorClient URL-elicitation error path", () => { code: ProtocolErrorCode.UrlElicitationRequired, }); expect(client.getPendingElicitations()).toHaveLength(0); - expect(fake.callTool).toHaveBeenCalledTimes(1); + expect(fake.request).toHaveBeenCalledTimes(1); }); it("rethrows an ordinary error unchanged", async () => { const fake: FakeClient = { - callTool: vi.fn(async () => { + callTool: vi.fn(), + request: vi.fn(async () => { throw new Error("boom"); }), - request: vi.fn(), }; const client = makeClient(fake); const failed = vi.fn(); diff --git a/clients/web/src/test/integration/mcp/inspectorClient-coverage-backfill.test.ts b/clients/web/src/test/integration/mcp/inspectorClient-coverage-backfill.test.ts index faa5c19cd..8c6de5325 100644 --- a/clients/web/src/test/integration/mcp/inspectorClient-coverage-backfill.test.ts +++ b/clients/web/src/test/integration/mcp/inspectorClient-coverage-backfill.test.ts @@ -567,12 +567,14 @@ describe("InspectorClient coverage backfill", () => { it("callTool error path covers general-only, tool-specific-only, and both metadata", async () => { client = stdioClient(); await client.connect(); - // Force the underlying SDK callTool to throw so the dispatchFailedToolCall - // error path runs for each metadata combination. + // Force the underlying SDK request to throw so the dispatchFailedToolCall + // error path runs for each metadata combination. The tool-call path issues + // `client.request({ method: "tools/call", … })` through the MRTR driver + // (#1704), so the throw is injected on `request`. const c = client as unknown as { - client: { callTool: () => Promise }; + client: { request: () => Promise }; }; - c.client.callTool = async () => { + c.client.request = async () => { throw new Error("forced-call-failure"); }; diff --git a/clients/web/src/test/integration/mcp/inspectorClient-modern-era.test.ts b/clients/web/src/test/integration/mcp/inspectorClient-modern-era.test.ts index 2a90ab766..a2e8e33bb 100644 --- a/clients/web/src/test/integration/mcp/inspectorClient-modern-era.test.ts +++ b/clients/web/src/test/integration/mcp/inspectorClient-modern-era.test.ts @@ -1,6 +1,7 @@ import { describe, it, expect, afterEach } from "vitest"; import { InspectorClient } from "@inspector/core/mcp/inspectorClient.js"; import { createTransportNode } from "@inspector/core/mcp/node/transport.js"; +import { ToolCallCancelledError } from "@inspector/core/mcp/toolCallCancelledError.js"; import { eraToVersionNegotiation, MODERN_PROTOCOL_VERSION, @@ -11,9 +12,17 @@ import { createTestServerInfo, createEchoTool, createMrtrTool, + createMrtrMultiRoundTool, + createMrtrRootsTool, + createMrtrSamplingTool, + createMrtrLoopTool, + createMrtrEdgeCaseTool, } from "@modelcontextprotocol/inspector-test-server"; import type { ServerConfig } from "@modelcontextprotocol/inspector-test-server"; -import type { ContentBlock } from "@modelcontextprotocol/client"; +import type { + ContentBlock, + JSONRPCRequest, +} from "@modelcontextprotocol/client"; /** * Live coverage of the modern (2026-07-28) connection path (#1700). The bundled @@ -126,25 +135,55 @@ describe("modern-era negotiation (2026-07-28)", () => { expect("text" in content[0] && content[0].text).toContain("hi"); }); - it("completes an MRTR round-trip (input_required elicitation → retry → final result)", async () => { - // The modern (2026-07-28) leg has no server→client requests; a tool that - // needs input returns `input_required` embedding the request, and the client - // fulfils it and retries with a new id. `createMrtrTool` returns - // `inputRequired({ inputRequests: { confirm: elicit(...) } })` on the first - // call and the final result once the answer is echoed back. + // Collect every outbound `tools/call` request frame the transport captured, + // so a test can assert what the MRTR retry actually put on the wire. + function collectToolCallRequests( + connected: InspectorClient, + ): JSONRPCRequest[] { + const frames: JSONRPCRequest[] = []; + connected.addEventListener("message", (event) => { + const entry = event.detail; + if ( + entry.direction === "request" && + "method" in entry.message && + entry.message.method === "tools/call" + ) { + frames.push(entry.message as JSONRPCRequest); + } + }); + return frames; + } + + async function startMrtrServer( + tool: ReturnType, + ): Promise { const started = createTestServerHttp({ serverInfo: createTestServerInfo("modern-mrtr-test", "1.0.0"), - tools: [createMrtrTool()], + tools: [tool], modern: {}, }); await started.start(); server = started; + return started; + } + it("drives an MRTR round-trip manually (pauses at the pending UI, retries with inputResponses + requestState on a new id)", async () => { + // The modern (2026-07-28) leg has no server→client requests; a tool that + // needs input returns `input_required` embedding the request. With the SDK's + // auto-fulfil disabled, InspectorClient drives the loop itself: it surfaces + // the embedded elicitation through the pending-request UI, then retries the + // original call with the answer. + const started = await startMrtrServer(createMrtrTool()); const connected = await connectWithEra(started.url, "modern"); + const toolCallFrames = collectToolCallRequests(connected); - // Auto-fulfil the embedded elicitation the MRTR tool requests, the same way - // the UI's pending-request panel would — which drives the SDK's retry. + // Prove the pending UX actually paused: record that an elicitation was + // surfaced BEFORE we answer it (the manual driver enqueued it), then answer. + let pausedAtPendingUi = false; connected.addEventListener("newPendingElicitation", (event) => { + pausedAtPendingUi = true; + // The manual driver tags an MRTR round's request "input-required". + expect(event.detail.origin).toBe("input-required"); void event.detail.respond({ action: "accept", content: { confirm: true }, @@ -157,12 +196,213 @@ describe("modern-era negotiation (2026-07-28)", () => { const result = await connected.callTool(mrtr!, { action: "deploy" }); expect(result.success).toBe(true); + expect(pausedAtPendingUi).toBe(true); + const content = result.result!.content as ContentBlock[]; const text = content[0] && "text" in content[0] ? content[0].text : ""; // The final result is only reachable after the input_required round was // fulfilled and the original call retried — i.e. the full MRTR loop ran. expect(text).toContain("MRTR complete"); expect(text).toContain("confirm"); + + // Two tools/call frames: the original and the retry. The retry has a + // DIFFERENT json-rpc id and carries inputResponses + the echoed requestState. + expect(toolCallFrames.length).toBe(2); + const [original, retry] = toolCallFrames; + expect(retry.id).not.toBe(original.id); + const retryParams = retry.params as { + inputResponses?: Record; + requestState?: unknown; + }; + expect(retryParams.inputResponses?.confirm).toEqual({ + action: "accept", + content: { confirm: true }, + }); + // The original call carries no requestState; the retry echoes the opaque + // server-minted token verbatim (shape: `mrtr::`). + expect( + (original.params as { requestState?: unknown }).requestState, + ).toBeUndefined(); + expect(retryParams.requestState).toMatch(/^mrtr:deploy:/); + }); + + it("drives a multi-round MRTR (two embedded elicitations in sequence, then completes)", async () => { + const started = await startMrtrServer(createMrtrMultiRoundTool()); + const connected = await connectWithEra(started.url, "modern"); + + const seenMessages: string[] = []; + connected.addEventListener("newPendingElicitation", (event) => { + seenMessages.push(event.detail.request.params.message ?? ""); + void event.detail.respond({ + action: "accept", + content: { value: `answer-${seenMessages.length}` }, + }); + }); + + const { tools } = await connected.listTools(); + const tool = tools.find((t) => t.name === "mrtr_two_step"); + const result = await connected.callTool(tool!, {}); + expect(result.success).toBe(true); + + // Both rounds surfaced, in order. + expect(seenMessages).toEqual([ + "Step 1: enter the first value", + "Step 2: enter the second value", + ]); + const content = result.result!.content as ContentBlock[]; + const text = content[0] && "text" in content[0] ? content[0].text : ""; + expect(text).toContain("MRTR two-step complete"); + }); + + it("auto-answers an embedded roots/list request from configured roots (no pending UI)", async () => { + const started = await startMrtrServer(createMrtrRootsTool()); + const connected = new InspectorClient( + { type: "streamable-http", url: started.url }, + { + environment: { transport: createTransportNode }, + versionNegotiation: eraToVersionNegotiation("modern"), + roots: [{ uri: "file:///workspace", name: "workspace" }], + }, + ); + client = connected; + await connected.connect(); + + let surfacedPending = false; + connected.addEventListener("newPendingElicitation", () => { + surfacedPending = true; + }); + connected.addEventListener("newPendingSample", () => { + surfacedPending = true; + }); + + const { tools } = await connected.listTools(); + const tool = tools.find((t) => t.name === "mrtr_roots"); + const result = await connected.callTool(tool!, {}); + expect(result.success).toBe(true); + // roots is answered silently — nothing pauses at the pending UI. + expect(surfacedPending).toBe(false); + const content = result.result!.content as ContentBlock[]; + const text = content[0] && "text" in content[0] ? content[0].text : ""; + expect(text).toContain("client reported 1 root"); + }); + + it("surfaces an embedded sampling request through the pending-sample UI", async () => { + const started = await startMrtrServer(createMrtrSamplingTool()); + const connected = await connectWithEra(started.url, "modern"); + + let sampleOrigin: string | undefined; + connected.addEventListener("newPendingSample", (event) => { + sampleOrigin = event.detail.origin; + void event.detail.respond({ + model: "test-model", + stopReason: "endTurn", + role: "assistant", + content: { type: "text", text: "hello" }, + }); + }); + + const { tools } = await connected.listTools(); + const tool = tools.find((t) => t.name === "mrtr_sample"); + const result = await connected.callTool(tool!, {}); + expect(result.success).toBe(true); + expect(sampleOrigin).toBe("input-required"); + const content = result.result!.content as ContentBlock[]; + const text = content[0] && "text" in content[0] ? content[0].text : ""; + expect(text).toContain("MRTR sample complete"); + }); + + it("echoes a declined elicitation back to the server (decline is not an abort)", async () => { + const started = await startMrtrServer(createMrtrTool()); + const connected = await connectWithEra(started.url, "modern"); + + connected.addEventListener("newPendingElicitation", (event) => { + void event.detail.respond({ action: "decline" }); + }); + + const { tools } = await connected.listTools(); + const tool = tools.find((t) => t.name === "mrtr_confirm"); + // The tool still completes — the declined result is echoed back, and the + // server returns its final result citing the declined answer. + const result = await connected.callTool(tool!, { action: "deploy" }); + expect(result.success).toBe(true); + const content = result.result!.content as ContentBlock[]; + const text = content[0] && "text" in content[0] ? content[0].text : ""; + expect(text).toContain("MRTR complete"); + expect(text).toContain("decline"); + }); + + it("bounds a pathological MRTR that never completes (MRTR_MAX_ROUNDS)", async () => { + const started = await startMrtrServer(createMrtrLoopTool()); + const connected = await connectWithEra(started.url, "modern"); + + // Auto-answer every round; the server never completes, so the driver's cap + // must stop the loop rather than spin forever. + connected.addEventListener("newPendingElicitation", (event) => { + void event.detail.respond({ action: "accept", content: { value: "x" } }); + }); + + const { tools } = await connected.listTools(); + const tool = tools.find((t) => t.name === "mrtr_loop"); + await expect(connected.callTool(tool!, {})).rejects.toThrow( + /exceeded .* input_required rounds/, + ); + }); + + it("handles requestState-only and inputRequests-only rounds (param shaping)", async () => { + const started = await startMrtrServer(createMrtrEdgeCaseTool()); + const connected = await connectWithEra(started.url, "modern"); + const toolCallFrames = collectToolCallRequests(connected); + + connected.addEventListener("newPendingElicitation", (event) => { + void event.detail.respond({ action: "accept", content: { value: "n" } }); + }); + + const { tools } = await connected.listTools(); + const tool = tools.find((t) => t.name === "mrtr_edge"); + const result = await connected.callTool(tool!, {}); + expect(result.success).toBe(true); + const content = result.result!.content as ContentBlock[]; + const text = content[0] && "text" in content[0] ? content[0].text : ""; + expect(text).toContain("MRTR edge complete"); + + // Three rounds: original + two retries. + expect(toolCallFrames.length).toBe(3); + // Round-1 retry carries inputResponses but NO requestState (round 1 minted + // none). + const retry1 = toolCallFrames[1].params as { + inputResponses?: unknown; + requestState?: unknown; + }; + expect(retry1.inputResponses).toBeDefined(); + expect(retry1.requestState).toBeUndefined(); + // Round-2 retry carries requestState and no meaningful inputResponses (round + // 2 was requestState-only; the driver adds none — the modern SDK codec may + // serialize an empty `{}` on the wire). + const retry2 = toolCallFrames[2].params as { + inputResponses?: Record; + requestState?: unknown; + }; + expect(retry2.requestState).toBeDefined(); + expect(Object.keys(retry2.inputResponses ?? {})).toHaveLength(0); + }); + + it("cancels an in-flight MRTR call while its embedded request is pending", async () => { + const started = await startMrtrServer(createMrtrTool()); + const connected = await connectWithEra(started.url, "modern"); + + // Cancel the tool call the moment its embedded elicitation is surfaced, + // instead of answering it. + connected.addEventListener("newPendingElicitation", () => { + connected.cancelToolCall(); + }); + + const { tools } = await connected.listTools(); + const tool = tools.find((t) => t.name === "mrtr_confirm"); + await expect( + connected.callTool(tool!, { action: "deploy" }), + ).rejects.toThrow(ToolCallCancelledError); + // The pending elicitation was removed when the call aborted. + expect(connected.getPendingElicitations()).toHaveLength(0); }); it("rejects a legacy client against a strict modern-only server", async () => { diff --git a/core/mcp/elicitationCreateMessage.ts b/core/mcp/elicitationCreateMessage.ts index 8589a4bc3..2c30a6b2b 100644 --- a/core/mcp/elicitationCreateMessage.ts +++ b/core/mcp/elicitationCreateMessage.ts @@ -1,5 +1,6 @@ import type { ElicitRequest, ElicitResult } from "@modelcontextprotocol/client"; import { RELATED_TASK_META_KEY } from "@modelcontextprotocol/client"; +import type { PendingRequestOrigin } from "./types.js"; export type { ElicitRequest, ElicitResult }; @@ -13,6 +14,7 @@ export interface InspectorPendingElicitation { timestamp: Date; request: ElicitRequest; taskId?: string; + origin: PendingRequestOrigin; } /** @@ -23,8 +25,19 @@ export class ElicitationCreateMessage { public readonly timestamp: Date; public readonly request: ElicitRequest; public readonly taskId?: string; + /** + * How this request reached the Inspector — a legacy server→client request or + * a modern MRTR `input_required` round. Drives era-accurate copy in the + * pending-request UI. Defaults to `"server-request"` so existing call sites + * (and stories) keep the legacy semantics unchanged. + */ + public readonly origin: PendingRequestOrigin; private resolvePromise?: (result: ElicitResult) => void; - /** Set only for task-augmented elicit; used when user declines so server's tasks/result receives an error */ + /** + * Rejects the originating call with an error. Set for task-augmented elicit + * (so the server's `tasks/result` receives the error on decline) and for + * MRTR-driven elicitations (so a genuine failure aborts the tool call). + */ private rejectCallback?: (error: Error) => void; private onRemove: (id: string) => void; @@ -33,6 +46,7 @@ export class ElicitationCreateMessage { resolve: (result: ElicitResult) => void, onRemove: (id: string) => void, reject?: (error: Error) => void, + origin: PendingRequestOrigin = "server-request", ) { this.onRemove = onRemove; this.id = `elicitation-${crypto.randomUUID()}`; @@ -41,6 +55,7 @@ export class ElicitationCreateMessage { // Extract taskId from request params metadata if present const relatedTask = request.params?._meta?.[RELATED_TASK_META_KEY]; this.taskId = relatedTask?.taskId; + this.origin = origin; this.resolvePromise = resolve; this.rejectCallback = reject; } diff --git a/core/mcp/inspectorClient.ts b/core/mcp/inspectorClient.ts index fd0e158b2..a555c3e53 100644 --- a/core/mcp/inspectorClient.ts +++ b/core/mcp/inspectorClient.ts @@ -14,6 +14,7 @@ import type { ToolCallInvocation, AppRendererClient, InspectorClientOptions, + PendingRequestOrigin, } from "./types.js"; // Re-export so v1.5 tests that do `import { InspectorClientOptions } from // "@inspector/core/mcp/inspectorClient.js"` keep resolving. @@ -78,6 +79,7 @@ import type { ReadResourceRequest, GetPromptRequest, CompleteRequest, + ListRootsRequest, } from "@modelcontextprotocol/client"; import type { Transport } from "@modelcontextprotocol/client"; import type { @@ -86,11 +88,20 @@ import type { VersionNegotiationOptions, ProtocolEra, DiscoverResult, + InputRequests, + InputRequiredOptions, + StandardSchemaV1, } from "@modelcontextprotocol/client"; import { ProtocolError, ProtocolErrorCode } from "@modelcontextprotocol/client"; +import { + isInputRequiredResult, + withInputRequired, +} from "@modelcontextprotocol/client"; import { EmptyResultSchema, CallToolResultSchema, + GetPromptResultSchema, + ReadResourceResultSchema, // Task request schemas — used for `.shape.params` in the 3-arg custom // `setRequestHandler` form (tasks/* are excluded from v2's spec-method set). ListTasksRequestSchema, @@ -173,6 +184,17 @@ interface ReceiverTaskRecord { */ const MAX_URL_ELICITATION_RETRIES = 5; +/** + * Error used to reject a pending sampling/elicitation request when the tool + * call driving its MRTR round is aborted (e.g. the user hits Cancel). Its + * message is not surfaced directly — `callToolWithRetries` maps the abort to a + * {@link ToolCallCancelledError} by inspecting the controller's reason — but a + * concrete error is needed to reject the awaiting driver promise. + */ +function createPendingAbortError(): Error { + return new Error("Pending request aborted"); +} + /** * The abort reason used by `cancelToolCall()`. It rides along on the * `notifications/cancelled` sent to the server and lets `callToolWithRetries` @@ -229,6 +251,14 @@ interface ToolCallRequest { * - Access to client functionality (prompts, resources, tools) */ export class InspectorClient extends InspectorClientEventTarget { + /** + * Upper bound on MRTR (`input_required`) rounds for a single logical request + * before {@link requestWithInputRequired} gives up. We drive the loop + * ourselves (`inputRequired: { autoFulfill: false }`), so this is the manual + * counterpart to the SDK auto-driver's default `maxRounds` (10) and guards + * against a server that keeps returning `input_required` forever. + */ + private static readonly MRTR_MAX_ROUNDS = 10; private client: Client | null = null; private appRendererClientProxy: AppRendererClient | null = null; // Lazily-built validator used only on the skipOutputValidation path to detect @@ -403,12 +433,21 @@ export class InspectorClient extends InspectorClientEventTarget { const clientOptions: { capabilities?: ClientCapabilities; versionNegotiation?: VersionNegotiationOptions; + inputRequired?: InputRequiredOptions; } = { // Per-server protocol era (SEP §7.8), threaded from config via // `eraToVersionNegotiation` and defaulted to `{ mode: "legacy" }` in the // constructor. "legacy" keeps the wire byte-identical to a 2025 client; // "auto"/"modern" opt into 2026-era negotiation (#1626). versionNegotiation: this.versionNegotiation, + // Drive MRTR (SEP-2322) manually instead of letting the SDK auto-fulfil + // and hide the retry loop (#1704). Unconditional and safe on every era: + // legacy servers never return `input_required`, so this is a no-op there; + // on modern connections the three multi-round-trip methods opt in via + // `allowInputRequired` in `requestWithInputRequired`, and no other method + // can receive an `input_required` result. The negotiated era is unknown + // at construction time, so gating here is impossible anyway. + inputRequired: { autoFulfill: false }, }; const capabilities: ClientCapabilities = {}; if (this.sample) { @@ -1078,19 +1117,7 @@ export class InspectorClient extends InspectorClientEventTarget { taskResult as unknown as CreateMessageResult, ); } - return new Promise((resolve, reject) => { - const samplingRequest = new SamplingCreateMessage( - request, - (result) => { - resolve(result); - }, - (error) => { - reject(error); - }, - (id) => this.removePendingSample(id), - ); - this.addPendingSample(samplingRequest); - }); + return this.enqueuePendingSample(request, "server-request"); }; this.client.setRequestHandler( "sampling/createMessage", @@ -1158,16 +1185,7 @@ export class InspectorClient extends InspectorClientEventTarget { const taskResult: CreateTaskResult = { task: record.task }; return Promise.resolve(taskResult as unknown as ElicitResult); } - return new Promise((resolve) => { - const elicitationRequest = new ElicitationCreateMessage( - request, - (result) => { - resolve(result); - }, - (id) => this.removePendingElicitation(id), - ); - this.addPendingElicitation(elicitationRequest); - }); + return this.enqueuePendingElicitation(request, "server-request"); }; this.client.setRequestHandler("elicitation/create", elicitHandler); if (this.receiverTasks) { @@ -1683,6 +1701,215 @@ export class InspectorClient extends InspectorClientEventTarget { return { tasks: result.tasks as Task[], nextCursor: result.nextCursor }; } + /** + * Surface a sampling request through the pending-request UI and resolve with + * the user's answer. Shared by the inbound `sampling/createMessage` handler + * (legacy server→client request) and the MRTR driver (a modern + * `input_required` round embeds the request in a tool-call result). `origin` + * tags which of the two so the UI can show era-accurate semantics. A + * declined/cancelled sampling still resolves normally so the bare result is + * echoed back to the server; only a genuine failure or an `signal` abort + * rejects (so the MRTR driver can abort the originating call). `signal` is + * only passed by the MRTR driver (the tool call's abort signal) — while the + * driver awaits an answer there is no in-flight SDK request to carry it. + */ + private enqueuePendingSample( + request: CreateMessageRequest, + origin: PendingRequestOrigin, + signal?: AbortSignal, + ): Promise { + // A Promise's resolve/reject is idempotent (first settle wins, later ones + // no-op), so the respond/reject/abort paths need no extra guard against a + // double settle. + return new Promise((resolvePromise, rejectPromise) => { + const sample = new SamplingCreateMessage( + request, + resolvePromise, + rejectPromise, + (id) => this.removePendingSample(id), + origin, + ); + this.addPendingSample(sample); + this.wirePendingAbort(signal, () => { + this.removePendingSample(sample.id); + rejectPromise(createPendingAbortError()); + }); + }); + } + + /** + * Surface an elicitation request through the pending-request UI and resolve + * with the user's answer. Shared by the inbound `elicitation/create` handler + * and the MRTR driver — see {@link enqueuePendingSample} for the `origin` / + * `signal` semantics. A declined/cancelled elicitation resolves with the + * corresponding `ElicitResult` (echoed to the server on retry); only a + * genuine failure or a `signal` abort rejects. + */ + private enqueuePendingElicitation( + request: ElicitRequest, + origin: PendingRequestOrigin, + signal?: AbortSignal, + ): Promise { + // See {@link enqueuePendingSample} — Promise settle is idempotent. + return new Promise((resolvePromise, rejectPromise) => { + const elicitation = new ElicitationCreateMessage( + request, + resolvePromise, + (id) => this.removePendingElicitation(id), + rejectPromise, + origin, + ); + this.addPendingElicitation(elicitation); + this.wirePendingAbort(signal, () => { + this.removePendingElicitation(elicitation.id); + rejectPromise(createPendingAbortError()); + }); + }); + } + + /** + * Reject a still-pending request when `signal` aborts (e.g. the user cancels + * the tool call while its MRTR round is awaiting an answer). No-op when + * `signal` is absent — the legacy inbound-handler path passes none. + */ + private wirePendingAbort( + signal: AbortSignal | undefined, + onAbort: () => void, + ): void { + if (!signal) return; + /* v8 ignore next 4 -- unreachable in the MRTR flow: an abort during the + SDK request leg rejects `client.request` before we reach the pending + enqueue, so the signal is never already-aborted here; kept as a defensive + guard because addEventListener("abort") would not fire on a pre-aborted + signal. */ + if (signal.aborted) { + onAbort(); + return; + } + signal.addEventListener("abort", onAbort, { once: true }); + } + + /** + * Drive a multi-round-trip request (SEP-2322 "MRTR") for one of the modern + * multi-round-trip methods (`tools/call`, `prompts/get`, `resources/read`). + * + * The client is constructed with `inputRequired: { autoFulfill: false }`, so + * an `input_required` result is handed back here (via `allowInputRequired`) + * instead of the SDK silently fulfilling and retrying. We surface each + * embedded request through the SAME pending-request UI the legacy + * server→client path uses (`fulfilInputRequests`), gather the bare results, + * and retry the ORIGINAL request with `inputResponses` + the echoed + * `requestState` on a fresh JSON-RPC id (`client.request` mints it). The loop + * runs until the server returns a complete result, bounded by + * {@link MRTR_MAX_ROUNDS}. + * + * On legacy connections a server never returns `input_required`, so the first + * response is always complete and this is a single `client.request` call. + */ + private async requestWithInputRequired( + method: "tools/call" | "prompts/get" | "resources/read", + params: Record, + resultSchema: TSchema, + requestOptions: RequestOptions, + ): Promise> { + const client = this.client; + /* v8 ignore next 3 -- defensive: every caller (callTool/getPrompt/ + readResource) already verified this.client is non-null before reaching + here, so this guard cannot trip in practice. */ + if (!client) { + throw new Error("Client is not connected"); + } + const wrapped = withInputRequired(resultSchema); + const signal = requestOptions.signal; + let round = 0; + let nextParams = params; + while (true) { + const outcome = await client.request( + { method, params: nextParams }, + wrapped, + { + ...requestOptions, + allowInputRequired: true, + }, + ); + if (!isInputRequiredResult(outcome)) { + return outcome; + } + round += 1; + if (round > InspectorClient.MRTR_MAX_ROUNDS) { + throw new Error( + `Multi-round-trip "${method}" exceeded ${InspectorClient.MRTR_MAX_ROUNDS} input_required rounds without completing.`, + ); + } + const inputResponses = await this.fulfilInputRequests( + outcome.inputRequests, + signal, + ); + // Retry re-issues the ORIGINAL params plus THIS round's answers and the + // server's opaque state token; the SDK assigns a fresh JSON-RPC id. + nextParams = { + ...params, + ...(inputResponses ? { inputResponses } : {}), + ...(outcome.requestState !== undefined + ? { requestState: outcome.requestState } + : {}), + }; + } + } + + /** + * Fulfil the embedded requests of one MRTR `input_required` round, keyed by + * the server-assigned identifiers echoed back in `inputResponses`. Sequential + * (one modal at a time) to keep the single-slot pending UI coherent. Returns + * `undefined` for a `requestState`-only round (no embedded requests); an empty + * `inputRequests` map yields an empty `{}`, which the retry echoes harmlessly. + */ + private async fulfilInputRequests( + inputRequests: InputRequests | undefined, + signal?: AbortSignal, + ): Promise | undefined> { + if (!inputRequests) return undefined; + const responses: Record = {}; + for (const [key, embedded] of Object.entries(inputRequests)) { + responses[key] = await this.fulfilEmbeddedInputRequest(embedded, signal); + } + return responses; + } + + /** + * Fulfil a single embedded MRTR request. `roots/list` is auto-answered from + * the configured roots (consistent with the legacy `roots/list` handler — no + * pending UX); `elicitation/create` and `sampling/createMessage` surface + * through the pending-request UI tagged `"input-required"`. + */ + private async fulfilEmbeddedInputRequest( + request: CreateMessageRequest | ElicitRequest | ListRootsRequest, + signal?: AbortSignal, + ): Promise { + switch (request.method) { + case "roots/list": + return { roots: this.roots ?? [] }; + case "elicitation/create": + return this.enqueuePendingElicitation( + request, + "input-required", + signal, + ); + case "sampling/createMessage": + return this.enqueuePendingSample(request, "input-required", signal); + /* v8 ignore next 6 -- defensive: an SDK server rejects an unknown embedded + method before it reaches the wire, so this only guards against a + non-conformant hand-rolled server; not reproducible against the + SDK-based test servers. */ + default: + throw new Error( + `Unsupported embedded input_required request method: ${ + (request as { method: string }).method + }`, + ); + } + } + /** * Get all pending sampling requests */ @@ -2092,29 +2319,28 @@ export class InspectorClient extends InspectorClientEventTarget { callParams.task = { ttl: taskOptions.ttl }; } - // MCP Apps forward the server's CallToolResult straight to the running - // view, which is the real consumer. The SDK's callTool() validates - // structuredContent against the tool's outputSchema and THROWS on a - // mismatch — which would deny the app a result the server actually - // returned (and that legacy hosts render fine). For those passthrough - // calls go through request() directly, which skips that host-side - // validation. Regular Tools-screen calls keep validating. const requestOptions = this.getRequestOptions( metadata?.progressToken, signal, ); - // Both branches yield a CallToolResult: request() parsed it with - // CallToolResultSchema above, callTool() returns the same shape — so the - // `as CallToolResult` casts below are safe. + // Route through the MRTR driver (`requestWithInputRequired`) so a modern + // `input_required` result pauses at the pending-request UI and retries with + // the user's answer (#1704). Both eras use `client.request` with + // `CallToolResultSchema`; on legacy this is a single round. We deliberately + // do NOT use `client.callTool` (which would auto-fulfil / reject on an + // `input_required` result) — its only extra behavior over `request` is + // structuredContent output validation, which we already re-implement below + // via `validateToolOutput`. MCP Apps passthrough (skipOutputValidation) + // simply skips that check; both paths yield a CallToolResult once the + // driver returns a complete (non-`input_required`) result. const result = await this.invokeMcpClient( () => - options?.skipOutputValidation - ? client.request( - { method: "tools/call", params: callParams }, - CallToolResultSchema, - requestOptions, - ) - : client.callTool(callParams, requestOptions), + this.requestWithInputRequired( + "tools/call", + callParams, + CallToolResultSchema, + requestOptions, + ), { method: "tools/call", toolName: tool.name }, ); @@ -2126,10 +2352,7 @@ export class InspectorClient extends InspectorClientEventTarget { // what a strict host would do), so the caller sees the error. // - skipOutputValidation (MCP Apps passthrough): non-fatal — surface it as // an advisory so a schema-violating-but-real result still reaches the app. - const outputValidationError = this.validateToolOutput( - tool, - result as CallToolResult, - ); + const outputValidationError = this.validateToolOutput(tool, result); if (outputValidationError && !options?.skipOutputValidation) { // Match the prior contract: on v1 a strict output-schema violation // surfaced as the SDK's typed `McpError`/`ProtocolError` (code @@ -2144,7 +2367,7 @@ export class InspectorClient extends InspectorClientEventTarget { const invocation: ToolCallInvocation = { toolName: tool.name, params: args, - result: result as CallToolResult, + result, timestamp, success: true, metadata, @@ -2295,6 +2518,10 @@ export class InspectorClient extends InspectorClientEventTarget { // synchronously (or for which the tool forbids/ignores task augmentation) // may return an immediate `CallToolResult` instead — accept either with a // union schema and branch on the presence of `task`. + // + // NOTE: this task path does NOT opt into `allowInputRequired`, so a + // task-augmented tool that returns `input_required` is not MRTR-driven here. + // Driving MRTR over the tasks extension is out of scope for #1704. const requestPromise = client.request( { method: "tools/call", params }, CreateTaskResultSchema.or(CallToolResultSchema), @@ -2669,10 +2896,15 @@ export class InspectorClient extends InspectorClientEventTarget { uri, ...(effectiveMeta ? { _meta: effectiveMeta } : {}), }; + // MRTR-driven (#1704): a modern `resources/read` can return `input_required` + // (embedding an elicitation/sampling request); the driver pauses at the + // pending-request UI and retries with the answer. Legacy is a single round. const result = await this.invokeMcpClient( () => - this.client!.readResource( + this.requestWithInputRequired( + "resources/read", params, + ReadResourceResultSchema, this.getRequestOptions(metadata?.progressToken), ), { method: "resources/read" }, @@ -2836,10 +3068,15 @@ export class InspectorClient extends InspectorClientEventTarget { ...(effectiveMeta ? { _meta: effectiveMeta } : {}), }; + // MRTR-driven (#1704): a modern `prompts/get` can return `input_required`; + // the driver pauses at the pending-request UI and retries with the answer. + // Legacy is a single round. const result = await this.invokeMcpClient( () => - this.client!.getPrompt( + this.requestWithInputRequired( + "prompts/get", params, + GetPromptResultSchema, this.getRequestOptions(metadata?.progressToken), ), { method: "prompts/get", toolName: name }, diff --git a/core/mcp/samplingCreateMessage.ts b/core/mcp/samplingCreateMessage.ts index b03b098fb..4f9539cbb 100644 --- a/core/mcp/samplingCreateMessage.ts +++ b/core/mcp/samplingCreateMessage.ts @@ -3,6 +3,7 @@ import type { CreateMessageResult, } from "@modelcontextprotocol/client"; import { RELATED_TASK_META_KEY } from "@modelcontextprotocol/client"; +import type { PendingRequestOrigin } from "./types.js"; export type { CreateMessageRequest, CreateMessageResult }; @@ -16,6 +17,7 @@ export interface InspectorPendingSampling { timestamp: Date; request: CreateMessageRequest; taskId?: string; + origin: PendingRequestOrigin; } /** @@ -26,6 +28,13 @@ export class SamplingCreateMessage { public readonly timestamp: Date; public readonly request: CreateMessageRequest; public readonly taskId?: string; + /** + * How this request reached the Inspector — a legacy server→client request or + * a modern MRTR `input_required` round. Drives era-accurate copy in the + * pending-request UI. Defaults to `"server-request"` so existing call sites + * (and stories) keep the legacy semantics unchanged. + */ + public readonly origin: PendingRequestOrigin; private resolvePromise?: (result: CreateMessageResult) => void; private rejectPromise?: (error: Error) => void; private onRemove: (id: string) => void; @@ -35,6 +44,7 @@ export class SamplingCreateMessage { resolve: (result: CreateMessageResult) => void, reject: (error: Error) => void, onRemove: (id: string) => void, + origin: PendingRequestOrigin = "server-request", ) { this.onRemove = onRemove; this.id = `sampling-${crypto.randomUUID()}`; @@ -43,6 +53,7 @@ export class SamplingCreateMessage { // Extract taskId from request params metadata if present const relatedTask = request.params?._meta?.[RELATED_TASK_META_KEY]; this.taskId = relatedTask?.taskId; + this.origin = origin; this.resolvePromise = resolve; this.rejectPromise = reject; } diff --git a/core/mcp/types.ts b/core/mcp/types.ts index f97d8483e..444f4f349 100644 --- a/core/mcp/types.ts +++ b/core/mcp/types.ts @@ -227,6 +227,17 @@ export interface StderrLogEntry { /** Who sent a tracked message: the inspector ("client") or the "server". */ export type MessageOrigin = "client" | "server"; +/** + * How a pending sampling/elicitation request reached the Inspector, so the + * pending-request UI can show era-accurate semantics: + * - `"server-request"` — a legacy (≤2025-11-25) server→client JSON-RPC request + * (`sampling/createMessage` / `elicitation/create`) delivered to our handler. + * - `"input-required"` — a modern (2026-07-28) MRTR round: the request was + * embedded in a tool-call/prompt/resource `input_required` result, and the + * user's answer is echoed back to the server as a retry (SEP-2322). + */ +export type PendingRequestOrigin = "server-request" | "input-required"; + export interface MessageEntry { id: string; timestamp: Date; diff --git a/test-servers/src/preset-registry.ts b/test-servers/src/preset-registry.ts index 34989453d..0869b7bb0 100644 --- a/test-servers/src/preset-registry.ts +++ b/test-servers/src/preset-registry.ts @@ -20,6 +20,11 @@ import { createListRootsTool, createCollectFormElicitationTool, createMrtrTool, + createMrtrMultiRoundTool, + createMrtrRootsTool, + createMrtrSamplingTool, + createMrtrLoopTool, + createMrtrEdgeCaseTool, createCollectUrlElicitationTool, createUrlElicitationFormTool, createSendNotificationTool, @@ -99,6 +104,16 @@ function resolveToolPreset( return createCollectFormElicitationTool(); case "mrtr_confirm": return createMrtrTool(); + case "mrtr_two_step": + return createMrtrMultiRoundTool(); + case "mrtr_roots": + return createMrtrRootsTool(); + case "mrtr_sample": + return createMrtrSamplingTool(); + case "mrtr_loop": + return createMrtrLoopTool(); + case "mrtr_edge": + return createMrtrEdgeCaseTool(); case "collect_url_elicitation": return createCollectUrlElicitationTool(); case "url_elicitation_form": diff --git a/test-servers/src/test-server-fixtures.ts b/test-servers/src/test-server-fixtures.ts index 3f988d61f..b278dffd0 100644 --- a/test-servers/src/test-server-fixtures.ts +++ b/test-servers/src/test-server-fixtures.ts @@ -442,6 +442,223 @@ export function createMrtrTool(): ToolDefinition { }; } +/** + * A two-round MRTR tool: it asks for a first value, then (on the retry) a second + * value, then completes. Exercises the manual driver's loop across MORE than one + * `input_required` round. + * + * The modern leg is stateless and each retry carries only THAT round's + * `inputResponses` (spec-correct — the client does not accumulate prior + * answers). So the tool tracks which step it is on via the opaque `requestState` + * the client echoes back, not via accumulated responses. Used to verify the + * Inspector surfaces each embedded elicitation in turn and only completes after + * both are answered (#1704). + */ +export function createMrtrMultiRoundTool(): ToolDefinition { + return { + name: "mrtr_two_step", + description: + "Two-round MRTR tool: collects a first value, then a second value, then completes.", + inputSchema: {}, + handler: async ( + _params: Record, + _context?: TestServerContext, + extra?: HandlerExtra, + ) => { + const step = + typeof extra?.requestState === "string" ? extra.requestState : "start"; + if (step === "start") { + return inputRequired({ + inputRequests: { + first: inputRequired.elicit({ + message: "Step 1: enter the first value", + requestedSchema: { + type: "object", + properties: { value: { type: "string", title: "First" } }, + required: ["value"], + }, + }), + }, + requestState: `mrtr-two:step2:${++mrtrMintCount}`, + }); + } + if (step.startsWith("mrtr-two:step2")) { + return inputRequired({ + inputRequests: { + second: inputRequired.elicit({ + message: "Step 2: enter the second value", + requestedSchema: { + type: "object", + properties: { value: { type: "string", title: "Second" } }, + required: ["value"], + }, + }), + }, + requestState: `mrtr-two:done:${++mrtrMintCount}`, + }); + } + return toToolResult( + `MRTR two-step complete — final answer: ${JSON.stringify( + extra?.inputResponses?.second, + )}`, + ); + }, + }; +} + +/** + * An MRTR tool that embeds a `roots/list` request. The Inspector auto-answers it + * from the configured roots (no pending UX), so the retry carries the client's + * `{ roots }` result and the tool completes reporting how many roots it saw. + * Used to verify silent roots fulfilment mid-MRTR (#1704). + */ +export function createMrtrRootsTool(): ToolDefinition { + return { + name: "mrtr_roots", + description: + "MRTR tool that asks the client for its roots, then completes reporting the count.", + inputSchema: {}, + handler: async ( + _params: Record, + _context?: TestServerContext, + extra?: HandlerExtra, + ) => { + const responses = extra?.inputResponses; + if (!responses || responses.roots === undefined) { + return inputRequired({ + inputRequests: { roots: inputRequired.listRoots() }, + requestState: `mrtr-roots:${++mrtrMintCount}`, + }); + } + const rootsResult = responses.roots as { roots?: unknown[] }; + const count = Array.isArray(rootsResult.roots) + ? rootsResult.roots.length + : 0; + return toToolResult( + `MRTR roots complete — client reported ${count} root(s)`, + ); + }, + }; +} + +/** + * An MRTR tool that embeds a `sampling/createMessage` request. On the retry the + * client's sampling result is echoed back and the tool completes. Verifies the + * driver surfaces an embedded sampling request through the pending-request UI + * the same way it does an elicitation (#1704). + */ +export function createMrtrSamplingTool(): ToolDefinition { + return { + name: "mrtr_sample", + description: + "MRTR tool that asks the client to sample an LLM completion, then completes.", + inputSchema: {}, + handler: async ( + _params: Record, + _context?: TestServerContext, + extra?: HandlerExtra, + ) => { + const responses = extra?.inputResponses; + if (!responses || responses.sample === undefined) { + return inputRequired({ + inputRequests: { + sample: inputRequired.createMessage({ + messages: [ + { + role: "user", + content: { type: "text", text: "Say hello." }, + }, + ], + maxTokens: 64, + }), + }, + requestState: `mrtr-sample:${++mrtrMintCount}`, + }); + } + return toToolResult( + `MRTR sample complete — echoed: ${JSON.stringify(responses.sample)}`, + ); + }, + }; +} + +/** + * A pathological MRTR tool that ALWAYS returns `input_required` and never + * completes. Used to verify the manual driver bounds the loop (its + * `MRTR_MAX_ROUNDS` cap) instead of spinning forever (#1704). + */ +export function createMrtrLoopTool(): ToolDefinition { + return { + name: "mrtr_loop", + description: + "Pathological MRTR tool that never completes — always returns input_required.", + inputSchema: {}, + handler: async () => { + return inputRequired({ + inputRequests: { + again: inputRequired.elicit({ + message: "Answer again (this never completes)", + requestedSchema: { + type: "object", + properties: { value: { type: "string", title: "Value" } }, + }, + }), + }, + requestState: `mrtr-loop:${++mrtrMintCount}`, + }); + }, + }; +} + +/** + * An MRTR tool exercising the driver's param-shaping edge cases (#1704): + * - Round 1 embeds an elicitation but mints NO `requestState` (so the retry + * carries `inputResponses` and no `requestState`). + * - Round 2 is `requestState`-only: it carries a `requestState` and NO + * `inputRequests` (so the retry carries `requestState` and no + * `inputResponses`). + * - Round 3 completes. + */ +export function createMrtrEdgeCaseTool(): ToolDefinition { + return { + name: "mrtr_edge", + description: + "MRTR tool covering the inputRequests-only and requestState-only round shapes.", + inputSchema: {}, + handler: async ( + _params: Record, + _context?: TestServerContext, + extra?: HandlerExtra, + ) => { + const state = + typeof extra?.requestState === "string" ? extra.requestState : ""; + // Round 2: the client answered round 1's elicitation. Bounce a + // requestState-only round (no embedded requests) to move to the final leg. + if (extra?.inputResponses?.note !== undefined) { + return inputRequired({ + requestState: `mrtr-edge:final:${++mrtrMintCount}`, + }); + } + // Round 3: the requestState-only round came back — complete. + if (state.startsWith("mrtr-edge:final")) { + return toToolResult("MRTR edge complete"); + } + // Round 1: embed an elicitation with NO requestState. + return inputRequired({ + inputRequests: { + note: inputRequired.elicit({ + message: "Edge round 1: enter a note", + requestedSchema: { + type: "object", + properties: { value: { type: "string", title: "Note" } }, + }, + }), + }, + }); + }, + }; +} + /** * Create a "url_elicitation_form" tool that spins up a simple HTTP server on a dynamic * port with a form page, sends that URL via URL elicitation, and on form submit collects @@ -1291,7 +1508,8 @@ export function createAddToolTool(): ToolDefinition { { description: params.description as string, inputSchema: params.inputSchema as - Record | undefined, + | Record + | undefined, }, async () => { return { @@ -1395,7 +1613,8 @@ export function createAddPromptTool(): ToolDefinition { { description: params.description as string | undefined, argsSchema: params.argsSchema as - Record | undefined, + | Record + | undefined, }, async () => { return { @@ -1539,7 +1758,9 @@ export function createSendProgressTool( // Extract progressToken from metadata const progressToken = extra?._meta?.progressToken as - string | number | undefined; + | string + | number + | undefined; // Send progress notifications let sent = 0; @@ -1975,9 +2196,12 @@ export function createTaskTool( handler: { createTask: async (args, extra) => { const message = (args as Record)?.message as - string | undefined; + | string + | undefined; const progressToken = extra._meta?.progressToken as - string | number | undefined; + | string + | number + | undefined; const task = await extra.taskStore.createTask({}); runTaskExecution({ task, From 2ce620b9f56f1727836deb3dd68b867cda807493 Mon Sep 17 00:00:00 2001 From: cliffhall Date: Fri, 17 Jul 2026 22:07:00 -0400 Subject: [PATCH 2/3] Review: clarify sampling-reject and between-rounds abort comments (#1704) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Address review nits on PR #1719 (comment-only, no behavior change): - enqueuePendingSample docstring: sampling has no decline/cancel action; the panel either sends a result (resolve) or Rejects (reject → fails the call). The shared "declined/cancelled resolves" wording was accurate only for the elicitation case. - callToolWithRetries abort comment: note the between-rounds case — when the abort lands while an MRTR round awaits an embedded pending request there is no in-flight SDK request, so nothing is sent on the wire; the outcome (ToolCallCancelledError, no failed-call record) is identical. Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_01XNdjEPKLG637X8YmhDiEk5 --- core/mcp/inspectorClient.ts | 28 ++++++++++++++++++---------- 1 file changed, 18 insertions(+), 10 deletions(-) diff --git a/core/mcp/inspectorClient.ts b/core/mcp/inspectorClient.ts index a555c3e53..2b48cc06c 100644 --- a/core/mcp/inspectorClient.ts +++ b/core/mcp/inspectorClient.ts @@ -1706,12 +1706,14 @@ export class InspectorClient extends InspectorClientEventTarget { * the user's answer. Shared by the inbound `sampling/createMessage` handler * (legacy server→client request) and the MRTR driver (a modern * `input_required` round embeds the request in a tool-call result). `origin` - * tags which of the two so the UI can show era-accurate semantics. A - * declined/cancelled sampling still resolves normally so the bare result is - * echoed back to the server; only a genuine failure or an `signal` abort - * rejects (so the MRTR driver can abort the originating call). `signal` is - * only passed by the MRTR driver (the tool call's abort signal) — while the - * driver awaits an answer there is no in-flight SDK request to carry it. + * tags which of the two so the UI can show era-accurate semantics. + * + * Sampling has no decline/cancel action (unlike elicitation): the panel + * either sends a `CreateMessageResult` (resolve — echoed to the server) or + * Rejects (reject — which fails the tool call). A `signal` abort likewise + * rejects so the MRTR driver can abort the originating call. `signal` is only + * passed by the MRTR driver (the tool call's abort signal) — while the driver + * awaits an answer there is no in-flight SDK request to carry it. */ private enqueuePendingSample( request: CreateMessageRequest, @@ -2178,10 +2180,16 @@ export class InspectorClient extends InspectorClientEventTarget { return await this.attemptToolCall(request, abortController.signal); } catch (error) { // The controller was aborted. A deliberate `cancelToolCall()` (matched - // by reason) means the SDK already sent `notifications/cancelled` — so - // surface a clean cancellation, not a generic failure, and don't record - // it in history. Any other abort (e.g. a disconnect, which aborts with a - // different reason) falls through to the normal error path (#1458). + // by reason) means the SDK already sent `notifications/cancelled` if the + // abort landed during a `client.request` leg — so surface a clean + // cancellation, not a generic failure, and don't record it in history. + // If instead the abort lands while an MRTR round is awaiting an embedded + // pending request (between `client.request` legs), there is no in-flight + // SDK request, so nothing is sent on the wire — `wirePendingAbort` just + // rejects the pending request and the driver abandons the retry; the + // outcome here is identical. Any other abort (e.g. a disconnect, which + // aborts with a different reason) falls through to the normal error path + // (#1458). if ( abortController.signal.aborted && abortController.signal.reason === TOOL_CALL_CANCELLED_REASON From 8dfe317f2d1b8ad818f7b252c74f7f8362d1b775 Mon Sep 17 00:00:00 2001 From: cliffhall Date: Fri, 17 Jul 2026 22:58:29 -0400 Subject: [PATCH 3/3] test-servers: add mrtr-showcase config exercising every MRTR preset (#1704) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A single modern-era streamable-HTTP server (port 3111) bundling all six MRTR presets — mrtr_confirm, mrtr_two_step, mrtr_sample, mrtr_roots, mrtr_edge, mrtr_loop — for manual testing of the manual-driving pending-request UX across single-round, multi-round, sampling, roots, param-shaping, and maxRounds paths. Document it in the README alongside modern-mrtr-http.json. Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_01XNdjEPKLG637X8YmhDiEk5 --- README.md | 2 +- test-servers/configs/mrtr-showcase-http.json | 20 ++++++++++++++++++++ 2 files changed, 21 insertions(+), 1 deletion(-) create mode 100644 test-servers/configs/mrtr-showcase-http.json diff --git a/README.md b/README.md index a7d5e4592..7789d36ce 100644 --- a/README.md +++ b/README.md @@ -110,7 +110,7 @@ npm run test-servers:build # (from clients/web) → tsc -p test-servers, emits The Vite alias `@modelcontextprotocol/inspector-test-server` (in `clients/web/vite.config.ts`) points at `test-servers/build/index.js` so `getTestMcpServerPath()` resolves to a real `.js` path. -A streamable-HTTP server can also serve the **modern (2026-07-28) protocol era** via the SDK's `createMcpHandler` — set `transport.modern` in the JSON config (`true` for dual-era stateless serving, or `{ "legacy": "reject" }` for modern-only strict), or pass `modern` on the `ServerConfig` for an in-process `createTestServerHttp`. This is what lets an Inspector connection negotiating `protocolEra: "auto" | "modern"` reach the modern leg (populated `server/discover`, sessionless). See `test-servers/configs/modern-http.json`. `test-servers/configs/modern-mrtr-http.json` additionally serves the `mrtr_confirm` tool (preset `mrtr_confirm`, `createMrtrTool`) over the modern leg: its handler returns `inputRequired(...)` embedding a form elicitation, so invoking it produces a real MRTR round-trip (`input_required` → the client fulfils the embedded elicitation and retries with a new id → `complete`). The Inspector drives MRTR manually (`inputRequired: { autoFulfill: false }`), so the embedded elicitation pauses at the pending-request modal (tagged "input_required") for you to answer, then the retry completes — useful for eyeballing both that pending-request UX and the Protocol view's MRTR conversation grouping. (The legacy `collect_elicitation` preset calls `server.elicitInput`, which errors on the 2026-07-28 leg — server→client requests aren't allowed there; MRTR is the modern replacement.) +A streamable-HTTP server can also serve the **modern (2026-07-28) protocol era** via the SDK's `createMcpHandler` — set `transport.modern` in the JSON config (`true` for dual-era stateless serving, or `{ "legacy": "reject" }` for modern-only strict), or pass `modern` on the `ServerConfig` for an in-process `createTestServerHttp`. This is what lets an Inspector connection negotiating `protocolEra: "auto" | "modern"` reach the modern leg (populated `server/discover`, sessionless). See `test-servers/configs/modern-http.json`. `test-servers/configs/modern-mrtr-http.json` additionally serves the `mrtr_confirm` tool (preset `mrtr_confirm`, `createMrtrTool`) over the modern leg: its handler returns `inputRequired(...)` embedding a form elicitation, so invoking it produces a real MRTR round-trip (`input_required` → the client fulfils the embedded elicitation and retries with a new id → `complete`). The Inspector drives MRTR manually (`inputRequired: { autoFulfill: false }`), so the embedded elicitation pauses at the pending-request modal (tagged "input_required") for you to answer, then the retry completes — useful for eyeballing both that pending-request UX and the Protocol view's MRTR conversation grouping. `test-servers/configs/mrtr-showcase-http.json` bundles every MRTR preset in one modern server for manual testing: `mrtr_confirm` (single round), `mrtr_two_step` (two elicitation rounds via `requestState`), `mrtr_sample` (embedded sampling → the Sampling panel), `mrtr_roots` (embedded `roots/list`, auto-answered silently from configured roots — no modal), `mrtr_edge` (an `inputRequests`-only round then a `requestState`-only round), and `mrtr_loop` (never completes → the `MRTR_MAX_ROUNDS` bound trips). (The legacy `collect_elicitation` preset calls `server.elicitInput`, which errors on the 2026-07-28 leg — server→client requests aren't allowed there; MRTR is the modern replacement.) ## Building diff --git a/test-servers/configs/mrtr-showcase-http.json b/test-servers/configs/mrtr-showcase-http.json new file mode 100644 index 000000000..026d43c01 --- /dev/null +++ b/test-servers/configs/mrtr-showcase-http.json @@ -0,0 +1,20 @@ +{ + "serverInfo": { + "name": "composable-mrtr-showcase", + "version": "1.0.0" + }, + "tools": [ + { "preset": "echo" }, + { "preset": "mrtr_confirm" }, + { "preset": "mrtr_two_step" }, + { "preset": "mrtr_sample" }, + { "preset": "mrtr_roots" }, + { "preset": "mrtr_edge" }, + { "preset": "mrtr_loop" } + ], + "transport": { + "type": "streamable-http", + "port": 3111, + "modern": true + } +}