diff --git a/AGENTS.md b/AGENTS.md index 8cf77bb4c..4f5a91393 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -33,6 +33,9 @@ inspector/ │ ├── json/ # JSON utilities and parameter/argument conversion │ ├── logging/ # Silent pino logger singleton │ ├── mcp/ # InspectorClient runtime + state stores +│ │ # (modernTaskSchemas.ts: SEP-2663 modern Tasks +│ │ # extension wire schemas + normalize/handle helpers, +│ │ # used by the raw-wire tasks/* channel — #1631) │ │ ├── import/ # Config import strategies (#1348): client-config parsers │ │ │ # (Claude Desktop/Cursor/Cline/VS Code), registry │ │ │ # server.json parser, strategy registry + well-known @@ -45,7 +48,9 @@ inspector/ │ ├── react/ # React hooks over the state stores │ └── storage/ # File I/O helpers (store-io.ts) used by OAuth persist backends ├── test-servers/ # Composable MCP test servers + fixtures used by integration tests. -│ ├── src/ # TypeScript sources. +│ ├── src/ # TypeScript sources. (modern-tasks.ts: SEP-2663 modern +│ │ # Tasks extension runtime + tasks/* Express interceptor +│ │ # + modern_task/modern_input_task tools — #1631) │ ├── build/ # Built JS (gitignored). Produced by `npm run test-servers:build` │ │ # so integration tests can spawn the stdio server as a real │ │ # subprocess via `node test-servers/build/test-server-stdio.js`. diff --git a/README.md b/README.md index 12c41b953..148202b31 100644 --- a/README.md +++ b/README.md @@ -120,6 +120,8 @@ A streamable-HTTP server can also serve the **modern (2026-07-28) protocol era** `test-servers/configs/subscriptions-legacy-http.json` and `test-servers/configs/subscriptions-modern-http.json` are the **resource-subscription era-fork showcase** (#1630). Both serve three `numbered_resources` with `subscriptions: true`; the legacy one also serves an `update_resource` tool, and the modern one sets `transport.modern: true`. Connect to the **legacy** server, open a resource in the **Resources** tab and click **Subscribe** — the client sends `resources/subscribe` (Network/Protocol view) and the Subscriptions section lists the URI with no stream chrome; call `update_resource` with that URI and the server updates the content and emits `notifications/resources/updated`, stamping the subscribed tile's last-updated time. Connect to the **modern** server with **Protocol Era = Modern** and the same Subscribe instead sends **`subscriptions/listen`** (its filter carries `resourceSubscriptions` + the `resourcesListChanged` opt-in) and resolves on `notifications/subscriptions/acknowledged`; the Subscriptions section then shows the stream-status badge (`Connecting…` → `Listening`) in its header, and if the long-lived stream drops it reconnects by re-listing. The modern config deliberately **omits** `update_resource`: the SDK's modern leg is stateless/per-request (`createMcpHandler(() => createMcpServer(config))`), so the tool would run against a throwaway server instance — the content change wouldn't persist for the next `resources/read`, and its `resources/updated` wouldn't reach the (separate) listen stream — which is more confusing than useful. The live update-notification round-trip is therefore demonstrated on the legacy (stateful-session) server; the modern server is for the subscribe/listen/badge behavior. (The Inspector's *receive* path is era-transparent, so a real stateful modern server that routes `resources/updated` onto the listen stream drives the subscribed tile the same way.) +`test-servers/configs/tasks-legacy-http.json` and `test-servers/configs/tasks-modern-http.json` are the **Tasks era-fork showcase** (#1631). The legacy server advertises `capabilities.tasks` (`tasks: { list, cancel }`) with the `simple_task` / `progress_task` / `elicitation_task` presets — connect to it, run one of those tools with **Run as task** on, and the **Tasks** tab lists it (populated via `tasks/list`), polls `tasks/get`, fetches the payload with the blocking `tasks/result`, and cancels with `tasks/cancel`. The modern server sets `transport.modern: true` and `tasksExtension: true`, advertising the `io.modelcontextprotocol/tasks` extension (SEP-2663) and serving the `modern_task` / `modern_input_task` tools. Connect with **Protocol Era = Modern**: the **Tasks** tab is now gated on the negotiated extension (not `capabilities.tasks`). Run `modern_task` as a task — the `tools/call` returns a `CreateTaskResult` (`resultType: "task"`, visible in the Protocol/Network tabs), the client polls **`tasks/get`** (no `tasks/list`), and the completed task inlines its result (no blocking `tasks/result`). Run `modern_input_task` and the task moves to `input_required`, surfacing an embedded elicitation through the pending-request modal; answering it sends **`tasks/update`** with the `inputResponses`, and the next poll completes. SDK v2 removed all tasks support **and** era-gates the `tasks/*` spec methods out of the modern era on both sides — so the Inspector drives the extension itself (the `resultType: "task"` frame is rewritten at the transport into a `CallToolResult` carrying the handle; `tasks/get`/`update`/`cancel` ride a raw-wire request channel with the full modern envelope), and the test server serves `tasks/*` from an Express interceptor ahead of the SDK handler (the SDK's modern leg would answer them `-32601`). The Tasks tab's **Refresh** re-polls the handles already known to the client (modern has no server-side task list). + ## Building ```bash diff --git a/clients/web/src/App.test.tsx b/clients/web/src/App.test.tsx index fc7fc9b11..1ffd72eea 100644 --- a/clients/web/src/App.test.tsx +++ b/clients/web/src/App.test.tsx @@ -64,6 +64,14 @@ vi.mock("@inspector/core/mcp/index.js", async (importOriginal) => { .fn() .mockResolvedValue({ success: true, result: { acts: [] } }); cancelRequestorTask = vi.fn().mockResolvedValue(undefined); + isTasksExtensionNegotiated = vi.fn().mockReturnValue(false); + getRequestorTask = vi.fn().mockResolvedValue({ + taskId: "t", + status: "working", + ttl: null, + createdAt: "", + lastUpdatedAt: "", + }); cancelToolCall = vi.fn().mockReturnValue(true); getPrompt = vi.fn().mockResolvedValue({ result: { messages: [] } }); readResource = vi @@ -1107,6 +1115,55 @@ describe("App task wiring", () => { expect(client.cancelRequestorTask).toHaveBeenCalledWith("task-42"); }); + it("Cancel on a task-input-required elicitation cancels the task, not answers it (#1631)", async () => { + // The user-facing half of the cancelable-loop fix: clicking Cancel on a + // MODERN task's input_required modal must cancel the underlying task (so the + // poll unblocks) rather than send an { action: "cancel" } answer that a + // non-advancing server would just re-prompt on. + const user = userEvent.setup(); + renderWithMantine(); + await user.click(screen.getByText("connect")); + await waitFor(() => expect(clientInstances).toHaveLength(1)); + + const client = clientInstances[0] as unknown as { + cancelRequestorTask: ReturnType; + }; + + const respond = vi.fn().mockResolvedValue(undefined); + const elicitation = { + id: "elicitation-1", + origin: "task-input-required", + taskId: "task-77", + request: { + params: { + message: "Approve this task before it continues?", + requestedSchema: { + type: "object", + properties: { approved: { type: "boolean", title: "Approved" } }, + }, + }, + }, + respond, + }; + act(() => { + clientInstances[0].dispatchEvent( + new CustomEvent("pendingElicitationsChange", { detail: [elicitation] }), + ); + }); + + await waitFor(() => + expect( + screen.getByText(/Approve this task before it continues/), + ).toBeInTheDocument(), + ); + + await user.click(screen.getByRole("button", { name: "Cancel" })); + + expect(client.cancelRequestorTask).toHaveBeenCalledWith("task-77"); + // The task is cancelled instead of answering the request. + expect(respond).not.toHaveBeenCalled(); + }); + it("does not re-cancel on a rapid second Cancel click", async () => { const user = userEvent.setup(); renderWithMantine(); diff --git a/clients/web/src/App.tsx b/clients/web/src/App.tsx index e9aaa44e7..ce2fe159a 100644 --- a/clients/web/src/App.tsx +++ b/clients/web/src/App.tsx @@ -2823,8 +2823,16 @@ function App() { // error). The created task shows up on the Tasks screen via the // `requestorTaskUpdated` events callToolStream dispatches, and its live // status/progress surface as toasts + progress bar. + // Legacy servers advertise task tool calls via + // `tasks.requests.tools.call`. Modern servers (SEP-2663) instead negotiate + // the `io.modelcontextprotocol/tasks` extension and are server-directed: + // task creation is decided per-request by the server, so declaring the + // extension on the call (which the task path does) is what makes a returned + // task handle legal ("unsolicited handles"). Either era routes the flagged + // call through the streaming task pipeline. const serverSupportsTaskToolCalls = - !!capabilities?.tasks?.requests?.tools?.call; + !!capabilities?.tasks?.requests?.tools?.call || + inspectorClient.isTasksExtensionNegotiated(); const asTask = serverSupportsTaskToolCalls && (runAsTask || tool.execution?.taskSupport === "required"); @@ -3999,9 +4007,25 @@ function App() { const onElicitationRespond = useCallback( (result: ElicitResult) => { - void pendingElicitations[0]?.respond(result); + const pending = pendingElicitations[0]; + if (!pending) return; + // "Cancel" on a MODERN task's input_required request means "give up on the + // task" — not "send a cancel answer" (which a non-advancing server would + // just re-prompt on). Cancel the underlying task instead; that aborts the + // pending request, closes this modal, and lets the poll settle as + // cancelled (#1631). Submit/Decline still answer the task normally. + if ( + result.action === "cancel" && + pending.origin === "task-input-required" && + pending.taskId && + inspectorClient + ) { + void inspectorClient.cancelRequestorTask(pending.taskId); + return; + } + void pending.respond(result); }, - [pendingElicitations], + [pendingElicitations, inspectorClient], ); const handleStepUpAuthorize = async () => { @@ -4256,7 +4280,8 @@ function App() { highlightedServerIds={highlightedServerIds} onClearHighlight={clearHighlight} serverSupportsTaskToolCalls={ - !!capabilities?.tasks?.requests?.tools?.call + !!capabilities?.tasks?.requests?.tools?.call || + (inspectorClient?.isTasksExtensionNegotiated() ?? false) } onToolsUiChange={onToolsUiChange} onCallTool={(name, args, runAsTask) => { diff --git a/clients/web/src/components/elements/MrtrOriginNote/MrtrOriginNote.stories.tsx b/clients/web/src/components/elements/MrtrOriginNote/MrtrOriginNote.stories.tsx index e9e4345fd..790e2bc54 100644 --- a/clients/web/src/components/elements/MrtrOriginNote/MrtrOriginNote.stories.tsx +++ b/clients/web/src/components/elements/MrtrOriginNote/MrtrOriginNote.stories.tsx @@ -13,6 +13,10 @@ export const InputRequired: Story = { args: { origin: "input-required" }, }; +export const TaskInputRequired: Story = { + args: { origin: "task-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 index 558ab6207..5d664d369 100644 --- a/clients/web/src/components/elements/MrtrOriginNote/MrtrOriginNote.test.tsx +++ b/clients/web/src/components/elements/MrtrOriginNote/MrtrOriginNote.test.tsx @@ -9,6 +9,16 @@ describe("MrtrOriginNote", () => { expect(screen.getByText(/sent back as a retry/i)).toBeInTheDocument(); }); + it("renders the tasks/update note for a modern task input-required round (#1631)", () => { + renderWithMantine(); + expect(screen.getByText("input_required")).toBeInTheDocument(); + expect( + screen.getByText(/submitted via a tasks\/update request/i), + ).toBeInTheDocument(); + // The task note must NOT claim the answer is a retry (that's the MRTR case). + expect(screen.queryByText(/sent back as a retry/i)).not.toBeInTheDocument(); + }); + it("renders nothing for a legacy server request", () => { renderWithMantine(); expect(screen.queryByText("input_required")).not.toBeInTheDocument(); diff --git a/clients/web/src/components/elements/MrtrOriginNote/MrtrOriginNote.tsx b/clients/web/src/components/elements/MrtrOriginNote/MrtrOriginNote.tsx index 8219f4356..c4aa7c58d 100644 --- a/clients/web/src/components/elements/MrtrOriginNote/MrtrOriginNote.tsx +++ b/clients/web/src/components/elements/MrtrOriginNote/MrtrOriginNote.tsx @@ -6,7 +6,8 @@ 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. + * modern (2026-07-28) MRTR round and `"task-input-required"` is a modern task + * (SEP-2663) round — each renders its own era-accurate note. */ origin?: PendingRequestOrigin; } @@ -22,27 +23,37 @@ const NoteText = Text.withProps({ c: "dimmed", }); +// The two modern origins differ in HOW the answer is delivered: an MRTR round +// retries the original call with the answer (SEP-2322); a task round submits it +// via a separate `tasks/update` request (SEP-2663). The note is accurate to each +// so a user debugging the wire isn't told to expect the wrong follow-up frame. +const NOTE_BY_ORIGIN: Partial> = { + "input-required": + "The server returned input_required; your answer is sent back as a retry of the original request (MRTR).", + "task-input-required": + "This task reached input_required; your answer is submitted via a tasks/update request (SEP-2663), not a retry.", +}; + /** - * 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. + * Era-accurate note for a pending sampling/elicitation request. A modern MRTR + * round (`origin: "input-required"`, SEP-2322) embeds the request in a + * tool-call/prompt/resource `input_required` result and retries the original + * call with the answer; a modern task round (`origin: "task-input-required"`, + * SEP-2663) surfaces it from the task's `inputRequests` and submits the answer + * via `tasks/update`. 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; + const note = NOTE_BY_ORIGIN[origin]; + if (!note) return null; return ( input_required - - The server returned input_required; your answer is sent back as a retry - of the original request (MRTR). - + {note} ); } diff --git a/clients/web/src/components/groups/TaskCard/TaskCard.stories.tsx b/clients/web/src/components/groups/TaskCard/TaskCard.stories.tsx index 29c334ee5..e56522f2e 100644 --- a/clients/web/src/components/groups/TaskCard/TaskCard.stories.tsx +++ b/clients/web/src/components/groups/TaskCard/TaskCard.stories.tsx @@ -90,3 +90,10 @@ export const Cancelled: Story = { export const Collapsed: Story = { args: { task: workingTask, isListExpanded: false }, }; + +// Compact monitoring-sidebar variant: the task id moves to its own line below +// the header so the status badge and Cancel Task control stay on the first row +// in the narrow column (#1631). +export const Embedded: Story = { + args: { task: workingTask, embedded: true }, +}; diff --git a/clients/web/src/components/groups/TaskCard/TaskCard.test.tsx b/clients/web/src/components/groups/TaskCard/TaskCard.test.tsx index 8f8d5a6c6..715c75d24 100644 --- a/clients/web/src/components/groups/TaskCard/TaskCard.test.tsx +++ b/clients/web/src/components/groups/TaskCard/TaskCard.test.tsx @@ -51,6 +51,25 @@ describe("TaskCard", () => { expect(screen.getByText("300000ms")).toBeInTheDocument(); }); + it("still renders the task id (on its own line) in the embedded variant (#1631)", () => { + // The embedded sidebar moves the id out of the header so the badge/Cancel + // don't wrap, but the id must remain visible (not removed entirely). + renderWithMantine( + , + ); + expect(screen.getByText("ID: task-working-1")).toBeInTheDocument(); + // The status badge and Cancel Task control are still present on the header. + expect(screen.getByText("working")).toBeInTheDocument(); + expect( + screen.getByRole("button", { name: "Cancel Task" }), + ).toBeInTheDocument(); + }); + it("renders the Cancel Task button when status is working", () => { renderWithMantine( , diff --git a/clients/web/src/components/groups/TaskCard/TaskCard.tsx b/clients/web/src/components/groups/TaskCard/TaskCard.tsx index 5edb57fa9..8946e9048 100644 --- a/clients/web/src/components/groups/TaskCard/TaskCard.tsx +++ b/clients/web/src/components/groups/TaskCard/TaskCard.tsx @@ -24,6 +24,13 @@ export interface TaskCardProps { progress?: TaskProgress; isListExpanded: boolean; onCancel: () => void; + /** + * Compact variant for the narrow monitoring sidebar. Moves the long task ID + * out of the header onto its own line below it (truncated) so the header + * doesn't wrap the status badge / Cancel Task control onto a second row. The + * full-width Tasks screen keeps the ID inline in the header. + */ + embedded?: boolean; } const TaskContainer = Card.withProps({ @@ -80,6 +87,9 @@ const HeaderLeftGroup = Group.withProps({ const HeaderRightGroup = Group.withProps({ gap: "xs", + // Keep the Cancel Task control and the status badge together on the header's + // first row rather than letting the badge wrap beneath the button. + wrap: "nowrap", }); const SectionTitle = Text.withProps({ @@ -100,6 +110,7 @@ export function TaskCard({ progress, isListExpanded, onCancel, + embedded = false, }: TaskCardProps) { const { taskId, status, ttl, createdAt, lastUpdatedAt, statusMessage } = task; const [isExpanded, setIsExpanded] = useState(isListExpanded); @@ -115,7 +126,11 @@ export function TaskCard({ Task Details - {formatTaskId(taskId)} + {/* Full-width screen: the id sits inline after the title. In the + narrow embedded sidebar it moves to its own line below (see + below) so it can't push the status badge / Cancel Task onto a + second row. */} + {!embedded && {formatTaskId(taskId)}} {isActive && ( @@ -125,6 +140,10 @@ export function TaskCard({ + {embedded && ( + {formatTaskId(taskId)} + )} + diff --git a/clients/web/src/components/groups/TaskListPanel/TaskListPanel.tsx b/clients/web/src/components/groups/TaskListPanel/TaskListPanel.tsx index 278f8e433..702da4d92 100644 --- a/clients/web/src/components/groups/TaskListPanel/TaskListPanel.tsx +++ b/clients/web/src/components/groups/TaskListPanel/TaskListPanel.tsx @@ -135,6 +135,7 @@ export function TaskListPanel({ progress={progressByTaskId?.[task.taskId]} isListExpanded={!compact} onCancel={() => onCancel(task.taskId)} + embedded={embedded} /> ))} @@ -157,6 +158,7 @@ export function TaskListPanel({ progress={progressByTaskId?.[task.taskId]} isListExpanded={!compact} onCancel={() => onCancel(task.taskId)} + embedded={embedded} /> ))} diff --git a/clients/web/src/components/groups/ToolDetailPanel/ToolDetailPanel.test.tsx b/clients/web/src/components/groups/ToolDetailPanel/ToolDetailPanel.test.tsx index ac9f94fbe..e1a4591e7 100644 --- a/clients/web/src/components/groups/ToolDetailPanel/ToolDetailPanel.test.tsx +++ b/clients/web/src/components/groups/ToolDetailPanel/ToolDetailPanel.test.tsx @@ -325,6 +325,42 @@ describe("ToolDetailPanel", () => { expect(screen.queryByLabelText("Run as task")).not.toBeInTheDocument(); }); + it("shows the toggle for a task-forbidden tool on a modern connection (#1631)", () => { + // On modern (SEP-2663) task creation is server-directed, so any tool can + // become a task — the toggle is offered regardless of per-tool taskSupport. + renderWithMantine( + , + ); + const toggle = screen.getByLabelText("Run as task"); + expect(toggle).not.toBeChecked(); + expect(toggle).not.toBeDisabled(); + }); + + it("routes the user's runAsTask choice on a modern connection (#1631)", async () => { + const user = userEvent.setup(); + const onExecute = vi.fn(); + renderWithMantine( + , + ); + // The toggle is checked (user's choice), and executing forwards true. + expect(screen.getByLabelText("Run as task")).toBeChecked(); + await user.click(screen.getByRole("button", { name: "Execute Tool" })); + expect(onExecute).toHaveBeenCalledWith(true); + }); + it("shows an unchecked, enabled toggle for an optional tool (off by default)", () => { renderWithMantine( void; onFormChange: (values: Record) => void; @@ -154,6 +162,7 @@ export function ToolDetailPanel({ isExecuting, progress, serverSupportsTaskToolCalls, + modernTasks = false, runAsTask, onRunAsTaskChange, onFormChange, @@ -180,20 +189,28 @@ export function ToolDetailPanel({ // single expandable control (aria-expanded + aria-controls). const descriptionRegionId = useId(); - // Show the toggle only when the server supports task tool calls and the tool - // doesn't forbid them. `required` tools are forced on (checked + disabled); - // `optional` tools follow the user's `runAsTask` choice. + // Show the toggle when the server supports task tool calls and either the + // connection is modern (task creation is server-directed there, so any tool + // may become a task) or the tool doesn't forbid per-tool task support + // (legacy). `required` tools are forced on (checked + disabled); `optional` + // and (on modern) any tool follow the user's `runAsTask` choice. + // + // NOTE: on modern, a per-tool `taskSupport: "forbidden"` is DELIBERATELY + // ignored. Under SEP-2663 task creation is decided by the server per request, + // not declared per tool, so `taskSupport` (a legacy 2025-11-25 concept) does + // not gate the affordance — the server may return a task for any call. The + // toggle just declares intent to poll a returned handle. const taskSupport = getTaskSupport(tool); const showRunAsTask = - serverSupportsTaskToolCalls && taskSupport !== "forbidden"; - // Gate the effective decision on `showRunAsTask` (which includes - // `serverSupportsTaskToolCalls`): a stale `runAsTask`/`required` value must - // not route through callToolStream when the toggle is hidden because the - // server doesn't advertise task tool calls. Per spec, a tool's taskSupport is - // only considered when the server advertises `tasks.requests.tools.call`. + serverSupportsTaskToolCalls && (modernTasks || taskSupport !== "forbidden"); + // Gate the effective decision on `showRunAsTask`: a stale `runAsTask`/`required` + // value must not route through callToolStream when the toggle is hidden. On + // legacy, a tool's taskSupport is only considered when the server advertises + // `tasks.requests.tools.call`; on modern, the user's choice governs any tool. const effectiveRunAsTask = showRunAsTask && - (taskSupport === "required" || (taskSupport === "optional" && runAsTask)); + (taskSupport === "required" || + ((taskSupport === "optional" || modernTasks) && runAsTask)); return ( diff --git a/clients/web/src/components/screens/ToolsScreen/ToolsScreen.tsx b/clients/web/src/components/screens/ToolsScreen/ToolsScreen.tsx index ae50d6e90..d4570d365 100644 --- a/clients/web/src/components/screens/ToolsScreen/ToolsScreen.tsx +++ b/clients/web/src/components/screens/ToolsScreen/ToolsScreen.tsx @@ -43,6 +43,9 @@ export interface ToolsScreenProps { listChanged: boolean; /** Whether the connected server advertises task-augmented tool calls. */ serverSupportsTaskToolCalls: boolean; + /** Modern (SEP-2663) tasks extension negotiated — "Run as task" is offered + * for any tool (server-directed task creation). */ + modernTasks?: boolean; onUiChange: (next: ToolsUiState) => void; onRefreshList: () => void; /** Pagination controls rendered in the sidebar (#1721). */ @@ -133,6 +136,7 @@ export function ToolsScreen({ ui, listChanged, serverSupportsTaskToolCalls, + modernTasks = false, onUiChange, onRefreshList, pagination, @@ -216,6 +220,7 @@ export function ToolsScreen({ isExecuting={isExecuting} progress={callState?.progress} serverSupportsTaskToolCalls={serverSupportsTaskToolCalls} + modernTasks={modernTasks} runAsTask={ui.runAsTask} onRunAsTaskChange={(value) => onUiChange({ ...ui, runAsTask: value }) diff --git a/clients/web/src/components/views/InspectorView/InspectorView.tsx b/clients/web/src/components/views/InspectorView/InspectorView.tsx index 8a4b3a77c..46672930c 100644 --- a/clients/web/src/components/views/InspectorView/InspectorView.tsx +++ b/clients/web/src/components/views/InspectorView/InspectorView.tsx @@ -38,6 +38,7 @@ import type { } from "@inspector/core/mcp/types.js"; import { isTerminalStatus } from "@inspector/core/mcp/types.js"; import { isAppTool } from "@inspector/core/mcp/apps.js"; +import { TASKS_EXTENSION_KEY } from "@inspector/core/mcp/modernTaskSchemas.js"; import { ViewHeader } from "../../groups/ViewHeader/ViewHeader"; import { VersionBadge } from "../../elements/VersionBadge/VersionBadge"; import { CopyrightBadge } from "../../elements/CopyrightBadge/CopyrightBadge"; @@ -807,8 +808,11 @@ export function InspectorView({ // Logs → capabilities.logging // Prompts → capabilities.prompts // Resources → capabilities.resources - // Tasks → capabilities.tasks (the "run as task" affordance on the - // Tools screen separately keys off tasks.requests.tools.call) + // Tasks → capabilities.tasks (legacy) OR the + // io.modelcontextprotocol/tasks extension (modern, SEP-2663). + // The "run as task" affordance on the Tools screen separately + // keys off tasks.requests.tools.call (legacy) / the negotiated + // extension (modern). // Apps → capabilities.tools (MCP Apps build on tools) AND at least one // app tool — Apps is a filtered view of tools, not its own // capability, so it keeps the content check; when app tools @@ -830,7 +834,12 @@ export function InspectorView({ const hasApps = hasTools && appTools.length > 0; const hasPrompts = capabilities?.prompts !== undefined; const hasResources = capabilities?.resources !== undefined; - const hasTasks = capabilities?.tasks !== undefined; + // Legacy servers gate Tasks on `capabilities.tasks`; modern servers + // (SEP-2663) advertise the `io.modelcontextprotocol/tasks` extension in + // `server/discover` instead, so gate on either being present. + const hasTasks = + capabilities?.tasks !== undefined || + capabilities?.extensions?.[TASKS_EXTENSION_KEY] !== undefined; const hasLogging = capabilities?.logging !== undefined; return ALL_TABS.filter((t) => { if (t === NETWORK_TAB && isStdio) return false; @@ -1314,6 +1323,12 @@ export function InspectorView({ ui={toolsUi} listChanged={toolsListChanged} serverSupportsTaskToolCalls={serverSupportsTaskToolCalls} + modernTasks={ + protocolEra === "modern" && + initializeResult?.capabilities?.extensions?.[ + TASKS_EXTENSION_KEY + ] !== undefined + } onUiChange={onToolsUiChange} onRefreshList={onRefreshTools} pagination={toolsPagination} diff --git a/clients/web/src/test/core/mcp/inspectorClient-raw-wire.test.ts b/clients/web/src/test/core/mcp/inspectorClient-raw-wire.test.ts new file mode 100644 index 000000000..2d9af6b1c --- /dev/null +++ b/clients/web/src/test/core/mcp/inspectorClient-raw-wire.test.ts @@ -0,0 +1,159 @@ +import { describe, it, expect, vi } from "vitest"; +import { InspectorClient } from "@inspector/core/mcp/inspectorClient.js"; +import { ModernGetTaskResultSchema } from "@inspector/core/mcp/modernTaskSchemas.js"; + +/** + * Unit coverage for the raw-wire request channel (#1631) that drives the modern + * tasks/* methods the SDK v2 era gate refuses to send. Exercised directly (with + * a fake transport) so the defensive branches — transport-null guard, send + * rejection, timeout, error response, and disconnect cleanup — are deterministic + * rather than dependent on server timing. + */ +describe("InspectorClient raw-wire channel (#1631)", () => { + function makeClient(): InspectorClient { + return new InspectorClient( + { type: "stdio", command: "noop", args: [] }, + // environment.transport is only used on connect(); these tests never + // connect, they poke the private raw-wire methods directly. + { environment: { transport: () => ({}) as never } }, + ); + } + + interface RawWireInternals { + transport: { send: (m: unknown) => Promise } | null; + requestTimeout?: number; + rawWireRequest: ( + method: string, + params: Record, + schema: { parse: (v: unknown) => unknown }, + ) => Promise; + consumeRawWireResponse: (message: unknown) => boolean; + rejectPendingRawWireRequests: (reason: string) => void; + } + + function internals(client: InspectorClient): RawWireInternals { + return client as unknown as RawWireInternals; + } + + it("throws when there is no transport", async () => { + const client = makeClient(); + internals(client).transport = null; + await expect( + internals(client).rawWireRequest( + "tasks/get", + {}, + ModernGetTaskResultSchema, + ), + ).rejects.toThrow(/not connected/i); + }); + + it("resolves when a matching response is consumed", async () => { + const client = makeClient(); + let sent: { id: string } | undefined; + internals(client).transport = { + send: vi.fn(async (m: unknown) => { + sent = m as { id: string }; + }), + }; + const promise = internals(client).rawWireRequest( + "tasks/get", + { taskId: "x" }, + ModernGetTaskResultSchema, + ); + // Let the send microtask register the pending entry. + await Promise.resolve(); + expect(sent?.id).toMatch(/^inspector-ext-/); + const consumed = internals(client).consumeRawWireResponse({ + id: sent!.id, + result: { + taskId: "x", + status: "completed", + createdAt: "a", + lastUpdatedAt: "b", + ttlMs: null, + result: { content: [] }, + }, + }); + expect(consumed).toBe(true); + const result = (await promise) as { taskId: string }; + expect(result.taskId).toBe("x"); + }); + + it("ignores a response id it does not own", () => { + const client = makeClient(); + expect( + internals(client).consumeRawWireResponse({ id: 42, result: {} }), + ).toBe(false); + }); + + it("rejects when the response is an error", async () => { + const client = makeClient(); + let sent: { id: string } | undefined; + internals(client).transport = { + send: vi.fn(async (m: unknown) => { + sent = m as { id: string }; + }), + }; + const promise = internals(client).rawWireRequest( + "tasks/cancel", + { taskId: "x" }, + ModernGetTaskResultSchema, + ); + await Promise.resolve(); + internals(client).consumeRawWireResponse({ + id: sent!.id, + error: { code: -32602, message: "Unknown taskId" }, + }); + await expect(promise).rejects.toThrow(/Unknown taskId/); + }); + + it("rejects when the transport send fails", async () => { + const client = makeClient(); + internals(client).transport = { + send: vi.fn().mockRejectedValue(new Error("socket closed")), + }; + await expect( + internals(client).rawWireRequest( + "tasks/get", + {}, + ModernGetTaskResultSchema, + ), + ).rejects.toThrow(/socket closed/); + }); + + it("rejects on timeout when no response arrives", async () => { + vi.useFakeTimers(); + try { + const client = makeClient(); + internals(client).requestTimeout = 10; + internals(client).transport = { + send: vi.fn().mockResolvedValue(undefined), + }; + const promise = internals(client).rawWireRequest( + "tasks/get", + {}, + ModernGetTaskResultSchema, + ); + const assertion = expect(promise).rejects.toThrow(/timed out/); + await vi.advanceTimersByTimeAsync(20); + await assertion; + } finally { + vi.useRealTimers(); + } + }); + + it("rejects all pending requests on teardown", async () => { + const client = makeClient(); + internals(client).transport = { + send: vi.fn().mockResolvedValue(undefined), + }; + const promise = internals(client).rawWireRequest( + "tasks/get", + {}, + ModernGetTaskResultSchema, + ); + await Promise.resolve(); + internals(client).rejectPendingRawWireRequests("Disconnected"); + await expect(promise).rejects.toThrow(/Disconnected/); + }); +}); diff --git a/clients/web/src/test/core/mcp/modernTaskSchemas.test.ts b/clients/web/src/test/core/mcp/modernTaskSchemas.test.ts new file mode 100644 index 000000000..2bc9020a6 --- /dev/null +++ b/clients/web/src/test/core/mcp/modernTaskSchemas.test.ts @@ -0,0 +1,136 @@ +import { describe, it, expect } from "vitest"; +import { + TASKS_EXTENSION_KEY, + MODERN_PROTOCOL_VERSION, + MODERN_TASK_HANDLE_META, + TASKS_EXTENSION_CLIENT_CAPABILITY, + ModernDetailedTaskSchema, + ModernGetTaskResultSchema, + ModernUpdateTaskResultSchema, + ModernCancelTaskResultSchema, + normalizeModernTask, + readInputRequests, + isModernCreateTaskResult, +} from "@inspector/core/mcp/modernTaskSchemas.js"; + +describe("modernTaskSchemas (#1631)", () => { + const baseTask = { + taskId: "abc", + status: "working" as const, + createdAt: "2026-07-20T00:00:00Z", + lastUpdatedAt: "2026-07-20T00:00:01Z", + }; + + describe("constants", () => { + it("exposes the SEP-2663 identifiers", () => { + expect(TASKS_EXTENSION_KEY).toBe("io.modelcontextprotocol/tasks"); + expect(MODERN_PROTOCOL_VERSION).toBe("2026-07-28"); + expect(MODERN_TASK_HANDLE_META).toContain("modernTaskHandle"); + expect( + TASKS_EXTENSION_CLIENT_CAPABILITY.extensions[TASKS_EXTENSION_KEY], + ).toEqual({}); + }); + }); + + describe("ModernDetailedTaskSchema", () => { + it("parses a working task and passes unknown fields through (loose)", () => { + const parsed = ModernDetailedTaskSchema.parse({ + ...baseTask, + ttlMs: 60000, + pollIntervalMs: 500, + somethingNew: "kept", + }); + expect(parsed.taskId).toBe("abc"); + expect((parsed as Record).somethingNew).toBe("kept"); + }); + + it("accepts a null ttlMs and inline result/error/inputRequests", () => { + const completed = ModernGetTaskResultSchema.parse({ + ...baseTask, + status: "completed", + ttlMs: null, + result: { content: [{ type: "text", text: "done" }] }, + }); + expect(completed.result).toBeDefined(); + const failed = ModernDetailedTaskSchema.parse({ + ...baseTask, + status: "failed", + error: { code: -1, message: "boom" }, + }); + expect(failed.error).toBeDefined(); + }); + + it("rejects a task missing required identity fields", () => { + expect(() => + ModernDetailedTaskSchema.parse({ status: "working" }), + ).toThrow(); + }); + + it("accepts empty update/cancel acks", () => { + expect(ModernUpdateTaskResultSchema.parse({})).toEqual({}); + expect( + ModernCancelTaskResultSchema.parse({ resultType: "complete" }), + ).toBeDefined(); + }); + }); + + describe("normalizeModernTask", () => { + it("maps ttlMs → ttl and pollIntervalMs → pollInterval", () => { + const task = normalizeModernTask({ + ...baseTask, + ttlMs: 60000, + pollIntervalMs: 250, + }); + expect(task.ttl).toBe(60000); + expect((task as { pollInterval?: number }).pollInterval).toBe(250); + }); + + it("defaults ttl to null and omits pollInterval when absent", () => { + const task = normalizeModernTask({ ...baseTask }); + expect(task.ttl).toBeNull(); + expect((task as { pollInterval?: number }).pollInterval).toBeUndefined(); + }); + + it("carries the status-specific members structurally", () => { + const task = normalizeModernTask({ + ...baseTask, + status: "completed", + ttlMs: null, + result: { content: [] }, + }); + expect((task as { result?: unknown }).result).toEqual({ content: [] }); + }); + }); + + describe("readInputRequests", () => { + it("returns the inputRequests map when present", () => { + const requests = { confirm: { method: "elicitation/create" } }; + const out = readInputRequests({ + ...baseTask, + status: "input_required", + inputRequests: requests, + }); + expect(out).toBe(requests); + }); + + it("returns undefined when absent", () => { + expect(readInputRequests({ ...baseTask })).toBeUndefined(); + }); + }); + + describe("isModernCreateTaskResult", () => { + it("is true only for a resultType:task frame with a taskId", () => { + expect( + isModernCreateTaskResult({ resultType: "task", taskId: "x" }), + ).toBe(true); + }); + + it("is false for complete results, non-objects, and missing taskId", () => { + expect(isModernCreateTaskResult({ resultType: "complete" })).toBe(false); + expect(isModernCreateTaskResult({ resultType: "task" })).toBe(false); + expect(isModernCreateTaskResult({ content: [] })).toBe(false); + expect(isModernCreateTaskResult(null)).toBe(false); + expect(isModernCreateTaskResult("task")).toBe(false); + }); + }); +}); diff --git a/clients/web/src/test/core/mcp/state/managedRequestorTasksState.test.ts b/clients/web/src/test/core/mcp/state/managedRequestorTasksState.test.ts index 7e001c11a..2a7d3c8e1 100644 --- a/clients/web/src/test/core/mcp/state/managedRequestorTasksState.test.ts +++ b/clients/web/src/test/core/mcp/state/managedRequestorTasksState.test.ts @@ -210,6 +210,68 @@ describe("ManagedRequestorTasksState", () => { expect(next[0]!.status).toBe("cancelled"); }); + it("keeps a cancelled task cancelled when a later completed update arrives (#1631)", async () => { + // Cancellation is a deliberate terminal decision: a server that completes the + // task anyway (cooperative cancel) or the in-flight call resolving must not + // flip it back to completed. + client.setStatus("connected"); + client.queueTaskPages({ tasks: [task("t1", "working")] }); + await state.refresh(); + + client.dispatchTypedEvent("taskCancelled", { taskId: "t1" }); + expect(state.getTasks()[0]!.status).toBe("cancelled"); + + // A late server notification and a client-origin update, both "completed": + let changed = false; + state.addEventListener("tasksChange", () => { + changed = true; + }); + client.dispatchTypedEvent("taskStatusChange", { + taskId: "t1", + task: task("t1", "completed"), + }); + client.dispatchTypedEvent("requestorTaskUpdated", { + taskId: "t1", + task: task("t1", "completed"), + }); + expect(changed).toBe(false); + expect(state.getTasks()[0]!.status).toBe("cancelled"); + }); + + it("still applies a subsequent cancelled update for a cancelled task", async () => { + client.setStatus("connected"); + client.queueTaskPages({ tasks: [task("t1", "working")] }); + await state.refresh(); + client.dispatchTypedEvent("taskCancelled", { taskId: "t1" }); + + // A cancelled-status update is not blocked (it agrees with the sticky state). + const changePromise = waitForChange(state); + client.dispatchTypedEvent("taskStatusChange", { + taskId: "t1", + task: { ...task("t1", "cancelled"), statusMessage: "stopped" }, + }); + const next = await changePromise; + expect(next[0]!.status).toBe("cancelled"); + expect(next[0]!.statusMessage).toBe("stopped"); + }); + + it("keeps a cancelled task cancelled across a legacy refresh that re-lists it completed (#1631)", async () => { + // A cooperative-cancel server may complete the task anyway and still return + // it as "completed" from tasks/list. A manual Refresh must not un-stick the + // user's cancel — the legacy refresh path pins it to "cancelled" too. + client.setStatus("connected"); + client.queueTaskPages({ tasks: [task("t1", "working")] }); + await state.refresh(); + + client.dispatchTypedEvent("taskCancelled", { taskId: "t1" }); + expect(state.getTasks()[0]!.status).toBe("cancelled"); + + // Server now lists the same task as completed; refresh must keep it cancelled. + client.queueTaskPages({ tasks: [task("t1", "completed")] }); + await state.refresh(); + expect(state.getTasks()[0]!.status).toBe("cancelled"); + }); + it("taskCancelled is a no-op when the task is unknown", async () => { client.setStatus("connected"); client.queueTaskPages({ tasks: [task("t1")] }); 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 8c6de5325..291a1d89f 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 @@ -903,9 +903,10 @@ describe("InspectorClient coverage backfill", () => { }); describe("constructor capability branches and createReceiverTask options", () => { - it("constructs with no advertised capabilities (clientOptions undefined branch)", async () => { - // sample:false + elicit:false + no roots + no receiverTasks → the - // capabilities object is empty, so clientOptions is passed as undefined. + it("advertises only the tasks extension when no other capabilities are set", async () => { + // sample:false + elicit:false + no roots + no receiverTasks → the only + // advertised capability is the always-on Tasks extension (#1631), so + // `capabilities` is non-empty and is always attached. client = new InspectorClient( { type: "stdio", @@ -919,7 +920,10 @@ describe("InspectorClient coverage backfill", () => { }, ); const caps = client.getClientCapabilities(); - expect(Object.keys(caps)).toHaveLength(0); + expect(caps.sampling).toBeUndefined(); + expect(caps.elicitation).toBeUndefined(); + expect(caps.roots).toBeUndefined(); + expect(caps.extensions?.["io.modelcontextprotocol/tasks"]).toBeDefined(); await client.connect(); expect(client.getStatus()).toBe("connected"); }); diff --git a/clients/web/src/test/integration/mcp/inspectorClient-tasks-era.test.ts b/clients/web/src/test/integration/mcp/inspectorClient-tasks-era.test.ts new file mode 100644 index 000000000..6cb841325 --- /dev/null +++ b/clients/web/src/test/integration/mcp/inspectorClient-tasks-era.test.ts @@ -0,0 +1,380 @@ +import { describe, it, expect, afterEach, afterAll } from "vitest"; +import * as z from "zod/v4"; +import { InspectorClient } from "@inspector/core/mcp/inspectorClient.js"; +import { createTransportNode } from "@inspector/core/mcp/node/transport.js"; +import { eraToVersionNegotiation } from "@inspector/core/mcp/types.js"; +import { ManagedRequestorTasksState } from "@inspector/core/mcp/state/managedRequestorTasksState.js"; +import { + createTestServerHttp, + type TestServerHttp, + createTestServerInfo, +} from "@modelcontextprotocol/inspector-test-server"; +import type { ServerConfig } from "@modelcontextprotocol/inspector-test-server"; +import { getTaskServerConfig } from "@modelcontextprotocol/inspector-test-server"; +import type { ContentBlock } from "@modelcontextprotocol/client"; +import type { MessageEntry } from "@inspector/core/mcp/types.js"; + +/** + * Live coverage of the Tasks era fork (#1631). Modern (2026-07-28) servers serve + * the `io.modelcontextprotocol/tasks` extension (SEP-2663): a task-augmented + * `tools/call` returns a `CreateTaskResult`, the client polls `tasks/get` + * (no `tasks/list`, no blocking `tasks/result`), an `input_required` task + * surfaces an embedded elicitation answered via `tasks/update`, and a completed + * task inlines its result. Legacy servers keep the `capabilities.tasks` / + * `tasks/list` flow. Both are driven against a real server over a real transport. + */ +describe("tasks era fork (#1631)", () => { + let client: InspectorClient | null = null; + // One server per era, shared across the block's tests (started lazily, stopped + // in afterAll). Starting a fresh Express server per test made connection + // negotiation flaky under the heavy concurrent coverage run; task ids are + // unique per create so sharing state is harmless. + let modernServer: TestServerHttp | null = null; + let legacyServer: TestServerHttp | null = null; + + afterEach(async () => { + if (client) { + try { + await client.disconnect(); + } catch { + // ignore + } + client = null; + } + }); + + afterAll(async () => { + for (const s of [modernServer, legacyServer]) { + if (s) { + try { + await s.stop(); + } catch { + // ignore + } + } + } + modernServer = null; + legacyServer = null; + }); + + async function startModernTasksServer(): Promise { + if (modernServer) return modernServer; + const started = createTestServerHttp({ + serverInfo: createTestServerInfo("tasks-modern-test", "1.0.0"), + tasksExtension: true, + // A plain tool alongside the task tools, so a modern tools/call that does + // NOT create a task exercises the synchronous-result path. + tools: [ + { + name: "plain_echo", + description: "Echo the message without creating a task", + inputSchema: { message: z.string().optional() }, + handler: async (args: Record) => ({ + content: [ + { type: "text" as const, text: `echo:${args.message ?? ""}` }, + ], + }), + }, + ], + modern: {}, + }); + await started.start(); + modernServer = started; + return started; + } + + async function startLegacyTasksServer(): Promise { + if (legacyServer) return legacyServer; + const config = getTaskServerConfig() as ServerConfig; + const started = createTestServerHttp({ + ...config, + serverInfo: createTestServerInfo("tasks-legacy-test", "1.0.0"), + }); + await started.start(); + legacyServer = started; + return started; + } + + async function connect( + url: string, + era: "legacy" | "modern", + ): Promise<{ connected: InspectorClient; messages: MessageEntry[] }> { + const connected = new InspectorClient( + { type: "streamable-http", url }, + { + environment: { transport: createTransportNode }, + versionNegotiation: eraToVersionNegotiation(era), + }, + ); + const messages: MessageEntry[] = []; + connected.addEventListener("message", (event) => { + messages.push(event.detail); + }); + await connected.connect(); + client = connected; + return { connected, messages }; + } + + function methodsSent(messages: MessageEntry[]): string[] { + return messages + .filter((m) => m.direction === "request") + .map((m) => ("method" in m.message ? m.message.method : "")) + .filter(Boolean); + } + + function firstText(content: ContentBlock[] | undefined): string { + const block = content?.[0]; + return block && "text" in block ? block.text : ""; + } + + describe("modern era", () => { + it("negotiates the tasks extension and gates on it", async () => { + const started = await startModernTasksServer(); + const { connected } = await connect(started.url, "modern"); + expect(connected.getProtocolEra()).toBe("modern"); + expect(connected.isTasksExtensionNegotiated()).toBe(true); + expect( + connected.getCapabilities()?.extensions?.[ + "io.modelcontextprotocol/tasks" + ], + ).toBeDefined(); + }); + + it("creates a task, polls tasks/get (no tasks/list or tasks/result), and inlines the completed result", async () => { + const started = await startModernTasksServer(); + const { connected, messages } = await connect(started.url, "modern"); + const { tools } = await connected.listTools(); + const tool = tools.find((t) => t.name === "modern_task"); + expect(tool).toBeDefined(); + + messages.length = 0; + const invocation = await connected.callToolStream(tool!, { + message: "hi", + }); + expect(invocation.success).toBe(true); + expect(firstText(invocation.result?.content as ContentBlock[])).toContain( + "completed", + ); + + const methods = methodsSent(messages); + expect(methods).toContain("tasks/get"); + expect(methods).not.toContain("tasks/list"); + expect(methods).not.toContain("tasks/result"); + + // Every task-eligible request declares the extension per-request (SEP-2663). + const call = messages.find( + (m) => + m.direction === "request" && + "method" in m.message && + m.message.method === "tools/call", + ); + const meta = ( + call?.message as { params?: { _meta?: Record } } + ).params?._meta; + expect( + ( + meta?.["io.modelcontextprotocol/clientCapabilities"] as { + extensions?: Record; + } + )?.extensions, + ).toHaveProperty("io.modelcontextprotocol/tasks"); + }); + + it("answers an input_required task via tasks/update, then completes", async () => { + const started = await startModernTasksServer(); + const { connected, messages } = await connect(started.url, "modern"); + const { tools } = await connected.listTools(); + const tool = tools.find((t) => t.name === "modern_input_task"); + expect(tool).toBeDefined(); + + let pausedAtPendingUi = false; + connected.addEventListener("newPendingElicitation", (event) => { + pausedAtPendingUi = true; + // The task round is tagged distinctly from an MRTR round so the UI can + // show the accurate "answer via tasks/update" note (#1631 follow-up). + expect(event.detail.origin).toBe("task-input-required"); + void event.detail.respond({ + action: "accept", + content: { approved: true }, + }); + }); + + messages.length = 0; + const invocation = await connected.callToolStream(tool!, {}); + expect(invocation.success).toBe(true); + expect(pausedAtPendingUi).toBe(true); + expect(firstText(invocation.result?.content as ContentBlock[])).toContain( + "approved", + ); + + const methods = methodsSent(messages); + expect(methods).toContain("tasks/update"); + }); + + it("auto-polls an unsolicited task handle on the ordinary callTool path (#1631 follow-up)", async () => { + const started = await startModernTasksServer(); + const { connected, messages } = await connect(started.url, "modern"); + const { tools } = await connected.listTools(); + const tool = tools.find((t) => t.name === "modern_task")!; + + // The ordinary (non-run-as-task) path: the server answers with a task + // handle; callTool must poll it to termination and resolve to the task's + // final inline result — NOT the transport's "task created" placeholder. + const taskUpdates: string[] = []; + connected.addEventListener("requestorTaskUpdated", (event) => { + taskUpdates.push(event.detail.taskId); + }); + + messages.length = 0; + const invocation = await connected.callTool(tool, { + message: "unsolicited", + }); + expect(invocation.success).toBe(true); + expect(firstText(invocation.result?.content as ContentBlock[])).toContain( + "completed", + ); + expect(taskUpdates.length).toBeGreaterThan(0); + expect(methodsSent(messages)).toContain("tasks/get"); + }); + + it("answers an input_required unsolicited handle via tasks/update on the ordinary path (#1631 follow-up)", async () => { + const started = await startModernTasksServer(); + const { connected, messages } = await connect(started.url, "modern"); + const { tools } = await connected.listTools(); + const tool = tools.find((t) => t.name === "modern_input_task")!; + + connected.addEventListener("newPendingElicitation", (event) => { + expect(event.detail.origin).toBe("task-input-required"); + void event.detail.respond({ + action: "accept", + content: { approved: true }, + }); + }); + + messages.length = 0; + const invocation = await connected.callTool(tool, {}); + expect(invocation.success).toBe(true); + expect(firstText(invocation.result?.content as ContentBlock[])).toContain( + "approved", + ); + expect(methodsSent(messages)).toContain("tasks/update"); + }); + + it("bounds a never-completing input_required task with the round cap (#1631 review)", async () => { + const started = await startModernTasksServer(); + const { connected } = await connect(started.url, "modern"); + const { tools } = await connected.listTools(); + const tool = tools.find((t) => t.name === "modern_loop_task")!; + + // The server never advances past input_required, so the client re-prompts + // each poll; auto-answer, and the round cap must eventually abort instead + // of looping forever. + connected.addEventListener("newPendingElicitation", (event) => { + void event.detail.respond({ + action: "accept", + content: { approved: true }, + }); + }); + + await expect(connected.callToolStream(tool, {})).rejects.toThrow( + /exceeded \d+ input_required rounds/, + ); + }); + + it("cancels a task paused at input_required — aborts the pending elicitation and unblocks the poll (#1631)", async () => { + const started = await startModernTasksServer(); + const { connected, messages } = await connect(started.url, "modern"); + const { tools } = await connected.listTools(); + + // A never-completing task: without the cancel-aborts-input fix, cancelling + // wouldn't unblock the poll — the modal would re-prompt forever. + const tool = tools.find((t) => t.name === "modern_loop_task")!; + let capturedTaskId: string | undefined; + let cancelledEventTaskId: string | undefined; + let cancelPromise: Promise | undefined; + connected.addEventListener("requestorTaskUpdated", (event) => { + capturedTaskId = event.detail.taskId; + }); + connected.addEventListener("taskCancelled", (event) => { + cancelledEventTaskId = event.detail.taskId; + }); + connected.addEventListener("newPendingElicitation", () => { + // Cancel the underlying task instead of answering — this must abort the + // pending elicitation (close the modal) and let the poll settle. + if (capturedTaskId && !cancelPromise) { + cancelPromise = connected + .cancelRequestorTask(capturedTaskId) + .catch(() => {}); + } + }); + + messages.length = 0; + // The poll must unblock and the call settle (reject) — not hang or loop. + await expect(connected.callToolStream(tool, {})).rejects.toThrow(); + // Let tasks/cancel finish (it dispatches taskCancelled on completion). + await cancelPromise; + expect(methodsSent(messages)).toContain("tasks/cancel"); + expect(cancelledEventTaskId).toBe(capturedTaskId); + // The pending elicitation was aborted, not left dangling behind a modal. + expect(connected.getPendingElicitations()).toHaveLength(0); + }); + + it("passes a synchronous (non-task) tool result straight through", async () => { + const started = await startModernTasksServer(); + const { connected } = await connect(started.url, "modern"); + const { tools } = await connected.listTools(); + const plain = tools.find((t) => t.name === "plain_echo"); + expect(plain).toBeDefined(); + + const invocation = await connected.callToolStream(plain!, { + message: "hi", + }); + expect(invocation.success).toBe(true); + expect(firstText(invocation.result?.content as ContentBlock[])).toContain( + "echo:hi", + ); + }); + + it("rejects a tasks/get for an unknown task id (raw-wire error path)", async () => { + const started = await startModernTasksServer(); + const { connected } = await connect(started.url, "modern"); + await expect( + connected.getRequestorTask("does-not-exist"), + ).rejects.toThrow(); + }); + + it("modern store refresh re-polls known tasks (no tasks/list)", async () => { + const started = await startModernTasksServer(); + const { connected, messages } = await connect(started.url, "modern"); + const store = new ManagedRequestorTasksState(connected); + const { tools } = await connected.listTools(); + const tool = tools.find((t) => t.name === "modern_task")!; + + // Running the task seeds the store via requestorTaskUpdated events. + await connected.callToolStream(tool, { message: "x" }); + expect(store.getTasks().length).toBeGreaterThan(0); + + messages.length = 0; + await store.refresh(); + const methods = methodsSent(messages); + // Refresh re-polls with tasks/get; it never calls tasks/list on modern. + expect(methods).not.toContain("tasks/list"); + store.destroy(); + }); + }); + + describe("legacy era", () => { + it("does not negotiate the extension and keeps the tasks/list flow", async () => { + const started = await startLegacyTasksServer(); + const { connected, messages } = await connect(started.url, "legacy"); + expect(connected.isTasksExtensionNegotiated()).toBe(false); + expect(connected.getCapabilities()?.tasks).toBeDefined(); + + const store = new ManagedRequestorTasksState(connected); + messages.length = 0; + await store.refresh(); + expect(methodsSent(messages)).toContain("tasks/list"); + store.destroy(); + }); + }); +}); diff --git a/core/mcp/__tests__/fakeInspectorClient.ts b/core/mcp/__tests__/fakeInspectorClient.ts index 166665ba4..35111b6aa 100644 --- a/core/mcp/__tests__/fakeInspectorClient.ts +++ b/core/mcp/__tests__/fakeInspectorClient.ts @@ -113,6 +113,24 @@ export class FakeInspectorClient listRequestorTasks = vi.fn( async () => this.taskPages.shift() ?? { tasks: [] }, ); + // Modern task poll (#1631): defaults to echoing back a minimal task; tests + // override the mock to drive status transitions. Dispatches nothing by + // default — tests that exercise the merge path dispatch requestorTaskUpdated + // themselves or override this to do so. + getRequestorTask = vi.fn(async (taskId: string) => ({ + taskId, + status: "working" as const, + ttl: null, + createdAt: "", + lastUpdatedAt: "", + })); + // Whether this fake presents as a modern connection that negotiated the + // tasks extension. Tests flip `tasksExtensionNegotiated` to true to exercise + // the modern task path. + tasksExtensionNegotiated = false; + isTasksExtensionNegotiated(): boolean { + return this.tasksExtensionNegotiated; + } // Aggregate variants used by the managed state stores on refresh: drain ALL // queued pages (mimicking the SDK's all-page walk) and return the flattened diff --git a/core/mcp/inspectorClient.ts b/core/mcp/inspectorClient.ts index e385dfc53..e594b89f3 100644 --- a/core/mcp/inspectorClient.ts +++ b/core/mcp/inspectorClient.ts @@ -59,6 +59,7 @@ import type { JSONRPCNotification, JSONRPCResultResponse, JSONRPCErrorResponse, + JSONRPCMessage, ServerCapabilities, ClientCapabilities, Implementation, @@ -107,7 +108,23 @@ import { isInputRequiredResult, withInputRequired, LOG_LEVEL_META_KEY, + CLIENT_CAPABILITIES_META_KEY, + CLIENT_INFO_META_KEY, + PROTOCOL_VERSION_META_KEY, + RELATED_TASK_META_KEY, } from "@modelcontextprotocol/client"; +import { + TASKS_EXTENSION_KEY, + MODERN_TASK_HANDLE_META, + MODERN_PROTOCOL_VERSION, + ModernGetTaskResultSchema, + ModernUpdateTaskResultSchema, + ModernCancelTaskResultSchema, + normalizeModernTask, + readInputRequests, + isModernCreateTaskResult, + type ModernDetailedTask, +} from "./modernTaskSchemas.js"; import { EmptyResultSchema, CallToolResultSchema, @@ -330,6 +347,9 @@ export class InspectorClient extends InspectorClientEventTarget { // UI surfaces (Server Info modal) can display them without poking at the // SDK Client's private state. private clientCapabilities: ClientCapabilities = {}; + // The client identity ({name, version}) passed to the SDK Client. Reused to + // build the modern per-request envelope for raw tasks/* requests. + private clientInfo: Implementation; // Sampling requests private pendingSamples: SamplingCreateMessage[] = []; // Elicitation requests @@ -376,6 +396,25 @@ export class InspectorClient extends InspectorClientEventTarget { // instead, so it lands in the right state immediately (#1455). Cleared on // disconnect. private cancelledTaskIds: Set = new Set(); + // Per-task abort controllers for a modern task paused at `input_required`. + // While the poll loop blocks on the pending elicitation (the modal), the tool + // call's own abort path isn't in play — so `cancelRequestorTask` aborts this + // controller to reject the pending request, close the modal, and let the poll + // observe the cancellation. Keyed by taskId; created/removed by the poll loops. + private taskInputAbortControllers = new Map(); + // Pending raw-wire requests (modern tasks/* — see rawWireRequest). Keyed by a + // string JSON-RPC id we mint; the SDK Client only mints numeric ids, so ours + // never collide with (or reach) it. Resolved by the transport's + // consume-response hook and rejected on disconnect. + private pendingRawWireRequests = new Map< + string, + { + resolve: (result: unknown) => void; + reject: (err: Error) => void; + timer: ReturnType; + } + >(); + private rawWireRequestCounter = 0; // Abort controller for the in-flight ordinary (non-task) tool call. Aborting // it makes the SDK send a `notifications/cancelled` for that request (the MCP // cancellation flow) and reject the pending call, which `callTool` surfaces as @@ -551,20 +590,30 @@ export class InspectorClient extends InspectorClientEventTarget { } if (options.oauth?.enterpriseManaged) { capabilities.extensions = { + ...capabilities.extensions, "io.modelcontextprotocol/enterprise-managed-authorization": {}, }; } - if (Object.keys(capabilities).length > 0) { - clientOptions.capabilities = capabilities; - } + // Advertise the modern Tasks extension (SEP-2663) so the SDK stamps it into + // every modern request's `clientCapabilities` envelope — the per-request + // declaration a server requires before it may return a `CreateTaskResult`. + // Harmless on legacy (extensions are ignored there). This is what makes + // server-directed ("unsolicited") task creation legal on modern, and it + // makes `capabilities` always non-empty, so it's always attached. + capabilities.extensions = { + ...capabilities.extensions, + [TASKS_EXTENSION_KEY]: {}, + }; + clientOptions.capabilities = capabilities; this.clientCapabilities = capabilities; this.appRendererClientProxy = null; + this.clientInfo = options.clientIdentity ?? { + name: corePackageJson.name.split("/")[1] ?? corePackageJson.name, + version: corePackageJson.version, + }; this.client = new Client( - options.clientIdentity ?? { - name: corePackageJson.name.split("/")[1] ?? corePackageJson.name, - version: corePackageJson.version, - }, + this.clientInfo, Object.keys(clientOptions).length > 0 ? clientOptions : undefined, ); } @@ -1085,6 +1134,12 @@ export class InspectorClient extends InspectorClientEventTarget { this.transport = new MessageTrackingTransport( baseTransport, messageTracking, + { + rewriteIncomingResult: (message) => + this.rewriteModernTaskResult(message), + consumeIncomingResponse: (message) => + this.consumeRawWireResponse(message), + }, ); this.attachTransportListeners(this.baseTransport); } @@ -1580,6 +1635,14 @@ export class InspectorClient extends InspectorClientEventTarget { INACTIVE_SUBSCRIPTION_STREAM_STATE, ); this.cancelledTaskIds.clear(); + // Settle any pending raw-wire (modern tasks/*) requests so their callers + // don't hang past teardown. + this.rejectPendingRawWireRequests("Disconnected"); + // Abort any task paused at input_required so its poll loop unwinds. + for (const [, controller] of this.taskInputAbortControllers) { + controller.abort(new Error("Disconnected")); + } + this.taskInputAbortControllers.clear(); // Abort any in-flight ordinary tool call so its promise settles instead of // hanging past teardown; drop the controller reference either way. this.activeToolCallAbortController?.abort("Disconnected"); @@ -1704,6 +1767,174 @@ export class InspectorClient extends InspectorClientEventTarget { }; } + /** + * True when the connection is modern (2026-07-28) AND the server advertised + * the `io.modelcontextprotocol/tasks` extension (SEP-2663) in its + * `server/discover` capabilities. Modern task methods (`tasks/get`, + * `tasks/update`, `tasks/cancel`) and the "unsolicited task handle" behavior + * are gated on this — legacy servers use `capabilities.tasks` and the + * `tasks/list`-backed flow instead. Exposed so the Tasks tab and the modern + * task store gate on the extension rather than the legacy capability. + */ + isTasksExtensionNegotiated(): boolean { + return ( + this.isModernEra() && + this.capabilities?.extensions?.[TASKS_EXTENSION_KEY] !== undefined + ); + } + + /** + * Build the full modern (2026-07-28) per-request envelope for a RAW tasks/* + * request. The SDK's codec normally stamps this envelope, but raw requests + * bypass the codec, and the modern server rejects a request whose + * `MCP-Protocol-Version` header names 2026-07-28 but omits the required + * envelope `_meta` keys (`protocolVersion`, `clientInfo`, plus + * `clientCapabilities` carrying the tasks extension). We reproduce it here. + */ + private withModernTaskEnvelope( + params: Record, + ): Record { + const existingMeta = + (params._meta as Record | undefined) ?? {}; + const clientCapabilities = { + ...this.clientCapabilities, + // extensions always carries the tasks extension (advertised at + // construction), so spreading it is never a no-op. + extensions: { + ...this.clientCapabilities.extensions, + [TASKS_EXTENSION_KEY]: {}, + }, + }; + // Use the NEGOTIATED protocol version so the envelope agrees with the + // `MCP-Protocol-Version` header the transport stamps from the same source — + // a future modern-family revision would negotiate a different string, and + // the two must not disagree. The raw channel only runs on a connected modern + // session, so this is always set; the constant is a defensive fallback. + /* v8 ignore next -- fallback only if getProtocolVersion() is unset, which + can't happen on the connected modern session this runs on. */ + const protocolVersion = this.getProtocolVersion() ?? MODERN_PROTOCOL_VERSION; + return { + ...params, + _meta: { + ...existingMeta, + [PROTOCOL_VERSION_META_KEY]: protocolVersion, + [CLIENT_INFO_META_KEY]: this.clientInfo, + [CLIENT_CAPABILITIES_META_KEY]: clientCapabilities, + }, + }; + } + + /** + * Transport-level rewrite of a modern (SEP-2663) `CreateTaskResult` + * (`resultType: "task"`) — the one task frame the SDK v2 codec rejects (tasks + * were removed, so the codec knows only `complete`/`input_required`). The true + * frame is already logged by `trackResponse`; here we hand the SDK a benign + * `CallToolResult` that carries the real `DetailedTask` under + * {@link MODERN_TASK_HANDLE_META}, where {@link pollTaskToolCall} reads it to + * drive the poll. Any other message passes through untouched. + */ + private rewriteModernTaskResult( + message: JSONRPCResultResponse, + ): JSONRPCMessage { + if (!isModernCreateTaskResult(message.result)) { + return message; + } + const task = message.result as ModernDetailedTask; + return { + ...message, + result: { + resultType: "complete", + content: [ + { type: "text", text: `Modern task ${task.taskId} created` }, + ], + _meta: { [MODERN_TASK_HANDLE_META]: task }, + }, + }; + } + + /** + * Send an extension method the SDK v2 era gate refuses to route — the modern + * `tasks/get` / `tasks/update` / `tasks/cancel`, which are spec-method names + * absent from the 2026-07-28 era, so `client.request` throws + * `MethodNotSupportedByProtocolVersion` before anything reaches the wire. + * + * We mint a string JSON-RPC id (the SDK only mints numeric ids, so ours never + * collide), send the raw frame straight through the transport (which still + * logs it via `trackRequest`, so the Protocol/Network tabs see it), and await + * the matching response — captured and consumed by the transport's + * consume-response hook so it never confuses the SDK Client. The response is + * validated with the caller's explicit schema. + */ + private async rawWireRequest( + method: string, + params: Record, + resultSchema: { parse: (value: unknown) => T }, + ): Promise { + const transport = this.transport; + if (!transport) { + throw new Error("Client is not connected"); + } + const id = `inspector-ext-${(this.rawWireRequestCounter += 1)}`; + const message = { + jsonrpc: "2.0" as const, + id, + method, + params, + } as unknown as JSONRPCMessage; + const timeoutMs = this.requestTimeout ?? 30_000; + const raw = await new Promise((resolve, reject) => { + const timer = setTimeout(() => { + this.pendingRawWireRequests.delete(id); + reject(new Error(`Raw request "${method}" timed out after ${timeoutMs} ms`)); + }, timeoutMs); + this.pendingRawWireRequests.set(id, { resolve, reject, timer }); + transport.send(message).catch((err: unknown) => { + const pending = this.pendingRawWireRequests.get(id); + if (pending) { + clearTimeout(pending.timer); + this.pendingRawWireRequests.delete(id); + } + reject(err instanceof Error ? err : new Error(String(err))); + }); + }); + return resultSchema.parse(raw); + } + + /** + * Transport consume-response hook: resolve/reject a pending + * {@link rawWireRequest} when its response arrives, and report it as consumed + * (so the transport does not forward it to the SDK Client, which never sent + * it). Returns false for any id we don't own, leaving normal SDK traffic + * untouched. + */ + private consumeRawWireResponse( + message: JSONRPCResultResponse | JSONRPCErrorResponse, + ): boolean { + const id = String((message as { id?: unknown }).id); + const pending = this.pendingRawWireRequests.get(id); + if (!pending) { + return false; + } + this.pendingRawWireRequests.delete(id); + clearTimeout(pending.timer); + if ("error" in message) { + const err = (message as JSONRPCErrorResponse).error; + pending.reject(new Error(err?.message ?? `Request ${id} failed`)); + } else { + pending.resolve((message as JSONRPCResultResponse).result); + } + return true; + } + + /** Reject and clear all pending raw-wire requests (on disconnect/teardown). */ + private rejectPendingRawWireRequests(reason: string): void { + for (const [, pending] of this.pendingRawWireRequests) { + clearTimeout(pending.timer); + pending.reject(new Error(reason)); + } + this.pendingRawWireRequests.clear(); + } + /** * Get requestor task status by taskId (tasks we created on the server) * @param taskId Task identifier @@ -1713,9 +1944,26 @@ export class InspectorClient extends InspectorClientEventTarget { if (!this.client) { throw new Error("Client is not connected"); } - // SDK v2 removed `client.experimental.tasks.*`; drive the 2025-11-25 - // `tasks/get` wire method directly with its explicit (deprecated-but- - // importable) result schema. `GetTaskResult` is the flattened task object. + // Modern (SEP-2663): `tasks/get` returns a `DetailedTask` (ttlMs/pollIntervalMs, + // inlined result/error/inputRequests) — a different wire shape than the + // deprecated SDK schema. Parse with the explicit modern schema and normalize + // onto the internal Task shape, stamping the extension client capability. + if (this.isTasksExtensionNegotiated()) { + const modern = await this.rawWireRequest( + "tasks/get", + this.withModernTaskEnvelope({ taskId }), + ModernGetTaskResultSchema, + ); + const task = normalizeModernTask(modern); + this.dispatchTypedEvent("requestorTaskUpdated", { + taskId: task.taskId, + task, + }); + return task; + } + // Legacy (2025-11-25): SDK v2 removed `client.experimental.tasks.*`; drive + // the `tasks/get` wire method directly with its deprecated-but-importable + // result schema. `GetTaskResult` is the flattened task object. const task = (await this.client.request( { method: "tasks/get", params: { taskId } }, GetTaskResultSchema, @@ -1762,16 +2010,60 @@ export class InspectorClient extends InspectorClientEventTarget { // whose error message may arrive before this resolves — the stream's error // path reads this set to label the task "cancelled" rather than "failed". this.cancelledTaskIds.add(taskId); - await this.client.request( - { method: "tasks/cancel", params: { taskId } }, - CancelTaskResultSchema, - this.getRequestOptions(), - ); + // If the task is paused at `input_required` (its poll loop blocked on the + // pending-request modal), abort it so the modal closes and the poll observes + // the cancellation — otherwise the user is stuck answering a modal that a + // non-advancing server would keep re-showing. + const inputAbort = this.taskInputAbortControllers.get(taskId); + if (inputAbort) { + inputAbort.abort(new Error(`Task ${taskId} cancelled by user`)); + } + // Modern `tasks/cancel` is a raw-wire request (the SDK era gate blocks the + // spec-method name on 2026-07-28); legacy uses the SDK path + deprecated + // schema. + if (this.isTasksExtensionNegotiated()) { + await this.rawWireRequest( + "tasks/cancel", + this.withModernTaskEnvelope({ taskId }), + ModernCancelTaskResultSchema, + ); + } else { + await this.client.request( + { method: "tasks/cancel", params: { taskId } }, + CancelTaskResultSchema, + this.getRequestOptions(), + ); + } // Dispatch event this.dispatchTypedEvent("taskCancelled", { taskId }); } + /** + * Fulfil the outstanding `inputRequests` of a modern (SEP-2663) + * `input_required` task by sending `tasks/update` with the collected + * `inputResponses`. The server acks with an empty result; the task's + * observable status advances on a subsequent `tasks/get` poll (the update is + * eventually consistent). Modern-only — legacy tasks surface input through the + * server→client request channel, not `tasks/update`. + * + * @param taskId Task identifier + * @param inputResponses Responses keyed by the server's `inputRequests` ids + */ + async updateRequestorTask( + taskId: string, + inputResponses: Record, + ): Promise { + if (!this.client) { + throw new Error("Client is not connected"); + } + await this.rawWireRequest( + "tasks/update", + this.withModernTaskEnvelope({ taskId, inputResponses }), + ModernUpdateTaskResultSchema, + ); + } + /** * Cancel the in-flight ordinary (non-task) tool call started by * {@link callTool}. Aborting its request makes the SDK send a @@ -1987,36 +2279,40 @@ export class InspectorClient extends InspectorClientEventTarget { private async fulfilInputRequests( inputRequests: InputRequests | undefined, signal?: AbortSignal, + origin: PendingRequestOrigin = "input-required", ): Promise | undefined> { if (!inputRequests) return undefined; const responses: Record = {}; for (const [key, embedded] of Object.entries(inputRequests)) { - responses[key] = await this.fulfilEmbeddedInputRequest(embedded, signal); + responses[key] = await this.fulfilEmbeddedInputRequest( + embedded, + signal, + origin, + ); } return responses; } /** - * Fulfil a single embedded MRTR request. `roots/list` is auto-answered from + * Fulfil a single embedded input 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"`. + * through the pending-request UI tagged with `origin`. `origin` distinguishes + * an MRTR round (`"input-required"`, answer echoed as a retry) from a modern + * task round (`"task-input-required"`, answer submitted via `tasks/update`). */ private async fulfilEmbeddedInputRequest( request: CreateMessageRequest | ElicitRequest | ListRootsRequest, signal?: AbortSignal, + origin: PendingRequestOrigin = "input-required", ): Promise { switch (request.method) { case "roots/list": return { roots: this.roots ?? [] }; case "elicitation/create": - return this.enqueuePendingElicitation( - request, - "input-required", - signal, - ); + return this.enqueuePendingElicitation(request, origin, signal); case "sampling/createMessage": - return this.enqueuePendingSample(request, "input-required", signal); + return this.enqueuePendingSample(request, origin, 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 @@ -2514,7 +2810,7 @@ export class InspectorClient extends InspectorClientEventTarget { // 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( + const rawResult = await this.invokeMcpClient( () => this.requestWithInputRequired( "tools/call", @@ -2525,6 +2821,19 @@ export class InspectorClient extends InspectorClientEventTarget { { method: "tools/call", toolName: tool.name }, ); + // Unsolicited modern task handle (SEP-2663): on a modern connection the + // server may answer ANY `tools/call` with a task rather than a result. The + // transport rewrote that frame into a `CallToolResult` carrying the real + // `DetailedTask` in `_meta`; poll it to completion here (the run-as-task + // path does the same via `callToolStream`) so the ordinary call resolves to + // the task's final result and the Tasks tab tracks it. + const taskHandle = (rawResult as CallToolResult)._meta?.[ + MODERN_TASK_HANDLE_META + ] as ModernDetailedTask | undefined; + const result = taskHandle + ? await this.pollModernTaskToTermination(taskHandle) + : rawResult; + // Output-schema validation. SDK v2's `callTool` relaxed some checks (e.g. it // no longer rejects a structuredContent with undeclared properties against a // strict `additionalProperties: false` schema), so we run our own Ajv check @@ -2655,6 +2964,205 @@ export class InspectorClient extends InspectorClientEventTarget { return validateToolOutput(this.outputValidator, tool, result); } + /** + * When a modern (SEP-2663) task is `input_required`, fulfil its embedded + * `inputRequests` through the pending-request UI and submit them via + * `tasks/update`. No-op for any other status. Shared by the streaming + * ({@link pollTaskToolCall}) and ordinary ({@link pollModernTaskToTermination}) + * poll loops so the input handling lives in one place. + * + * `priorRounds` is the count of `input_required` rounds already handled for + * this task; the return value is the updated count. A non-conformant server + * that keeps returning `input_required` without ever completing would + * otherwise re-prompt the user on every poll forever, so we bound it with the + * same {@link MRTR_MAX_ROUNDS} cap the MRTR driver uses. + */ + private async submitModernTaskInput( + detailed: ModernDetailedTask, + task: Task, + priorRounds: number, + signal?: AbortSignal, + ): Promise { + if (task.status !== "input_required") { + return priorRounds; + } + const rounds = priorRounds + 1; + if (rounds > InspectorClient.MRTR_MAX_ROUNDS) { + throw new Error( + `Modern task "${task.taskId}" exceeded ${InspectorClient.MRTR_MAX_ROUNDS} input_required rounds without completing.`, + ); + } + const inputResponses = await this.fulfilInputRequests( + this.tagInputRequestsWithTask(readInputRequests(detailed), task.taskId), + signal, + "task-input-required", + ); + /* v8 ignore next 3 -- a conformant `input_required` task always carries + `inputRequests`, so `fulfilInputRequests` returns a (possibly empty) + object here, never undefined; the guard is defensive. */ + if (inputResponses) { + await this.updateRequestorTask(task.taskId, inputResponses); + } + return rounds; + } + + /** + * Stamp `_meta[RELATED_TASK_META_KEY]` with the owning task id on each embedded + * request of a modern task's `inputRequests`. The pending-request UI reads that + * id (via `ElicitationCreateMessage.taskId`) so its Cancel control can cancel + * the TASK — not just answer the request — when a task is paused at + * `input_required`. + */ + private tagInputRequestsWithTask( + inputRequests: InputRequests | undefined, + taskId: string, + ): InputRequests | undefined { + /* v8 ignore next -- only called for an input_required task, which always + carries inputRequests; the undefined passthrough is defensive. */ + if (!inputRequests) return inputRequests; + const tagged: Record = {}; + for (const [key, req] of Object.entries(inputRequests)) { + const request = req as { params?: { _meta?: Record } }; + tagged[key] = { + ...request, + params: { + ...request.params, + _meta: { + ...request.params?._meta, + [RELATED_TASK_META_KEY]: { taskId }, + }, + }, + }; + } + return tagged as InputRequests; + } + + /** + * Terminal outcome for a modern task: the inlined `CallToolResult` for a + * `completed` task (SEP-2663 removed the blocking `tasks/result`), or a + * `ProtocolError` for `failed` / `cancelled`. Shared so both poll loops agree + * on the result/error shape. + */ + private modernTaskTerminalOutcome( + task: Task, + detailed: ModernDetailedTask, + ): + | { type: "result"; result: CallToolResult } + | { type: "error"; error: ProtocolError } { + if (task.status === "completed") { + /* v8 ignore next -- a conformant `completed` task always inlines its + `result`; the `{ content: [] }` fallback is defensive. */ + return { + type: "result", + result: (detailed.result ?? { content: [] }) as CallToolResult, + }; + } + return { + type: "error", + error: new ProtocolError( + ProtocolErrorCode.InternalError, + task.statusMessage ?? `Task ${task.status}`, + ), + }; + } + + /** + * Poll cadence for a task: the server-advertised `pollInterval` when + * positive, else the default. Shared by every task poll loop (both eras). + */ + private taskPollInterval(task: Task): number { + const advertised = task.pollInterval; + if (typeof advertised !== "number") return DEFAULT_TASK_POLL_INTERVAL_MS; + // A spec-conformant server never advertises a non-positive interval; the + // `> 0` guard is defensive against a malformed value. + /* v8 ignore next -- non-positive pollInterval is unreachable from a conformant server. */ + return advertised > 0 ? advertised : DEFAULT_TASK_POLL_INTERVAL_MS; + } + + /** + * Register a per-task abort controller (keyed by taskId) whose signal gates + * the task's `input_required` pending request, and return the signal plus a + * `release` cleanup. {@link cancelRequestorTask} aborts it to unblock a task + * paused at the pending-request modal. + */ + private registerTaskInputAbort(taskId: string): { + signal: AbortSignal; + release: () => void; + } { + const controller = new AbortController(); + this.taskInputAbortControllers.set(taskId, controller); + return { + signal: controller.signal, + release: () => { + // Only delete our own entry — tool calls are serial, so a second task + // never replaces this id's controller mid-poll; the guard is defensive. + /* v8 ignore next */ + if (this.taskInputAbortControllers.get(taskId) === controller) { + this.taskInputAbortControllers.delete(taskId); + } + }, + }; + } + + /** + * Drive a modern (SEP-2663) task to a terminal state from a seed + * `DetailedTask`, dispatching task events so the Tasks tab and toasts track + * it, and return the completed task's inlined `CallToolResult` (or throw on + * `failed` / `cancelled`). Used by the ORDINARY `callTool` path when a server + * returns an unsolicited task handle (the run-as-task streaming path drives + * the equivalent loop inline in {@link pollTaskToolCall}). `input_required` + * rounds are answered through the pending-request UI and submitted via + * `tasks/update`. + */ + private async pollModernTaskToTermination( + seed: ModernDetailedTask, + ): Promise { + let detailed = seed; + let task = normalizeModernTask(detailed); + const emit = (t: Task): void => { + this.dispatchTypedEvent("toolCallTaskUpdated", { + taskId: t.taskId, + task: t, + }); + this.dispatchTypedEvent("requestorTaskUpdated", { + taskId: t.taskId, + task: t, + }); + }; + emit(task); + const { signal: inputSignal, release } = this.registerTaskInputAbort( + task.taskId, + ); + try { + let inputRounds = 0; + while (!InspectorClient.isTerminalTaskStatus(task.status)) { + inputRounds = await this.submitModernTaskInput( + detailed, + task, + inputRounds, + inputSignal, + ); + await new Promise((resolve) => + setTimeout(resolve, this.taskPollInterval(task)), + ); + detailed = await this.rawWireRequest( + "tasks/get", + this.withModernTaskEnvelope({ taskId: task.taskId }), + ModernGetTaskResultSchema, + ); + task = normalizeModernTask(detailed); + emit(task); + } + } finally { + release(); + } + const outcome = this.modernTaskTerminalOutcome(task, detailed); + if (outcome.type === "error") { + throw outcome.error; + } + return outcome.result; + } + /** * Poll a task-augmented tool call to completion. Replaces the removed * `client.experimental.tasks.callToolStream` helper: it sends the @@ -2700,12 +3208,27 @@ export class InspectorClient extends InspectorClientEventTarget { // 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. + // NOTE: the LEGACY task path does NOT opt into `allowInputRequired` (MRTR + // over legacy tasks is out of scope for #1704). The MODERN path (SEP-2663) + // instead surfaces a task's `input_required` through `tasks/get`'s + // `inputRequests` and answers via `tasks/update` (handled in the poll loop + // below), reusing the same pending-request UI. + const modernTasks = this.isTasksExtensionNegotiated(); const requestPromise = client.request( - { method: "tools/call", params }, - CreateTaskResultSchema.or(CallToolResultSchema), + { + // On modern the SDK codec stamps the tasks-extension client capability + // into the request envelope (advertised at construction), so a server + // may answer with a `CreateTaskResult` — no per-call `_meta` needed. + method: "tools/call", + params, + }, + // Modern: the SDK codec can't decode a `resultType: "task"` result, so the + // transport rewrote it to a `CallToolResult` carrying the task handle in + // `_meta` — parse as a CallToolResult and read the handle below. Legacy: + // accept a `{ task }` handle or an immediate result. + modernTasks + ? CallToolResultSchema + : CreateTaskResultSchema.or(CallToolResultSchema), requestOptions, ); // The SDK registers the progress handler synchronously while constructing @@ -2722,6 +3245,67 @@ export class InspectorClient extends InspectorClientEventTarget { ? [...progressHandlers.keys()].find((k) => !keysBeforeRequest.has(k)) : undefined; const created = await requestPromise; + + if (modernTasks) { + // Modern (SEP-2663): a task-creating `tools/call` came back as a + // `resultType: "task"` frame the SDK can't decode, so the transport + // rewrote it to a `CallToolResult` carrying the real `DetailedTask` under + // MODERN_TASK_HANDLE_META. A synchronous completion has no such handle — + // yield that `CallToolResult` directly. + const handle = (created as CallToolResult)._meta?.[ + MODERN_TASK_HANDLE_META + ] as ModernDetailedTask | undefined; + if (!handle) { + yield { type: "result", result: created as CallToolResult }; + return; + } + let detailed = handle; + let task = normalizeModernTask(detailed); + yield { type: "taskCreated", task }; + if (progressSubscriptionId != null && requestOptions.onprogress) { + progressHandlers.set(progressSubscriptionId, requestOptions.onprogress); + } + const { signal: inputSignal, release } = this.registerTaskInputAbort( + task.taskId, + ); + let inputRounds = 0; + try { + while (!InspectorClient.isTerminalTaskStatus(task.status)) { + // `input_required`: fulfil the embedded server→client requests through + // the same pending-request UI the MRTR path uses, then submit them via + // `tasks/update`. The update is eventually consistent — the task's + // status advances on a following `tasks/get`, so keep polling + // (bounded by MRTR_MAX_ROUNDS against a server that never advances). + // `inputSignal` fires if the task is cancelled while paused here. + inputRounds = await this.submitModernTaskInput( + detailed, + task, + inputRounds, + inputSignal, + ); + await new Promise((resolve) => + setTimeout(resolve, this.taskPollInterval(task)), + ); + detailed = await this.rawWireRequest( + "tasks/get", + this.withModernTaskEnvelope({ taskId: task.taskId }), + ModernGetTaskResultSchema, + ); + task = normalizeModernTask(detailed); + yield { type: "taskStatus", task }; + } + } finally { + release(); + if (progressSubscriptionId != null) { + progressHandlers.delete(progressSubscriptionId); + } + } + // Modern removes the blocking `tasks/result`: a completed task inlines its + // CallToolResult; failed/cancelled surface as an error. + yield this.modernTaskTerminalOutcome(task, detailed); + return; + } + if (!("task" in created) || created.task == null) { // Immediate result — no task was created; yield it directly. yield { type: "result", result: created as CallToolResult }; @@ -2739,11 +3323,9 @@ export class InspectorClient extends InspectorClientEventTarget { // Poll `tasks/get` until the task reaches a terminal status. Honour the // server-advertised `pollInterval` when present, else the default cadence. while (!InspectorClient.isTerminalTaskStatus(task.status)) { - const pollInterval = - typeof task.pollInterval === "number" && task.pollInterval > 0 - ? task.pollInterval - : DEFAULT_TASK_POLL_INTERVAL_MS; - await new Promise((resolve) => setTimeout(resolve, pollInterval)); + await new Promise((resolve) => + setTimeout(resolve, this.taskPollInterval(task)), + ); task = (await client.request( { method: "tasks/get", params: { taskId: task.taskId } }, GetTaskResultSchema, diff --git a/core/mcp/inspectorClientProtocol.ts b/core/mcp/inspectorClientProtocol.ts index cf8d187e0..d2e44cc75 100644 --- a/core/mcp/inspectorClientProtocol.ts +++ b/core/mcp/inspectorClientProtocol.ts @@ -91,6 +91,15 @@ export interface InspectorClientProtocol extends InspectorClientEventTarget { listRequestorTasks( cursor?: string, ): Promise<{ tasks: Task[]; nextCursor?: string }>; + /** Poll one requestor task's current status (era-aware: modern `DetailedTask` + * via `tasks/get`, or the legacy flattened task). Dispatches + * `requestorTaskUpdated`. Used by the modern task store's refresh (no + * `tasks/list`). */ + getRequestorTask(taskId: string): Promise; + /** True when a modern (2026-07-28) connection negotiated the + * `io.modelcontextprotocol/tasks` extension (SEP-2663). Gates the Tasks tab + * and the modern task store's poll-based refresh. */ + isTasksExtensionNegotiated(): boolean; // Aggregate (all-page) list methods used by the managed state stores on // refresh. Unlike the single-page methods above, these route through the diff --git a/core/mcp/messageTrackingTransport.ts b/core/mcp/messageTrackingTransport.ts index d55158800..32b17169d 100644 --- a/core/mcp/messageTrackingTransport.ts +++ b/core/mcp/messageTrackingTransport.ts @@ -26,15 +26,52 @@ export interface MessageTrackingCallbacks { ) => void; } +/** + * Optional rewrite of an incoming response BEFORE it reaches the SDK's codec. + * Used for extension result shapes the SDK v2 codec would reject outright — e.g. + * a modern (SEP-2663) `resultType: "task"` result, which the codec has no + * knowledge of (tasks were removed from the SDK). The ORIGINAL message is still + * what `trackResponse` logs (so the Protocol/Network tabs show the true wire); + * only the copy handed to the SDK is rewritten. Return the message unchanged to + * pass it through untouched. + */ +export type IncomingResultRewriter = ( + message: JSONRPCResultResponse, +) => JSONRPCMessage; + +/** + * Optional consumer for an incoming response the SDK Client did not originate — + * used for the raw-wire channel that drives extension methods the SDK v2 era + * gate refuses to send (e.g. modern `tasks/get`/`tasks/update`/`tasks/cancel`, + * which are spec-method names absent from the 2026-07-28 era). When this returns + * `true` the response is treated as fully handled and is NOT forwarded to the + * SDK Client (which has no pending request for it). The response is still logged + * by `trackResponse` first, so the Protocol/Network tabs see the true frame. + */ +export type IncomingResponseConsumer = ( + message: JSONRPCResultResponse | JSONRPCErrorResponse, +) => boolean; + +export interface MessageTrackingHooks { + rewriteIncomingResult?: IncomingResultRewriter; + consumeIncomingResponse?: IncomingResponseConsumer; +} + // Transport wrapper that intercepts all messages for tracking export class MessageTrackingTransport implements Transport { private baseTransport: Transport; private callbacks: MessageTrackingCallbacks; private negotiatedProtocolVersion?: string; + private hooks: MessageTrackingHooks; - constructor(baseTransport: Transport, callbacks: MessageTrackingCallbacks) { + constructor( + baseTransport: Transport, + callbacks: MessageTrackingCallbacks, + hooks: MessageTrackingHooks = {}, + ) { this.baseTransport = baseTransport; this.callbacks = callbacks; + this.hooks = hooks; } async start(): Promise { @@ -121,6 +158,28 @@ export class MessageTrackingTransport implements Transport { message as JSONRPCResultResponse | JSONRPCErrorResponse, "server", ); + // Consume a response to a raw-wire request the SDK never sent (e.g. + // a modern `tasks/get`); handled entirely by the caller, not the SDK. + if ( + this.hooks.consumeIncomingResponse?.( + message as JSONRPCResultResponse | JSONRPCErrorResponse, + ) + ) { + return; + } + // Rewrite a result the SDK codec can't decode (e.g. a modern + // `resultType: "task"` handle) AFTER logging the true wire, so the + // SDK receives a shape it accepts while the Protocol/Network tabs + // still show the real frame. + if (this.hooks.rewriteIncomingResult && "result" in message) { + const rewritten = this.hooks.rewriteIncomingResult( + message as JSONRPCResultResponse, + ); + if (rewritten !== message) { + handler(rewritten as T, extra); + return; + } + } } else if ("method" in message) { // This is a request coming from the server this.callbacks.trackRequest?.(message as JSONRPCRequest, "server"); diff --git a/core/mcp/modernTaskSchemas.ts b/core/mcp/modernTaskSchemas.ts new file mode 100644 index 000000000..885717508 --- /dev/null +++ b/core/mcp/modernTaskSchemas.ts @@ -0,0 +1,132 @@ +/** + * Modern (2026-07-28) task extension wire schemas — SEP-2663 + * (`io.modelcontextprotocol/tasks`). + * + * SDK v2 removed all built-in tasks support: the `Task` / `GetTaskResultSchema` + * / `CreateTaskResultSchema` it still exports are the **deprecated 2025-11-25** + * vocabulary (`ttl` / `pollInterval`, blocking `tasks/result`, `tasks/list`). + * The redesigned extension is a different wire shape — `ttlMs` / `pollIntervalMs`, + * a polymorphic `DetailedTask` that inlines `result` / `error` / `inputRequests` + * by status, `tasks/get` polling, a new `tasks/update`, no `tasks/list`, and no + * blocking `tasks/result`. There is no SDK schema for it, so the Inspector drives + * modern `tasks/*` as raw requests with these explicit schemas (the "explicit- + * schema raw-request form" the SDK docs prescribe). + * + * Schemas are intentionally permissive (`looseObject`) so an unknown wire field + * (e.g. a future status-specific member) passes through rather than failing the + * parse — the Inspector is a debugging tool and should surface, not reject. + */ + +import { z } from "zod/v4"; +import type { InputRequests, Task } from "@modelcontextprotocol/client"; + +/** SEP-2133 extension identifier for the redesigned Tasks extension (SEP-2663). */ +export const TASKS_EXTENSION_KEY = "io.modelcontextprotocol/tasks"; + +/** The modern protocol revision, used as the raw-request envelope's + * `protocolVersion` when the negotiated version isn't otherwise available. */ +export const MODERN_PROTOCOL_VERSION = "2026-07-28"; + +/** The `_meta` value stamped on modern task-eligible requests to declare the + * client supports the tasks extension (per-request capability, SEP-2663). */ +export const TASKS_EXTENSION_CLIENT_CAPABILITY = { + extensions: { [TASKS_EXTENSION_KEY]: {} }, +} as const; + +/** + * `_meta` key under which the transport-level rewriter stashes a modern task + * handle. SDK v2's codec rejects a `resultType: "task"` result outright (tasks + * were removed), so a task-creating `tools/call` response is rewritten to a + * benign `CallToolResult` carrying the real `DetailedTask` here, where the task + * poll driver reads it. See `MessageTrackingTransport`'s rewrite hook. + */ +export const MODERN_TASK_HANDLE_META = + "io.modelcontextprotocol/inspector/modernTaskHandle"; + +/** True when a decoded wire result is a modern `CreateTaskResult` + * (`resultType: "task"`) — the frame the SDK codec cannot handle. */ +export function isModernCreateTaskResult(result: unknown): boolean { + return ( + typeof result === "object" && + result !== null && + (result as { resultType?: unknown }).resultType === "task" && + typeof (result as { taskId?: unknown }).taskId === "string" + ); +} + +const ModernTaskStatusSchema = z.enum([ + "working", + "input_required", + "completed", + "failed", + "cancelled", +]); + +/** + * `DetailedTask` (SEP-2663): the modern task shape returned by `tasks/get` and + * carried by a `CreateTaskResult`. Status-specific members (`result`, `error`, + * `inputRequests`) are optional here because a single loose schema stands in for + * the wire union `Working | InputRequired | Completed | Failed | Cancelled`. + */ +export const ModernDetailedTaskSchema = z.looseObject({ + taskId: z.string(), + status: ModernTaskStatusSchema, + statusMessage: z.string().optional(), + createdAt: z.string(), + lastUpdatedAt: z.string(), + ttlMs: z.number().nullable().optional(), + pollIntervalMs: z.number().optional(), + /** Present on `completed`: the original request's result (e.g. CallToolResult). */ + result: z.record(z.string(), z.unknown()).optional(), + /** Present on `failed`: the JSON-RPC error that ended the task. */ + error: z.record(z.string(), z.unknown()).optional(), + /** Present on `input_required`: embedded server→client requests, keyed by id. */ + inputRequests: z.record(z.string(), z.unknown()).optional(), +}); + +export type ModernDetailedTask = z.infer; + +/** `GetTaskResult = Result & DetailedTask`. Same fields we need as the task itself. */ +export const ModernGetTaskResultSchema = ModernDetailedTaskSchema; + +/** `CreateTaskResult = Result & Task` (`resultType: "task"`). The seed task state. */ +export const ModernCreateTaskResultSchema = ModernDetailedTaskSchema; + +/** `UpdateTaskResult` — an empty acknowledgement (`resultType: "complete"`). */ +export const ModernUpdateTaskResultSchema = z.looseObject({}); + +/** `CancelTaskResult` — modern cancel acks with an empty/loose result. */ +export const ModernCancelTaskResultSchema = z.looseObject({}); + +/** + * Normalize a modern `DetailedTask` onto the internal (SDK 2025-11-25) `Task` + * shape the state store, events, and `TaskCard` consume: `ttlMs` → `ttl`, + * `pollIntervalMs` → `pollInterval`. The status-specific members + * (`result` / `error` / `inputRequests`) ride along structurally so the poll + * driver can read them; they are not part of the `Task` type but are harmless + * extra properties on the object (the card renders the full task JSON). + */ +export function normalizeModernTask(modern: ModernDetailedTask): Task { + const { ttlMs, pollIntervalMs, ...rest } = modern; + const normalized: Record = { ...rest }; + // The internal Task requires `ttl: number | null`; map the modern `ttlMs` + // (which is itself `number | null`) straight across, defaulting to null. + normalized.ttl = ttlMs ?? null; + if (pollIntervalMs != null) normalized.pollInterval = pollIntervalMs; + // The loose modern schema is a structural superset of the internal Task + // (taskId/status/statusMessage/createdAt/lastUpdatedAt present; ttl/pollInterval + // mapped above). No SDK schema relates the two nominal types, so a single + // narrowing cast bridges the structurally-identical shape. + return normalized as unknown as Task; +} + +/** Read the embedded `inputRequests` map off a modern task, typed for + * {@link fulfilInputRequests}. The loose parse yields `unknown` values; the + * per-request `fulfilEmbeddedInputRequest` switch validates each by method. */ +export function readInputRequests( + modern: ModernDetailedTask, +): InputRequests | undefined { + // Structural bridge: the loose record parse cannot express the InputRequests + // union, but fulfilEmbeddedInputRequest validates each entry by its `method`. + return modern.inputRequests as InputRequests | undefined; +} diff --git a/core/mcp/state/managedRequestorTasksState.ts b/core/mcp/state/managedRequestorTasksState.ts index 1bb3ec102..a75d28d48 100644 --- a/core/mcp/state/managedRequestorTasksState.ts +++ b/core/mcp/state/managedRequestorTasksState.ts @@ -45,6 +45,12 @@ export class ManagedRequestorTasksState extends TypedEventTarget(); + // Task ids the user cancelled via the Cancel Task control. Cancellation is a + // deliberate terminal decision, so it is sticky: a status update that arrives + // afterwards — e.g. a server that completes the task anyway (cancellation is + // cooperative) or the in-flight tool call resolving with a result — must not + // flip a cancelled task back to "completed". Reset on disconnect / destroy. + private cancelledTaskIds = new Set(); constructor(client: InspectorClientProtocol) { super(); @@ -59,14 +65,21 @@ export class ManagedRequestorTasksState extends TypedEventTarget + this.dismissedTaskIds.has(taskId) || + (this.cancelledTaskIds.has(taskId) && status !== "cancelled"); const onTaskStatusChange = ( e: TypedEventGeneric, ): void => { const { taskId, task } = e.detail; - if (this.dismissedTaskIds.has(taskId)) return; + if (shouldIgnoreUpdate(taskId, task.status)) return; this.tasks = mergeTaskIntoList(this.tasks, taskId, task); this.dispatchTypedEvent("tasksChange", this.tasks); }; @@ -74,7 +87,7 @@ export class ManagedRequestorTasksState extends TypedEventTarget, ): void => { const { taskId, task } = e.detail; - if (this.dismissedTaskIds.has(taskId)) return; + if (shouldIgnoreUpdate(taskId, task.status)) return; this.tasks = mergeTaskIntoList(this.tasks, taskId, task); this.dispatchTypedEvent("tasksChange", this.tasks); }; @@ -83,6 +96,8 @@ export class ManagedRequestorTasksState extends TypedEventTarget { const { taskId } = e.detail; if (this.dismissedTaskIds.has(taskId)) return; + // Remember the cancel so a later status update can't un-cancel it. + this.cancelledTaskIds.add(taskId); const idx = this.tasks.findIndex((t) => t.taskId === taskId); if (idx >= 0) { const next = [...this.tasks]; @@ -126,9 +141,18 @@ export class ManagedRequestorTasksState extends TypedEventTarget !this.dismissedTaskIds.has(t.taskId), - ); + // For a user-cancelled task, keep it but pin its status to "cancelled": + // cancellation is cooperative, so a server that completes and re-lists it + // as "completed" must not un-stick the cancel on a manual Refresh — same + // guarantee the live-merge handlers give via `cancelledTaskIds`. + const page = result.tasks + .filter((t) => !this.dismissedTaskIds.has(t.taskId)) + .map((t) => + this.cancelledTaskIds.has(t.taskId) + ? { ...t, status: "cancelled" as const } + : t, + ); this.tasks = cursor ? [...this.tasks, ...page] : page; cursor = result.nextCursor; pageCount++; @@ -159,6 +191,29 @@ export class ManagedRequestorTasksState extends TypedEventTarget { + const ids = this.tasks + .map((t) => t.taskId) + .filter((id) => !this.dismissedTaskIds.has(id)); + for (const id of ids) { + try { + await client.getRequestorTask(id); + } catch { + // Ignore a single failed poll (expired / unknown task); keep the rest. + } + } + return this.getTasks(); + } + /** * Drop terminal-state tasks (completed / failed / cancelled) from the list. * Their ids are remembered in `dismissedTaskIds` so they stay gone for the @@ -186,5 +241,6 @@ export class ManagedRequestorTasksState extends TypedEventTarget; } = {}; - if (config.tools !== undefined) { + // The modern tasks extension (SEP-2663) needs the tools capability too (its + // task-augmented tools list via `tools/list`), even when no `config.tools` + // were supplied. + if (config.tools !== undefined || config.tasksExtension) { capabilities.tools = {}; } if ( @@ -651,6 +676,19 @@ export function createMcpServer(config: ServerConfig): McpServer { } } + // Modern tasks extension (SEP-2663): advertise it in server/discover and reuse + // (or lazily create + cache) the shared runtime so the stateless modern leg's + // per-request servers all answer against the same task store. + const modernTaskRuntime = config.tasksExtension + ? (config.modernTaskRuntime ??= new ModernTaskRuntime()) + : undefined; + if (config.tasksExtension) { + capabilities.extensions = { + ...(capabilities.extensions ?? {}), + [TASKS_EXTENSION_KEY]: {}, + }; + } + // Create the in-memory task store if tasks are enabled. SDK v2 has no built-in // task runtime, so the store is owned here and its methods answer the wire. const taskStore = @@ -746,9 +784,15 @@ export function createMcpServer(config: ServerConfig): McpServer { // (returning a task handle), reproducing the deleted SDK task runtime. const taskTools = new Map(); - // Set up tools - if (config.tools && config.tools.length > 0) { - for (const tool of config.tools) { + // Set up tools. The modern tasks extension contributes its task-augmented + // tools (registered as ordinary tools so they surface in `tools/list`; their + // `tools/call` is intercepted by `wireModernTaskHandlers` below). + const effectiveTools: (ToolDefinition | TaskToolDefinition)[] = [ + ...(config.tools ?? []), + ...(config.tasksExtension ? createModernTaskTools() : []), + ]; + if (effectiveTools.length > 0) { + for (const tool of effectiveTools) { if (isTaskTool(tool)) { // Register the task tool as an ordinary tool so it surfaces in // `tools/list` with its input schema, `_meta`, and `execution` support. @@ -1254,6 +1298,12 @@ export function createMcpServer(config: ServerConfig): McpServer { wireTaskHandlers(mcpServer, taskStore, taskTools); } + // Modern tasks extension (SEP-2663): raw tasks/get, tasks/update, tasks/cancel + // (no tasks/list, no tasks/result) plus the CreateTaskResult tools/call seam. + if (modernTaskRuntime) { + wireModernTaskHandlers(mcpServer, modernTaskRuntime); + } + return mcpServer; } diff --git a/test-servers/src/load-config.ts b/test-servers/src/load-config.ts index 3f0384f0b..21e9da33c 100644 --- a/test-servers/src/load-config.ts +++ b/test-servers/src/load-config.ts @@ -56,6 +56,10 @@ export interface ConfigFile { list?: boolean; cancel?: boolean; }; + /** Advertise the modern (SEP-2663) `io.modelcontextprotocol/tasks` extension + * and wire its handlers + `modern_task` / `modern_input_task` tools. Pair with + * `transport.modern`. */ + tasksExtension?: boolean; maxPageSize?: { tools?: number; resources?: number; diff --git a/test-servers/src/modern-tasks.ts b/test-servers/src/modern-tasks.ts new file mode 100644 index 000000000..3bb01dc68 --- /dev/null +++ b/test-servers/src/modern-tasks.ts @@ -0,0 +1,408 @@ +/** + * Modern (2026-07-28) Tasks extension server — SEP-2663 + * (`io.modelcontextprotocol/tasks`). + * + * SDK v2 removed all built-in tasks support, and the redesigned extension is a + * different wire shape than the deleted 2025-11-25 runtime (`ttlMs`/`pollIntervalMs`, + * a polymorphic `DetailedTask` inlining `result`/`error`/`inputRequests`, + * `tasks/get` polling, a new `tasks/update`, no `tasks/list`, no blocking + * `tasks/result`). This module wires a minimal, poll-driven modern task runtime + * by hand so an Inspector connecting with **Protocol Era = Modern** can exercise + * the full flow: a task-augmented `tools/call` returns a `CreateTaskResult` + * (`resultType: "task"`); the client polls `tasks/get`; an `input_required` task + * surfaces an embedded elicitation the client answers via `tasks/update`; and + * the completed task inlines its `CallToolResult`. + * + * The runtime is a single shared `Map` so it survives across the stateless + * modern leg's per-request server instances (each `createMcpHandler` request + * builds a fresh `McpServer`, but they share one runtime via the config). + */ + +import * as z from "zod/v4"; +import type { RequestHandler } from "express"; +import type { McpServer } from "@modelcontextprotocol/server"; +import type { ToolDefinition } from "./composable-test-server.js"; + +/** SEP-2133 extension identifier for the redesigned Tasks extension. */ +export const TASKS_EXTENSION_KEY = "io.modelcontextprotocol/tasks"; + +/** Names of the modern task-augmented tools whose `tools/call` returns a task. */ +export const MODERN_TASK_TOOL_NAMES = new Set([ + "modern_task", + "modern_input_task", + // Never completes — stays `input_required` on every poll, so a client keeps + // being re-prompted. Used to exercise the client's input-round cap. + "modern_loop_task", +]); + +const DEFAULT_TTL_MS = 60_000; +const DEFAULT_POLL_INTERVAL_MS = 500; +/** `modern_task` reports `working` for this many `tasks/get` polls before completing. */ +const SIMPLE_WORKING_POLLS = 2; + +type ModernTaskStatus = + "working" | "input_required" | "completed" | "failed" | "cancelled"; + +interface ModernTaskEntry { + taskId: string; + kind: "simple" | "input" | "loop"; + status: ModernTaskStatus; + createdAt: string; + lastUpdatedAt: string; + args: Record; + /** Remaining `working` polls for the simple task before it completes. */ + pollsRemaining: number; + /** Set once the client answered the embedded elicitation via `tasks/update`. */ + inputSatisfied?: boolean; + /** The `inputResponses` the client submitted, echoed back in the result. */ + inputResponses?: Record; +} + +/** The embedded elicitation an `input_required` modern task surfaces. Shaped as + * a standalone `elicitation/create` request so the client's pending-request UI + * (reused from the MRTR path) renders it and returns an `ElicitResult`. */ +function confirmInputRequests(): Record { + return { + confirm: { + method: "elicitation/create", + params: { + message: "Approve this task before it continues?", + requestedSchema: { + type: "object", + properties: { + approved: { + type: "boolean", + title: "Approved", + description: "Whether to proceed with the task", + }, + }, + required: ["approved"], + }, + }, + }, + }; +} + +function nowIso(): string { + return new Date().toISOString(); +} + +function newTaskId(): string { + return ( + globalThis.crypto?.randomUUID?.() ?? + `task-${Date.now()}-${Math.random().toString(36).slice(2, 11)}` + ); +} + +/** + * Shared, in-memory modern task runtime. One instance is created per modern + * server config and reused across the stateless leg's per-request servers, so a + * task created by one `tools/call` request is visible to a later `tasks/get`. + */ +export class ModernTaskRuntime { + private tasks = new Map(); + + /** Handle a task-augmented `tools/call`: durably create the task and return a + * `CreateTaskResult` (`resultType: "task"`) seed. */ + createTask( + toolName: string, + args: Record, + ): Record { + const now = nowIso(); + const kind: ModernTaskEntry["kind"] = + toolName === "modern_input_task" + ? "input" + : toolName === "modern_loop_task" + ? "loop" + : "simple"; + const entry: ModernTaskEntry = { + taskId: newTaskId(), + kind, + status: "working", + createdAt: now, + lastUpdatedAt: now, + args, + pollsRemaining: SIMPLE_WORKING_POLLS, + }; + this.tasks.set(entry.taskId, entry); + return { + resultType: "task", + ...this.project(entry), + statusMessage: "The operation is now in progress.", + }; + } + + /** Serve `tasks/get`: advance the scripted lifecycle and return a + * `DetailedTask` (`resultType: "complete"`). Throws on an unknown id. */ + getTask(taskId: string): Record { + const entry = this.requireTask(taskId); + this.advance(entry); + return { resultType: "complete", ...this.project(entry) }; + } + + /** Serve `tasks/update`: record the answered `inputResponses` and ack empty. */ + updateTask( + taskId: string, + inputResponses: Record | undefined, + ): Record { + const entry = this.requireTask(taskId); + entry.inputSatisfied = true; + if (inputResponses) entry.inputResponses = inputResponses; + this.touch(entry); + return { resultType: "complete" }; + } + + /** Serve `tasks/cancel`: move the task to `cancelled` and ack empty. */ + cancelTask(taskId: string): Record { + const entry = this.requireTask(taskId); + if ( + entry.status !== "completed" && + entry.status !== "failed" && + entry.status !== "cancelled" + ) { + entry.status = "cancelled"; + this.touch(entry); + } + return { resultType: "complete" }; + } + + private requireTask(taskId: string): ModernTaskEntry { + const entry = this.tasks.get(taskId); + if (!entry) throw new Error(`Unknown taskId: ${taskId}`); + return entry; + } + + private touch(entry: ModernTaskEntry): void { + entry.lastUpdatedAt = nowIso(); + } + + /** Move a task one step along its scripted lifecycle on each poll. */ + private advance(entry: ModernTaskEntry): void { + if ( + entry.status === "completed" || + entry.status === "failed" || + entry.status === "cancelled" + ) { + return; + } + if (entry.kind === "simple") { + if (entry.pollsRemaining > 0) { + entry.pollsRemaining -= 1; + } else { + entry.status = "completed"; + } + } else if (entry.kind === "loop") { + // Never advances: stays input_required every poll (ignores tasks/update), + // so a client is re-prompted indefinitely — exercises its round cap. + entry.status = "input_required"; + } else { + // input task: request input, then complete once the client has answered. + entry.status = entry.inputSatisfied ? "completed" : "input_required"; + } + this.touch(entry); + } + + /** Project the internal entry onto the wire `DetailedTask`, inlining the + * status-specific member (`result` / `inputRequests`). */ + private project(entry: ModernTaskEntry): Record { + const base: Record = { + taskId: entry.taskId, + status: entry.status, + createdAt: entry.createdAt, + lastUpdatedAt: entry.lastUpdatedAt, + ttlMs: DEFAULT_TTL_MS, + }; + // The simple task advertises a poll interval; the input task omits it so a + // client falls back to its own default cadence (both paths exercised). + if (entry.kind === "simple") { + base.pollIntervalMs = DEFAULT_POLL_INTERVAL_MS; + } + if (entry.status === "input_required") { + base.inputRequests = confirmInputRequests(); + } + if (entry.status === "completed") { + base.result = this.completedResult(entry); + } + return base; + } + + /** The `CallToolResult` a completed task inlines (SEP-2663: no `tasks/result`). */ + private completedResult(entry: ModernTaskEntry): Record { + const detail = + entry.kind === "input" + ? `input=${JSON.stringify(entry.inputResponses ?? {})}` + : `args=${JSON.stringify(entry.args ?? {})}`; + return { + content: [ + { + type: "text", + text: `Modern task ${entry.taskId} completed (${detail}).`, + }, + ], + isError: false, + }; + } +} + +/** The two plain tools whose `tools/call` the modern task handlers intercept. + * Their handlers are placeholders — {@link wireModernTaskHandlers} routes these + * names to the runtime before the SDK handler runs. */ +export function createModernTaskTools(): ToolDefinition[] { + const placeholder: ToolDefinition["handler"] = async () => ({ + content: [ + { type: "text" as const, text: "Task tool must be invoked as a task." }, + ], + isError: true, + }); + return [ + { + name: "modern_task", + description: + "Create a modern (SEP-2663) task that reports progress over a few polls then completes.", + inputSchema: { + message: z + .string() + .optional() + .describe("Text echoed back in the completed task result."), + }, + handler: placeholder, + }, + { + name: "modern_input_task", + description: + "Create a modern task that pauses at input_required and completes after tasks/update.", + inputSchema: {}, + handler: placeholder, + }, + { + name: "modern_loop_task", + description: + "Create a modern task that never completes — stays input_required every poll, so the client's round cap trips.", + inputSchema: {}, + handler: placeholder, + }, + ]; +} + +interface RawHandlerHost { + _requestHandlers: Map< + string, + (request: unknown, ctx: unknown) => Promise + >; +} + +interface ToolsCallRequest { + params: { name: string; arguments?: Record }; +} + +interface TaskMethodRequest { + params: { + taskId: string; + inputResponses?: Record; + }; +} + +/** + * Wire the modern task methods by hand onto an `McpServer`: + * - override `tools/call` so a modern task tool returns a `CreateTaskResult` + * (`resultType: "task"`) and durably creates the task; ordinary tools fall + * through to the SDK handler; + * - register raw `tasks/get` / `tasks/update` / `tasks/cancel` handlers backed + * by the shared runtime. There is deliberately **no** `tasks/list` and **no** + * `tasks/result` (SEP-2663 removed both). + * + * The `tools/call` override is installed into the private handler registry (not + * via `setRequestHandler`) so the `CreateTaskResult` skips the `Server`'s + * `tools/call` result-schema validation — the same seam the legacy task server + * uses. This goes away when the SDK models the tasks extension natively. + */ +export function wireModernTaskHandlers( + mcpServer: McpServer, + runtime: ModernTaskRuntime, +): void { + const lowLevel = mcpServer.server; + const registry = (lowLevel as unknown as RawHandlerHost)._requestHandlers; + const sdkToolsCall = registry.get("tools/call"); + + registry.set("tools/call", async (request, ctx) => { + const req = request as ToolsCallRequest; + if (MODERN_TASK_TOOL_NAMES.has(req.params.name)) { + return runtime.createTask(req.params.name, req.params.arguments ?? {}); + } + if (!sdkToolsCall) { + throw new Error("tools/call handler is not initialized"); + } + return sdkToolsCall(request, ctx); + }); + + registry.set("tasks/get", async (request) => { + const req = request as TaskMethodRequest; + return runtime.getTask(req.params.taskId); + }); + + registry.set("tasks/update", async (request) => { + const req = request as TaskMethodRequest; + return runtime.updateTask(req.params.taskId, req.params.inputResponses); + }); + + registry.set("tasks/cancel", async (request) => { + const req = request as TaskMethodRequest; + return runtime.cancelTask(req.params.taskId); + }); +} + +const MODERN_TASK_METHODS = new Set([ + "tasks/get", + "tasks/update", + "tasks/cancel", +]); + +interface TaskRpcBody { + jsonrpc?: string; + id?: unknown; + method?: string; + params?: { taskId?: string; inputResponses?: Record }; +} + +/** + * Express middleware that answers modern `tasks/get` / `tasks/update` / + * `tasks/cancel` POSTs DIRECTLY, before the SDK's `createMcpHandler` sees them. + * SDK v2's modern leg era-gates inbound spec methods, so `tasks/*` (spec-method + * names removed from the 2026-07-28 era) would be answered `-32601 Method not + * found` by the handler — a conformant server can't serve the extension through + * the SDK. Intercepting at the HTTP layer is how the test server serves the + * extension anyway (mirrors the `specErrorInjector` seam). `tasks` task + * creation still flows through `tools/call` (a modern method) inside the handler. + */ +export function createModernTaskInterceptor( + getRuntime: () => ModernTaskRuntime, +): RequestHandler { + return (req, res, next) => { + const body = req.body as TaskRpcBody | undefined; + const method = body?.method; + if (!method || !MODERN_TASK_METHODS.has(method)) { + next(); + return; + } + const runtime = getRuntime(); + const id = body?.id ?? null; + const taskId = body?.params?.taskId ?? ""; + try { + let result: Record; + if (method === "tasks/get") { + result = runtime.getTask(taskId); + } else if (method === "tasks/update") { + result = runtime.updateTask(taskId, body?.params?.inputResponses); + } else { + result = runtime.cancelTask(taskId); + } + res.json({ jsonrpc: "2.0", id, result }); + } catch (err) { + res.json({ + jsonrpc: "2.0", + id, + error: { code: -32602, message: (err as Error).message }, + }); + } + }; +} diff --git a/test-servers/src/resolve-config.ts b/test-servers/src/resolve-config.ts index 06b6f7a84..6a7f0cb7d 100644 --- a/test-servers/src/resolve-config.ts +++ b/test-servers/src/resolve-config.ts @@ -89,6 +89,7 @@ export function resolveConfig(config: ConfigFile): ServerConfig { listChanged: config.listChanged, subscriptions: config.subscriptions, tasks: config.tasks, + tasksExtension: config.tasksExtension, maxPageSize: config.maxPageSize, serverType: isHttp ? (transport.type as "sse" | "streamable-http") diff --git a/test-servers/src/test-server-http.ts b/test-servers/src/test-server-http.ts index a8cc73d67..6776ae01a 100644 --- a/test-servers/src/test-server-http.ts +++ b/test-servers/src/test-server-http.ts @@ -8,6 +8,10 @@ import type { McpHttpHandler, } from "@modelcontextprotocol/server"; import { createMcpServer } from "./test-server-fixtures.js"; +import { + ModernTaskRuntime, + createModernTaskInterceptor, +} from "./modern-tasks.js"; import { SSEServerTransport } from "@modelcontextprotocol/server-legacy/sse"; import type { Request, Response } from "express"; import express from "express"; @@ -365,6 +369,19 @@ export class TestServerHttp { ? [specErrorInjector] : []; + // Modern tasks extension (SEP-2663): the SDK's modern leg era-gates inbound + // `tasks/*` spec methods, so serve them from a middleware ahead of the SDK + // handler, backed by the shared runtime (also used by the tools/call task + // seam inside `createMcpServer`). + if (this.config.tasksExtension) { + this.configWithCallback.modernTaskRuntime ??= new ModernTaskRuntime(); + extraMiddleware.push( + createModernTaskInterceptor( + () => this.configWithCallback.modernTaskRuntime!, + ), + ); + } + app.post("/mcp", ...mcpMiddleware, ...extraMiddleware, route); app.get("/mcp", ...mcpMiddleware, route); app.delete("/mcp", ...mcpMiddleware, route);