Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 6 additions & 1 deletion AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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`.
Expand Down
2 changes: 2 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
57 changes: 57 additions & 0 deletions clients/web/src/App.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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(<App />);
await user.click(screen.getByText("connect"));
await waitFor(() => expect(clientInstances).toHaveLength(1));

const client = clientInstances[0] as unknown as {
cancelRequestorTask: ReturnType<typeof vi.fn>;
};

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(<App />);
Expand Down
33 changes: 29 additions & 4 deletions clients/web/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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");
Expand Down Expand Up @@ -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 () => {
Expand Down Expand Up @@ -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) => {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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" },
};
Original file line number Diff line number Diff line change
Expand Up @@ -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(<MrtrOriginNote origin="task-input-required" />);
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(<MrtrOriginNote origin="server-request" />);
expect(screen.queryByText("input_required")).not.toBeInTheDocument();
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
Expand All @@ -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<Record<PendingRequestOrigin, string>> = {
"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 (
<NoteRow>
<Badge variant="outline" color="blue">
input_required
</Badge>
<NoteText>
The server returned input_required; your answer is sent back as a retry
of the original request (MRTR).
</NoteText>
<NoteText>{note}</NoteText>
</NoteRow>
);
}
Original file line number Diff line number Diff line change
Expand Up @@ -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 },
};
19 changes: 19 additions & 0 deletions clients/web/src/components/groups/TaskCard/TaskCard.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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(
<TaskCard
task={workingTask}
isListExpanded={false}
onCancel={vi.fn()}
embedded
/>,
);
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(
<TaskCard task={workingTask} isListExpanded={false} onCancel={vi.fn()} />,
Expand Down
21 changes: 20 additions & 1 deletion clients/web/src/components/groups/TaskCard/TaskCard.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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({
Expand Down Expand Up @@ -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({
Expand All @@ -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);
Expand All @@ -115,7 +126,11 @@ export function TaskCard({
<HeaderRow>
<HeaderLeftGroup>
<SectionTitle>Task Details</SectionTitle>
<TaskIdText>{formatTaskId(taskId)}</TaskIdText>
{/* 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 && <TaskIdText>{formatTaskId(taskId)}</TaskIdText>}
</HeaderLeftGroup>
<HeaderRightGroup>
{isActive && (
Expand All @@ -125,6 +140,10 @@ export function TaskCard({
</HeaderRightGroup>
</HeaderRow>

{embedded && (
<TaskIdText truncate="end">{formatTaskId(taskId)}</TaskIdText>
)}

<SummaryRow>
<DetailRow>
<Stack gap={2}>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -135,6 +135,7 @@ export function TaskListPanel({
progress={progressByTaskId?.[task.taskId]}
isListExpanded={!compact}
onCancel={() => onCancel(task.taskId)}
embedded={embedded}
/>
))}
</>
Expand All @@ -157,6 +158,7 @@ export function TaskListPanel({
progress={progressByTaskId?.[task.taskId]}
isListExpanded={!compact}
onCancel={() => onCancel(task.taskId)}
embedded={embedded}
/>
))}
</>
Expand Down
Loading
Loading