From e9766dce6fad364c0ba33ac87a0df3f5274222f6 Mon Sep 17 00:00:00 2001 From: cliffhall Date: Mon, 5 Oct 2026 01:25:42 -0400 Subject: [PATCH 1/5] feat(tui): add Tasks and Subscriptions tabs and a Roots editor (#2432) The TUI had no surface for resource subscriptions, Roots or Tasks, so a terminal-only workflow had to drop into the web client for them. - Subscriptions tab (u): shown when the server advertises resources.subscribe. Enter toggles subscribe/unsubscribe through InspectorClient (era-aware), the detail pane shows last-updated, the modern listen-stream status and a live resources/updated feed read from the protocol log. - Tasks tab (s): shown when the server supports Tasks (legacy capability or the SEP-2663 extension). Lists requestor tasks from ManagedRequestorTasksState; Enter fetches a result, x cancels, f refreshes, l clears finished. s is suppressed while the Auth pane is focused, where it clears OAuth state. - Roots: the Info tab lists advertised roots and e opens a modal editor (add/remove) that applies changes via setRoots, so the server gets roots/list_changed. Session-only; the catalog is not rewritten. All state comes from existing core stores and hooks (ManagedRequestorTasksState, ResourceSubscriptionsState, useStoreSnapshot); no new core machinery. Co-Authored-By: Claude Opus 5.5 Signed-off-by: cliffhall --- clients/tui/README.md | 3 + clients/tui/__tests__/App.test.tsx | 187 ++++++++++ clients/tui/__tests__/InfoTab.test.tsx | 68 ++++ clients/tui/__tests__/RootsModal.test.tsx | 205 ++++++++++ .../tui/__tests__/SubscriptionsTab.test.tsx | 256 +++++++++++++ clients/tui/__tests__/Tabs.test.tsx | 20 + clients/tui/__tests__/TasksTab.test.tsx | 322 ++++++++++++++++ clients/tui/__tests__/resourceUpdates.test.ts | 92 +++++ clients/tui/__tests__/tabsConfig.test.ts | 4 + clients/tui/src/App.tsx | 235 +++++++++++- clients/tui/src/components/InfoTab.tsx | 33 +- clients/tui/src/components/RootsModal.tsx | 229 ++++++++++++ .../tui/src/components/SubscriptionsTab.tsx | 325 ++++++++++++++++ clients/tui/src/components/Tabs.tsx | 10 + clients/tui/src/components/TasksTab.tsx | 349 ++++++++++++++++++ clients/tui/src/components/tabsConfig.ts | 21 ++ clients/tui/src/utils/resourceUpdates.ts | 80 ++++ 17 files changed, 2432 insertions(+), 7 deletions(-) create mode 100644 clients/tui/__tests__/RootsModal.test.tsx create mode 100644 clients/tui/__tests__/SubscriptionsTab.test.tsx create mode 100644 clients/tui/__tests__/TasksTab.test.tsx create mode 100644 clients/tui/__tests__/resourceUpdates.test.ts create mode 100644 clients/tui/src/components/RootsModal.tsx create mode 100644 clients/tui/src/components/SubscriptionsTab.tsx create mode 100644 clients/tui/src/components/TasksTab.tsx create mode 100644 clients/tui/src/utils/resourceUpdates.ts diff --git a/clients/tui/README.md b/clients/tui/README.md index 3927875b13..2be63b59ad 100644 --- a/clients/tui/README.md +++ b/clients/tui/README.md @@ -77,11 +77,14 @@ See also [EMA / enterprise-managed auth](../../specification/v2_auth_ema.md) and The TUI provides terminal-native tabs and panes for interacting with your MCP server: +- **Info**: The server's configuration and identity, plus the **Roots** the client advertises to it. With the pane focused, `e` opens the roots editor (`n` add, `x` remove), which applies changes through `setRoots` and so sends `notifications/roots/list_changed`. Edits last for the session only; the TUI does not write them back to the catalog ([#2432](https://github.com/modelcontextprotocol/inspector/issues/2432)). - **Resources**: Browse and read resources exposed by the server. +- **Subscriptions** (`u`): Shown only when the server advertises `resources.subscribe`. **Enter** subscribes to or unsubscribes from the selected resource (legacy `resources/subscribe`, or the modern `subscriptions/listen` filter, chosen by era), and the detail pane shows each subscription's last update, the modern listen-stream status, and a live feed of `notifications/resources/updated` read from the protocol log ([#2432](https://github.com/modelcontextprotocol/inspector/issues/2432)). - **Prompts**: List and test prompts. - **Tools**: View available tools and execute them with form-like inputs. A tool whose advertised schema carries a portability problem is flagged in the list — red `!` for a construct a shipping MCP client refuses, yellow `?` for one handled unevenly — and the detail pane lists each finding under **Schema Portability** with the path, the problem, and a concrete fix. The verdict comes from [`core/json/schemaLint.ts`](../../core/json/schemaLint.ts), shared with the web Tools tab and the CLI's `--strict` report, so the three cannot disagree ([#1005](https://github.com/modelcontextprotocol/inspector/issues/1005)). - **Saving a result**: on the tool result view, press **`w`** to save the result to a file. The prompt opens on `-result.json` (relative paths resolve against the directory the TUI was launched from); **Tab** switches between `json` — the whole result, pretty-printed — and `raw` — the text of its text blocks, or the decoded bytes of its single binary block — rendered by the shared [`core/mcp/resultFile.ts`](../../core/mcp/resultFile.ts), whose encodings match the CLI's planned `--output` ([#2431](https://github.com/modelcontextprotocol/inspector/issues/2431)). **Enter** writes it and confirms the path; **Escape** cancels. A failed write is reported on the result view ([#2571](https://github.com/modelcontextprotocol/inspector/issues/2571)). - **Skills**: Shown only when the connected server declares the SEP-2640 Skills extension (`io.modelcontextprotocol/skills`), since it is a *server* declaration and so only knowable after connecting. The list marks each skill with its structural verdict — `✓` conforms, `!` warnings only, `✗` an error — using a glyph as well as a colour, because this pane is read over ssh, in tmux and through `script(1)`. The detail pane shows the entry's URI, description, conformance findings and manifest. **Enter** verifies the selected skill: one `resources/read` per manifest file, each hashed against its advertised digest, plus the frontmatter cross-check that compares the served `SKILL.md`'s own frontmatter against the one the listing advertised. Verification is a gesture rather than a page load because SEP-2640 says hosts MUST NOT retrieve a skill's files ahead of need. The checks are the same ones the web Skills tab and the CLI's `--verify` run ([#2234](https://github.com/modelcontextprotocol/inspector/issues/2234), [#2248](https://github.com/modelcontextprotocol/inspector/issues/2248)). +- **Tasks** (`s`): Shown only when the server supports Tasks (legacy `capabilities.tasks` or the negotiated SEP-2663 extension). Lists the tasks this client created, with live status. **Enter** fetches a completed or failed task's result, `x` cancels a running one, `f` refreshes and `l` clears finished tasks. `s` does not switch here while the Auth pane is focused, where it clears OAuth state ([#2432](https://github.com/modelcontextprotocol/inspector/issues/2432)). - **Protocol**: View JSON-RPC request/response/notification history (matches the web Protocol monitor). - **Network**: View HTTP fetch traffic for SSE / Streamable HTTP servers (matches the web Network monitor). - **Console**: View stdio stderr from the connected server process (matches the web Console monitor). diff --git a/clients/tui/__tests__/App.test.tsx b/clients/tui/__tests__/App.test.tsx index 83346902c9..021d144dc0 100644 --- a/clients/tui/__tests__/App.test.tsx +++ b/clients/tui/__tests__/App.test.tsx @@ -39,6 +39,10 @@ const h = vi.hoisted(() => { messages: unknown[]; fetchRequests: unknown[]; stderrLogs: unknown[]; + tasks: unknown[]; + subscriptions: unknown[]; + roots: unknown[]; + tasksExtension: boolean; } const ctrl: Ctrl = { status: "disconnected", @@ -56,7 +60,13 @@ const h = vi.hoisted(() => { messages: [], fetchRequests: [], stderrLogs: [], + tasks: [], + subscriptions: [], + roots: [], + tasksExtension: false, }; + const refreshTasks = vi.fn(async () => []); + const clearCompletedTasks = vi.fn(); const connect = vi.fn().mockResolvedValue(undefined); const disconnect = vi.fn().mockResolvedValue(undefined); const openUrl = vi.fn().mockResolvedValue(undefined); @@ -181,6 +191,13 @@ const h = vi.hoisted(() => { // "not declared" — the tab is hidden unless a test opts in by pointing // `ctrl.skillsExtension` at a declaration. getSkillsExtension = vi.fn(() => ctrl.skillsExtension); + isTasksExtensionNegotiated = vi.fn(() => ctrl.tasksExtension); + getRoots = vi.fn(() => ctrl.roots); + setRoots = vi.fn(async () => {}); + subscribeToResource = vi.fn(async () => {}); + unsubscribeFromResource = vi.fn(async () => {}); + cancelRequestorTask = vi.fn(async () => {}); + getRequestorTaskResult = vi.fn(async () => ({ content: [] })); authenticate = (...a: Parameters) => clientSpies.authenticate(...a); clearOAuthTokens = ( @@ -262,6 +279,20 @@ const h = vi.hoisted(() => { useMessageLog: vi.fn(() => ({ messages: ctrl.messages })), useFetchRequestLog: vi.fn(() => ({ fetchRequests: ctrl.fetchRequests })), useStderrLog: vi.fn(() => ({ stderrLogs: ctrl.stderrLogs })), + refreshTasks, + clearCompletedTasks, + useManagedRequestorTasks: vi.fn(() => ({ + tasks: ctrl.tasks, + refresh: refreshTasks, + clearCompleted: clearCompletedTasks, + })), + useResourceSubscriptions: vi.fn(() => ({ + subscriptions: ctrl.subscriptions, + streamState: { active: false, status: "ended", honoredUris: [] }, + })), + // Roots are read with a direct store snapshot; the fake client is not a + // real TypedEventTarget, so the snapshot is driven from `ctrl.roots`. + useStoreSnapshot: vi.fn(() => ctrl.roots), }; }); @@ -277,6 +308,8 @@ vi.mock("@inspector/core/mcp/state/index.js", () => ({ MessageLogState: h.FakeManager, FetchRequestLogState: h.FakeManager, StderrLogState: h.FakeManager, + ManagedRequestorTasksState: h.FakeManager, + ResourceSubscriptionsState: h.FakeManager, })); vi.mock("@inspector/core/mcp/node/index.js", () => ({ createTransportNode: vi.fn(), @@ -314,6 +347,15 @@ vi.mock("@inspector/core/react/useFetchRequestLog.js", () => ({ vi.mock("@inspector/core/react/useStderrLog.js", () => ({ useStderrLog: h.useStderrLog, })); +vi.mock("@inspector/core/react/useManagedRequestorTasks.js", () => ({ + useManagedRequestorTasks: h.useManagedRequestorTasks, +})); +vi.mock("@inspector/core/react/useResourceSubscriptions.js", () => ({ + useResourceSubscriptions: h.useResourceSubscriptions, +})); +vi.mock("@inspector/core/react/useStoreSnapshot.js", () => ({ + useStoreSnapshot: h.useStoreSnapshot, +})); vi.mock("@inspector/core/auth/index.js", async (importOriginal) => { const actual = await importOriginal(); @@ -704,7 +746,13 @@ beforeEach(() => { messages: [], fetchRequests: [], stderrLogs: [], + tasks: [], + subscriptions: [], + roots: [], + tasksExtension: false, }); + h.refreshTasks.mockClear(); + h.clearCompletedTasks.mockClear(); h.connect.mockClear(); h.connect.mockResolvedValue(undefined); h.disconnect.mockClear(); @@ -2201,3 +2249,142 @@ describe("App (keybinding help, #2436)", () => { expect(r.lastFrame() ?? "").not.toContain("Keyboard shortcuts"); }); }); + +describe("App (tasks, subscriptions, roots — #2432)", () => { + const task = { + taskId: "task-1", + status: "completed", + createdAt: "2026-01-01T00:00:00Z", + lastUpdatedAt: "2026-01-01T00:00:01Z", + ttl: null, + }; + + it("hides the Subscriptions and Tasks tabs for a server that serves neither", async () => { + h.ctrl.status = "connected"; + h.ctrl.capabilities = { resources: {} }; + const r = await mount(oneStdio()); + await expectFrame(r, "Tools"); + const frame = r.lastFrame() ?? ""; + expect(frame).not.toContain("Subscriptions"); + expect(frame).not.toContain("Tasks"); + }); + + it("opens the Subscriptions tab with 'u' when the server can subscribe", async () => { + h.ctrl.status = "connected"; + h.ctrl.capabilities = { resources: { subscribe: true } }; + h.ctrl.resources = [{ uri: "file:///a", name: "a" }]; + h.ctrl.subscriptions = [{ resource: { uri: "file:///a", name: "a" } }]; + const r = await mount(oneStdio()); + await expectFrame(r, "Subscriptions (1)"); + await press(r, ["u", TAB, ENTER]); + await expectFrame(r, "Subscriptions (1/1)"); + const client = h.clientInstances[0] as unknown as { + unsubscribeFromResource: ReturnType; + }; + await waitUntil(() => client.unsubscribeFromResource.mock.calls.length > 0); + expect(client.unsubscribeFromResource).toHaveBeenCalledWith("file:///a"); + }); + + it("opens the Tasks tab with 's' for a legacy tasks server and zooms a task", async () => { + h.ctrl.status = "connected"; + h.ctrl.capabilities = { tasks: {} }; + h.ctrl.tasks = [task]; + const r = await mount(oneStdio()); + await expectFrame(r, "Tasks (1)"); + await press(r, ["s", TAB]); + await expectFrame(r, "task-1"); + // Fetch the result, then zoom the details pane into the modal. + await press(r, [ENTER, TAB]); + const client = h.clientInstances[0] as unknown as { + getRequestorTaskResult: ReturnType; + }; + await waitUntil(() => client.getRequestorTaskResult.mock.calls.length > 0); + await press(r, ["+"]); + await press(r, ["f", "l"]); + expect(h.refreshTasks).not.toHaveBeenCalled(); + }); + + it("zooms a task with no fetched result", async () => { + h.ctrl.status = "connected"; + h.ctrl.tasksExtension = true; + h.ctrl.tasks = [{ ...task, status: "working" }]; + const r = await mount(oneStdio()); + await press(r, ["s", TAB, TAB, "+"]); + // The details modal swallows App input, so 's' no longer switches tabs. + await press(r, [ESC]); + await expectFrame(r, "task-1"); + }); + + it("refreshes and clears tasks through the store hook", async () => { + h.ctrl.status = "connected"; + h.ctrl.tasksExtension = true; + h.ctrl.tasks = [task]; + const r = await mount(oneStdio()); + await press(r, ["s", TAB, "f", "l"]); + await waitUntil(() => h.refreshTasks.mock.calls.length > 0); + expect(h.refreshTasks).toHaveBeenCalled(); + expect(h.clearCompletedTasks).toHaveBeenCalled(); + }); + + it("walks the new tabs with the arrow keys", async () => { + h.ctrl.status = "connected"; + h.ctrl.capabilities = { resources: { subscribe: true }, tasks: {} }; + const r = await mount(oneStdio()); + // info -> resources -> subscriptions + await press(r, ["i", RIGHT, RIGHT]); + await expectFrame(r, "Subscriptions (0/0)"); + // tools -> tasks + await press(r, ["t", RIGHT]); + await expectFrame(r, "No tasks yet"); + }); + + it("does not jump to Tasks on 's' while the Auth pane is focused", async () => { + h.ctrl.status = "connected"; + h.ctrl.capabilities = { tasks: {} }; + const r = await mount(oneHttp()); + await press(r, ["a"]); + await expectFrame(r, "Auth"); + await press(r, ["s"]); + expect(r.lastFrame() ?? "").not.toContain("No tasks yet"); + // From the tab bar, the accelerator works as usual. + await press(r, [STAB, "s"]); + await expectFrame(r, "No tasks yet"); + }); + + it("leaves the Subscriptions and Tasks tabs when the server stops serving them", async () => { + h.ctrl.status = "connected"; + h.ctrl.capabilities = { resources: { subscribe: true }, tasks: {} }; + const r = await mount(oneStdio()); + await press(r, ["s"]); + await expectFrame(r, "No tasks yet"); + h.ctrl.capabilities = {}; + r.rerender( + , + ); + await expectFrame(r, "Server Configuration"); + expect(r.lastFrame() ?? "").not.toContain("No tasks yet"); + }); + + it("lists roots on the Info tab and edits them in the roots modal", async () => { + h.ctrl.status = "connected"; + h.ctrl.roots = [{ uri: "file:///work", name: "work" }]; + const r = await mount(oneStdio()); + // The 24-row test terminal shows the section heading; the entries below it + // are InfoTab's to render (covered in InfoTab.test.tsx). + await expectFrame(r, "Roots (1)"); + await press(r, ["i", TAB, "e", "x"]); + const client = h.clientInstances[0] as unknown as { + setRoots: ReturnType; + }; + await waitUntil(() => client.setRoots.mock.calls.length > 0); + expect(client.setRoots).toHaveBeenCalledWith([]); + // Esc closes the modal rather than exiting the app, and `e` reopens it. + await press(r, [ESC, "e", "x"]); + await waitUntil(() => client.setRoots.mock.calls.length > 1); + expect(client.setRoots).toHaveBeenCalledTimes(2); + }); +}); diff --git a/clients/tui/__tests__/InfoTab.test.tsx b/clients/tui/__tests__/InfoTab.test.tsx index 099eee47ef..ac0144402f 100644 --- a/clients/tui/__tests__/InfoTab.test.tsx +++ b/clients/tui/__tests__/InfoTab.test.tsx @@ -396,4 +396,72 @@ describe("InfoTab", () => { ); expect(lastFrame() ?? "").not.toContain("to scroll"); }); + + describe("roots (#2432)", () => { + it("lists the advertised roots, with and without a name", () => { + const { lastFrame } = render( + , + ); + const frame = lastFrame() ?? ""; + expect(frame).toContain("Roots (2)"); + expect(frame).toContain("file:///a (alpha)"); + expect(frame).toContain("file:///b"); + }); + + it("says None when no roots are advertised", () => { + const { lastFrame } = render( + , + ); + expect(lastFrame() ?? "").toContain("Roots (0)"); + expect(lastFrame() ?? "").toContain("None"); + }); + + it("opens the roots editor on 'e' when focused", async () => { + const onEditRoots = vi.fn(); + const { stdin, lastFrame } = render( + , + ); + expect(lastFrame() ?? "").toContain("e to edit roots"); + stdin.write("e"); + await tick(); + expect(onEditRoots).toHaveBeenCalledTimes(1); + }); + + it("ignores 'e' when no editor is wired", async () => { + const { stdin, lastFrame } = render( + , + ); + stdin.write("e"); + await tick(); + expect(lastFrame() ?? "").not.toContain("e to edit roots"); + }); + }); }); diff --git a/clients/tui/__tests__/RootsModal.test.tsx b/clients/tui/__tests__/RootsModal.test.tsx new file mode 100644 index 0000000000..510074eeb3 --- /dev/null +++ b/clients/tui/__tests__/RootsModal.test.tsx @@ -0,0 +1,205 @@ +import React from "react"; +import { describe, it, expect, vi, afterEach } from "vitest"; +import { render } from "./helpers/renderTui"; +import type { Root } from "@modelcontextprotocol/client"; +import type { InspectorClient } from "@inspector/core/mcp/index.js"; + +vi.mock("ink-form", () => import("./helpers/inkFormMock.js")); + +import { RootsModal, rootFromForm } from "../src/components/RootsModal.js"; + +// The modal renders position="absolute", which ink-testing-library draws as an +// EMPTY frame (see ResourceTestModal.test.tsx), so these tests assert on the +// client fake and the callbacks rather than on lastFrame(). + +const tick = async () => { + for (let i = 0; i < 8; i++) + await new Promise((resolve) => setTimeout(resolve, 4)); +}; +const ESC = String.fromCharCode(27); +const UP = `${ESC}[A`; +const DOWN = `${ESC}[B`; +const DELETE = `${ESC}[3~`; + +const setSubmitValue = (value: Record) => { + (globalThis as Record).__INK_FORM_SUBMIT_VALUE__ = value; +}; + +afterEach(() => { + delete (globalThis as Record).__INK_FORM_SUBMIT_VALUE__; +}); + +const fakeClient = ( + setRoots: ReturnType = vi.fn(async () => {}), +) => + ({ setRoots }) as unknown as InspectorClient & { setRoots: typeof setRoots }; + +const roots: Root[] = [{ uri: "file:///a", name: "a" }, { uri: "file:///b" }]; + +function renderModal(props: Partial>) { + const onClose = vi.fn(); + const client = fakeClient(); + const api = render( + , + ); + return { ...api, onClose, client }; +} + +describe("rootFromForm", () => { + it("trims, requires a URI and drops an empty name", () => { + expect(rootFromForm({ uri: " file:///x ", name: " x " })).toEqual({ + uri: "file:///x", + name: "x", + }); + expect(rootFromForm({ uri: "file:///x", name: " " })).toEqual({ + uri: "file:///x", + }); + expect(rootFromForm({ uri: "file:///x" })).toEqual({ uri: "file:///x" }); + expect(rootFromForm({ uri: " " })).toEqual({ error: "A root needs a URI" }); + expect(rootFromForm({})).toEqual({ error: "A root needs a URI" }); + }); +}); + +describe("RootsModal", () => { + it("removes the selected root with 'x' after moving the selection", async () => { + const { stdin, client } = renderModal({}); + await tick(); + stdin.write(DOWN); + await tick(); + stdin.write(DOWN); // at the end + await tick(); + stdin.write(UP); + await tick(); + stdin.write(UP); // at the top + await tick(); + stdin.write(DOWN); + await tick(); + stdin.write("x"); + await tick(); + expect(client.setRoots).toHaveBeenCalledWith([ + { uri: "file:///a", name: "a" }, + ]); + }); + + it("removes with the Delete key too", async () => { + const { stdin, client } = renderModal({}); + await tick(); + stdin.write(DELETE); + await tick(); + expect(client.setRoots).toHaveBeenCalledWith([{ uri: "file:///b" }]); + }); + + it("adds a root through the form", async () => { + const { stdin, client } = renderModal({}); + await tick(); + stdin.write("n"); + await tick(); + setSubmitValue({ uri: "file:///c", name: "c" }); + stdin.write("\r"); + await tick(); + expect(client.setRoots).toHaveBeenCalledWith([ + ...roots, + { uri: "file:///c", name: "c" }, + ]); + }); + + it("refuses a root without a URI and returns to the list", async () => { + const { stdin, client, onClose } = renderModal({}); + await tick(); + stdin.write("+"); + await tick(); + setSubmitValue({ uri: "" }); + stdin.write("\r"); + await tick(); + expect(client.setRoots).not.toHaveBeenCalled(); + // Back on the list, Esc closes. + stdin.write(ESC); + await tick(); + expect(onClose).toHaveBeenCalledTimes(1); + }); + + it("backs out of the form with Esc before closing", async () => { + const { stdin, onClose } = renderModal({}); + await tick(); + stdin.write("n"); + await tick(); + stdin.write("x"); // the form owns this key, so nothing is removed + await tick(); + stdin.write(ESC); + await tick(); + expect(onClose).not.toHaveBeenCalled(); + stdin.write(ESC); + await tick(); + expect(onClose).toHaveBeenCalledTimes(1); + }); + + it("keeps the form open and reports a failed save", async () => { + const setRoots = vi.fn(async () => { + throw new Error("Client is not connected"); + }); + const { stdin } = renderModal({ inspectorClient: fakeClient(setRoots) }); + await tick(); + stdin.write("x"); + await tick(); + expect(setRoots).toHaveBeenCalledTimes(1); + }); + + it("reports a non-Error failure", async () => { + const setRoots = vi.fn(() => Promise.reject("nope")); + const { stdin } = renderModal({ inspectorClient: fakeClient(setRoots) }); + await tick(); + stdin.write("x"); + await tick(); + expect(setRoots).toHaveBeenCalledTimes(1); + }); + + it("ignores keys while a save is in flight", async () => { + let release: () => void = () => {}; + const setRoots = vi.fn( + () => + new Promise((resolve) => { + release = resolve; + }), + ); + const { stdin } = renderModal({ inspectorClient: fakeClient(setRoots) }); + await tick(); + stdin.write("x"); + await tick(); + stdin.write("x"); + await tick(); + expect(setRoots).toHaveBeenCalledTimes(1); + release(); + await tick(); + }); + + it("is read-only while disconnected", async () => { + const { stdin, client } = renderModal({ connected: false }); + await tick(); + stdin.write("n"); + await tick(); + stdin.write("x"); + await tick(); + expect(client.setRoots).not.toHaveBeenCalled(); + }); + + it("does nothing on an empty list or without a client", async () => { + const { stdin, client } = renderModal({ roots: [] }); + await tick(); + stdin.write("x"); + await tick(); + expect(client.setRoots).not.toHaveBeenCalled(); + + const noClient = renderModal({ inspectorClient: null }); + await tick(); + noClient.stdin.write("x"); + await tick(); + }); +}); diff --git a/clients/tui/__tests__/SubscriptionsTab.test.tsx b/clients/tui/__tests__/SubscriptionsTab.test.tsx new file mode 100644 index 0000000000..8b11ac83bd --- /dev/null +++ b/clients/tui/__tests__/SubscriptionsTab.test.tsx @@ -0,0 +1,256 @@ +import React from "react"; +import { describe, it, expect, vi } from "vitest"; +import { render } from "./helpers/renderTui"; +import type { Resource } from "@modelcontextprotocol/client"; +import type { InspectorClient } from "@inspector/core/mcp/index.js"; +import type { + InspectorResourceSubscription, + MessageEntry, + ResourceSubscriptionStreamState, +} from "@inspector/core/mcp/types.js"; +import { INACTIVE_SUBSCRIPTION_STREAM_STATE } from "@inspector/core/mcp/types.js"; +import { AuthRecoveryRequiredError } from "@inspector/core/auth/challenge.js"; + +const CHALLENGE = { reason: "insufficient_scope" as const }; + +vi.mock("ink-scroll-view", () => import("./helpers/inkScrollViewMock.js")); + +import { SubscriptionsTab } from "../src/components/SubscriptionsTab.js"; + +const tick = async () => { + for (let i = 0; i < 8; i++) + await new Promise((resolve) => setTimeout(resolve, 4)); +}; +const ESC = String.fromCharCode(27); +const UP = `${ESC}[A`; +const DOWN = `${ESC}[B`; +const PAGE_UP = `${ESC}[5~`; +const PAGE_DOWN = `${ESC}[6~`; + +const resA: Resource = { uri: "file:///a", name: "alpha" }; +const resB: Resource = { uri: "file:///b", name: "" }; + +const updated = (id: string, uri: string): MessageEntry => ({ + id, + timestamp: new Date(Date.UTC(2026, 0, 1, 12, 0, 0)), + direction: "notification", + message: { + jsonrpc: "2.0", + method: "notifications/resources/updated", + params: { uri }, + }, +}); + +interface FakeOps { + subscribeToResource: ReturnType; + unsubscribeFromResource: ReturnType; +} + +const fakeClient = (over: Partial = {}): FakeOps & InspectorClient => + ({ + subscribeToResource: vi.fn(async () => {}), + unsubscribeFromResource: vi.fn(async () => {}), + ...over, + }) as unknown as FakeOps & InspectorClient; + +function renderTab( + props: Partial> = {}, +) { + const client = fakeClient(); + const api = render( + , + ); + return { ...api, client }; +} + +const subscribedA: InspectorResourceSubscription[] = [ + { resource: resA, lastUpdated: new Date(Date.UTC(2026, 0, 1, 12, 0, 0)) }, +]; + +describe("SubscriptionsTab", () => { + it("shows an empty state", () => { + const { lastFrame } = renderTab({ resources: [] }); + const frame = lastFrame() ?? ""; + expect(frame).toContain("Subscriptions (0/0)"); + expect(frame).toContain("No resources to subscribe to"); + expect(frame).toContain("Resource updates"); + expect(frame).toContain("No resources/updated notifications yet"); + expect(frame).not.toContain("Enter to subscribe"); + }); + + it("lists resources with subscription state and the update feed", () => { + const { lastFrame } = renderTab({ + subscriptions: subscribedA, + messages: [updated("m1", "file:///a"), updated("m2", "file:///b")], + }); + const frame = lastFrame() ?? ""; + expect(frame).toContain("Subscriptions (1/2)"); + expect(frame).toContain("● alpha"); + // A resource with an empty name falls back to its URI. + expect(frame).toContain("○ file:///b"); + expect(frame).toContain("Subscribed"); + expect(frame).toContain("last updated"); + expect(frame).toContain("Updates (2):"); + expect(frame).toContain("Enter to unsubscribe"); + }); + + it("subscribes on Enter", async () => { + const { stdin, client } = renderTab(); + stdin.write("\r"); + await tick(); + expect(client.subscribeToResource).toHaveBeenCalledWith("file:///a"); + }); + + it("unsubscribes on Enter when subscribed", async () => { + const { stdin, client } = renderTab({ subscriptions: subscribedA }); + stdin.write("\r"); + await tick(); + expect(client.unsubscribeFromResource).toHaveBeenCalledWith("file:///a"); + }); + + it("shows a pending toggle and ignores a second Enter until it settles", async () => { + let release: () => void = () => {}; + const pending = new Promise((resolve) => { + release = resolve; + }); + const client = fakeClient({ subscribeToResource: vi.fn(() => pending) }); + const { stdin, lastFrame } = renderTab({ inspectorClient: client }); + stdin.write("\r"); + await tick(); + expect(lastFrame()).toContain("Updating subscription…"); + stdin.write("\r"); + await tick(); + expect(client.subscribeToResource).toHaveBeenCalledTimes(1); + release(); + await tick(); + expect(lastFrame()).not.toContain("Updating subscription…"); + }); + + it("surfaces a subscribe failure", async () => { + const client = fakeClient({ + subscribeToResource: vi.fn(async () => { + throw new Error("Server does not support resource subscriptions"); + }), + }); + const { stdin, lastFrame } = renderTab({ inspectorClient: client }); + stdin.write("\r"); + await tick(); + expect(lastFrame()).toContain("does not support resource subscriptions"); + }); + + it("surfaces a non-Error failure", async () => { + const client = fakeClient({ + subscribeToResource: vi.fn(() => Promise.reject("nope")), + }); + const { stdin, lastFrame } = renderTab({ inspectorClient: client }); + stdin.write("\r"); + await tick(); + expect(lastFrame()).toContain("nope"); + }); + + it("hands auth recovery to the caller, and tolerates no handler", async () => { + const recovery = new AuthRecoveryRequiredError( + new URL("https://auth.example/start"), + CHALLENGE, + ); + const client = fakeClient({ + subscribeToResource: vi.fn(async () => { + throw recovery; + }), + }); + const onAuthRecoveryRequired = vi.fn(); + const first = renderTab({ + inspectorClient: client, + onAuthRecoveryRequired, + }); + first.stdin.write("\r"); + await tick(); + expect(onAuthRecoveryRequired).toHaveBeenCalledWith(recovery); + first.unmount(); + + const second = renderTab({ inspectorClient: client }); + second.stdin.write("\r"); + await tick(); + expect(second.lastFrame()).toContain("Not subscribed"); + }); + + it("does nothing without a client", async () => { + const { stdin, lastFrame } = renderTab({ inspectorClient: null }); + stdin.write("\r"); + await tick(); + expect(lastFrame()).toContain("Not subscribed"); + }); + + it("navigates the list and highlights the selection's updates", async () => { + const { stdin, lastFrame } = renderTab({ + messages: [updated("m1", "file:///b")], + }); + stdin.write(DOWN); + await tick(); + expect(lastFrame()).toContain("URI: file:///b"); + stdin.write(DOWN); // at the end + await tick(); + stdin.write(UP); + await tick(); + expect(lastFrame()).toContain("URI: file:///a"); + stdin.write(UP); // at the top + await tick(); + expect(lastFrame()).toContain("URI: file:///a"); + }); + + it("scrolls the details pane", async () => { + const { stdin, lastFrame } = renderTab({ focusedPane: "details" }); + for (const k of [UP, DOWN, PAGE_UP, PAGE_DOWN, "z"]) { + stdin.write(k); + await tick(); + } + expect(lastFrame()).toContain("URI: file:///a"); + }); + + it("shows the modern listen stream and an unhonored URI", () => { + const streamState: ResourceSubscriptionStreamState = { + active: true, + status: "acknowledged", + honoredUris: [], + }; + const { lastFrame } = renderTab({ + subscriptions: subscribedA, + streamState, + }); + const frame = lastFrame() ?? ""; + expect(frame).toContain("Listen stream: acknowledged"); + expect(frame).toContain("did not honor"); + }); + + it("does not flag a URI the server honored", () => { + const { lastFrame } = renderTab({ + subscriptions: [{ resource: resA }], + streamState: { + active: true, + status: "reconnecting", + honoredUris: ["file:///a"], + }, + }); + const frame = lastFrame() ?? ""; + expect(frame).toContain("Listen stream: reconnecting"); + expect(frame).not.toContain("did not honor"); + expect(frame).not.toContain("last updated"); + }); + + it("ignores keys while a modal is open", async () => { + const { stdin, client } = renderTab({ modalOpen: true }); + stdin.write("\r"); + await tick(); + expect(client.subscribeToResource).not.toHaveBeenCalled(); + }); +}); diff --git a/clients/tui/__tests__/Tabs.test.tsx b/clients/tui/__tests__/Tabs.test.tsx index 8854d12660..6c3ca965fd 100644 --- a/clients/tui/__tests__/Tabs.test.tsx +++ b/clients/tui/__tests__/Tabs.test.tsx @@ -69,6 +69,26 @@ describe("Tabs", () => { expect(shown.lastFrame() ?? "").toContain("Skills"); }); + it("hides Subscriptions and Tasks by default and shows them when supported (#2432)", () => { + const hidden = render( + , + ); + expect(hidden.lastFrame() ?? "").not.toContain("Subscriptions"); + expect(hidden.lastFrame() ?? "").not.toContain("Tasks"); + const shown = render( + , + ); + expect(shown.lastFrame() ?? "").toContain("Subscriptions (2)"); + expect(shown.lastFrame() ?? "").toContain("Tasks (3)"); + }); + it("renders a count on the skills tab", () => { const { lastFrame } = render( import("./helpers/inkScrollViewMock.js")); + +import { + TasksTab, + hasTaskResult, + isTaskActive, + taskStatusStyle, +} from "../src/components/TasksTab.js"; + +const tick = async () => { + for (let i = 0; i < 8; i++) + await new Promise((resolve) => setTimeout(resolve, 4)); +}; +const ESC = String.fromCharCode(27); +const UP = `${ESC}[A`; +const DOWN = `${ESC}[B`; +const PAGE_UP = `${ESC}[5~`; +const PAGE_DOWN = `${ESC}[6~`; + +const task = (over: Partial = {}): Task => + ({ + taskId: "task-1", + status: "working", + createdAt: "2026-01-01T00:00:00Z", + lastUpdatedAt: "2026-01-01T00:00:01Z", + ttl: 60000, + ...over, + }) as Task; + +interface FakeOps { + cancelRequestorTask: ReturnType; + getRequestorTaskResult: ReturnType; +} + +const fakeClient = (over: Partial = {}): FakeOps & InspectorClient => + ({ + cancelRequestorTask: vi.fn(async () => {}), + getRequestorTaskResult: vi.fn(async () => ({ + content: [{ type: "text", text: "done" }], + })), + ...over, + }) as unknown as FakeOps & InspectorClient; + +function renderTab(props: Partial> = {}) { + const onRefresh = vi.fn(async () => []); + const onClearCompleted = vi.fn(); + const client = fakeClient(); + const api = render( + , + ); + return { ...api, onRefresh, onClearCompleted, client }; +} + +describe("task status helpers", () => { + it("styles known and unknown statuses", () => { + expect(taskStatusStyle("completed").color).toBe("green"); + expect(taskStatusStyle("mystery")).toEqual({ glyph: "·", color: "gray" }); + }); + + it("classifies active and result-bearing statuses", () => { + expect(isTaskActive("working")).toBe(true); + expect(isTaskActive("input_required")).toBe(true); + expect(isTaskActive("completed")).toBe(false); + expect(hasTaskResult("completed")).toBe(true); + expect(hasTaskResult("failed")).toBe(true); + expect(hasTaskResult("cancelled")).toBe(false); + }); +}); + +describe("TasksTab", () => { + it("shows an empty state", () => { + const { lastFrame } = renderTab({ tasks: [] }); + expect(lastFrame()).toContain("Tasks (0)"); + expect(lastFrame()).toContain("No tasks yet"); + expect(lastFrame()).toContain("Select a task to view details"); + }); + + it("renders the selected task's details and footer", () => { + const { lastFrame } = renderTab({ + tasks: [task({ statusMessage: "halfway", pollInterval: 500 })], + }); + const frame = lastFrame() ?? ""; + expect(frame).toContain("Tasks (1)"); + expect(frame).toContain("Status: working"); + expect(frame).toContain("halfway"); + expect(frame).toContain("Updated: 2026-01-01T00:00:01Z"); + expect(frame).toContain("TTL: 60000 Poll: 500ms"); + expect(frame).toContain("x cancel"); + }); + + it("renders a task without optional fields", () => { + const { lastFrame } = renderTab({ + tasks: [task({ ttl: null, lastUpdatedAt: undefined })], + focusedPane: "details", + }); + const frame = lastFrame() ?? ""; + expect(frame).toContain("TTL: none"); + expect(frame).not.toContain("Updated:"); + expect(frame).toContain("+ zoom"); + }); + + it("navigates the list with the arrow keys", async () => { + const { lastFrame, stdin } = renderTab({ + tasks: [task(), task({ taskId: "task-2", status: "failed" })], + }); + stdin.write(DOWN); + await tick(); + expect(lastFrame()).toContain("Status: failed"); + stdin.write(DOWN); // already at the end + await tick(); + stdin.write(UP); + await tick(); + expect(lastFrame()).toContain("Status: working"); + stdin.write(UP); // already at the top + await tick(); + expect(lastFrame()).toContain("Status: working"); + }); + + it("refreshes on 'f' and clears completed on 'l'", async () => { + const { stdin, onRefresh, onClearCompleted } = renderTab(); + stdin.write("f"); + await tick(); + stdin.write("l"); + await tick(); + expect(onRefresh).toHaveBeenCalledTimes(1); + expect(onClearCompleted).toHaveBeenCalledTimes(1); + }); + + it("surfaces a refresh failure", async () => { + const { stdin, lastFrame } = renderTab({ + onRefresh: vi.fn(async () => { + throw new Error("list failed"); + }), + }); + stdin.write("f"); + await tick(); + expect(lastFrame()).toContain("list failed"); + }); + + it("surfaces a non-Error failure with no task selected", async () => { + const { stdin, lastFrame } = renderTab({ + tasks: [], + onRefresh: vi.fn(() => Promise.reject("plain failure")), + }); + stdin.write("f"); + await tick(); + expect(lastFrame()).toContain("plain failure"); + }); + + it("shows the busy line while an operation is pending", async () => { + let release: () => void = () => {}; + const pending = new Promise((resolve) => { + release = () => resolve([]); + }); + const { stdin, lastFrame } = renderTab({ onRefresh: () => pending }); + stdin.write("f"); + await tick(); + expect(lastFrame()).toContain("Refreshing…"); + release(); + await tick(); + expect(lastFrame()).not.toContain("Refreshing…"); + }); + + it("shows the busy line in the empty state too", async () => { + let release: () => void = () => {}; + const pending = new Promise((resolve) => { + release = () => resolve([]); + }); + const { stdin, lastFrame } = renderTab({ + tasks: [], + onRefresh: () => pending, + }); + stdin.write("f"); + await tick(); + expect(lastFrame()).toContain("Refreshing…"); + release(); + await tick(); + }); + + it("cancels an active task on 'x'", async () => { + const client = fakeClient(); + const { stdin } = renderTab({ inspectorClient: client }); + stdin.write("x"); + await tick(); + expect(client.cancelRequestorTask).toHaveBeenCalledWith("task-1"); + }); + + it("does not cancel a terminal task", async () => { + const client = fakeClient(); + const { stdin } = renderTab({ + inspectorClient: client, + tasks: [task({ status: "completed" })], + }); + stdin.write("x"); + await tick(); + expect(client.cancelRequestorTask).not.toHaveBeenCalled(); + }); + + it("fetches and shows a terminal task's result on Enter, and zooms it", async () => { + const client = fakeClient(); + const onViewDetails = vi.fn(); + const { stdin, lastFrame, rerender, onRefresh, onClearCompleted } = + renderTab({ + inspectorClient: client, + tasks: [task({ status: "completed" })], + }); + stdin.write("\r"); + await tick(); + expect(client.getRequestorTaskResult).toHaveBeenCalledWith("task-1"); + expect(lastFrame()).toContain("Result:"); + expect(lastFrame()).toContain("done"); + + rerender( + , + ); + await tick(); + stdin.write("+"); + await tick(); + expect(onViewDetails).toHaveBeenCalledWith( + expect.objectContaining({ taskId: "task-1" }), + { content: [{ type: "text", text: "done" }] }, + ); + }); + + it("does not fetch a result for a task still running", async () => { + const client = fakeClient(); + const { stdin } = renderTab({ inspectorClient: client }); + stdin.write("\r"); + await tick(); + expect(client.getRequestorTaskResult).not.toHaveBeenCalled(); + }); + + it("hands auth recovery to the caller", async () => { + const recovery = new AuthRecoveryRequiredError( + new URL("https://auth.example/start"), + CHALLENGE, + ); + const client = fakeClient({ + cancelRequestorTask: vi.fn(async () => { + throw recovery; + }), + }); + const onAuthRecoveryRequired = vi.fn(); + const { stdin, lastFrame } = renderTab({ + inspectorClient: client, + onAuthRecoveryRequired, + }); + stdin.write("x"); + await tick(); + expect(onAuthRecoveryRequired).toHaveBeenCalledWith(recovery); + expect(lastFrame()).not.toContain("Error"); + }); + + it("tolerates auth recovery with no handler", async () => { + const client = fakeClient({ + cancelRequestorTask: vi.fn(async () => { + throw new AuthRecoveryRequiredError( + new URL("https://auth.example"), + CHALLENGE, + ); + }), + }); + const { stdin, lastFrame } = renderTab({ inspectorClient: client }); + stdin.write("x"); + await tick(); + expect(lastFrame()).toContain("Status: working"); + }); + + it("scrolls the details pane and ignores zoom without a handler", async () => { + const { stdin, lastFrame } = renderTab({ focusedPane: "details" }); + for (const k of [UP, DOWN, PAGE_UP, PAGE_DOWN, "+", "z"]) { + stdin.write(k); + await tick(); + } + expect(lastFrame()).toContain("Status: working"); + }); + + it("does nothing without a client", async () => { + const { stdin, lastFrame } = renderTab({ + inspectorClient: null, + tasks: [task({ status: "completed" })], + }); + stdin.write("x"); + stdin.write("\r"); + await tick(); + expect(lastFrame()).not.toContain("Result:"); + }); + + it("ignores keys while a modal is open", async () => { + const { stdin, onRefresh } = renderTab({ modalOpen: true }); + stdin.write("f"); + await tick(); + expect(onRefresh).not.toHaveBeenCalled(); + }); +}); diff --git a/clients/tui/__tests__/resourceUpdates.test.ts b/clients/tui/__tests__/resourceUpdates.test.ts new file mode 100644 index 0000000000..26bf68e66e --- /dev/null +++ b/clients/tui/__tests__/resourceUpdates.test.ts @@ -0,0 +1,92 @@ +import { describe, it, expect } from "vitest"; +import type { + InspectorResourceSubscription, + MessageEntry, +} from "@inspector/core/mcp/types.js"; +import { + RESOURCE_UPDATED_METHOD, + resourceUpdateFeed, + subscribableResources, +} from "../src/utils/resourceUpdates.js"; + +const at = (s: number) => new Date(Date.UTC(2026, 0, 1, 0, 0, s)); + +const notification = ( + id: string, + method: string, + params: Record | undefined, + seconds: number, +): MessageEntry => ({ + id, + timestamp: at(seconds), + direction: "notification", + message: params + ? { jsonrpc: "2.0", method, params } + : { jsonrpc: "2.0", method }, +}); + +describe("resourceUpdateFeed", () => { + it("keeps only resources/updated notifications with a URI, newest first", () => { + const messages: MessageEntry[] = [ + notification("1", RESOURCE_UPDATED_METHOD, { uri: "file:///a" }, 1), + { + id: "2", + timestamp: at(2), + direction: "request", + message: { jsonrpc: "2.0", id: 1, method: RESOURCE_UPDATED_METHOD }, + }, + notification("3", "notifications/tools/list_changed", undefined, 3), + notification("4", RESOURCE_UPDATED_METHOD, undefined, 4), + notification("5", RESOURCE_UPDATED_METHOD, { uri: 42 }, 5), + { + id: "6", + timestamp: at(6), + direction: "response", + message: { jsonrpc: "2.0", id: 1, result: {} }, + }, + notification("7", RESOURCE_UPDATED_METHOD, { uri: "file:///b" }, 7), + ]; + expect(resourceUpdateFeed(messages)).toEqual([ + { id: "7", timestamp: at(7), uri: "file:///b" }, + { id: "1", timestamp: at(1), uri: "file:///a" }, + ]); + }); + + it("is empty for an empty log", () => { + expect(resourceUpdateFeed([])).toEqual([]); + }); +}); + +describe("subscribableResources", () => { + it("marks listed resources and appends unlisted subscriptions", () => { + const updated = at(9); + const subs: InspectorResourceSubscription[] = [ + { resource: { uri: "file:///a", name: "a" }, lastUpdated: updated }, + { resource: { uri: "file:///x", name: "file:///x" } }, + ]; + const rows = subscribableResources( + [ + { uri: "file:///a", name: "a" }, + { uri: "file:///b", name: "b" }, + ], + subs, + ); + expect(rows).toEqual([ + { + resource: { uri: "file:///a", name: "a" }, + subscribed: true, + lastUpdated: updated, + }, + { + resource: { uri: "file:///b", name: "b" }, + subscribed: false, + lastUpdated: undefined, + }, + { + resource: { uri: "file:///x", name: "file:///x" }, + subscribed: true, + lastUpdated: undefined, + }, + ]); + }); +}); diff --git a/clients/tui/__tests__/tabsConfig.test.ts b/clients/tui/__tests__/tabsConfig.test.ts index 2bdb41b5fe..ab3975b095 100644 --- a/clients/tui/__tests__/tabsConfig.test.ts +++ b/clients/tui/__tests__/tabsConfig.test.ts @@ -29,6 +29,8 @@ describe("visibleTabs", () => { expect(ids).not.toContain("logging"); expect(ids).not.toContain("requests"); expect(ids).not.toContain("skills"); + expect(ids).not.toContain("subscriptions"); + expect(ids).not.toContain("tasks"); // The unconditional ones remain. expect(ids).toContain("info"); expect(ids).toContain("tools"); @@ -40,6 +42,8 @@ describe("visibleTabs", () => { showLogging: true, showRequests: true, showSkills: true, + showSubscriptions: true, + showTasks: true, }).map((t) => t.id); expect(ids).toEqual(tabs.map((t) => t.id)); }); diff --git a/clients/tui/src/App.tsx b/clients/tui/src/App.tsx index 463602eef6..1f4c31bc4c 100644 --- a/clients/tui/src/App.tsx +++ b/clients/tui/src/App.tsx @@ -18,6 +18,9 @@ import type { Prompt, PromptArgument, GetPromptResult, + Root, + Task, + CallToolResult, } from "@modelcontextprotocol/client"; import { InspectorClient } from "@inspector/core/mcp/index.js"; import { cleanRoots } from "@inspector/core/mcp/serverList.js"; @@ -31,6 +34,8 @@ import { MessageLogState, FetchRequestLogState, StderrLogState, + ManagedRequestorTasksState, + ResourceSubscriptionsState, } from "@inspector/core/mcp/state/index.js"; import { createProxyFetch, @@ -45,6 +50,9 @@ import { useManagedSkills } from "@inspector/core/react/useManagedSkills.js"; import { useMessageLog } from "@inspector/core/react/useMessageLog.js"; import { useFetchRequestLog } from "@inspector/core/react/useFetchRequestLog.js"; import { useStderrLog } from "@inspector/core/react/useStderrLog.js"; +import { useManagedRequestorTasks } from "@inspector/core/react/useManagedRequestorTasks.js"; +import { useResourceSubscriptions } from "@inspector/core/react/useResourceSubscriptions.js"; +import { useStoreSnapshot } from "@inspector/core/react/useStoreSnapshot.js"; import { CallbackNavigation, MutableRedirectUrlProvider, @@ -94,6 +102,9 @@ import { DetailsModal } from "./components/DetailsModal.js"; import { toCopyText } from "./utils/clipboard.js"; import { HelpOverlay } from "./components/HelpOverlay.js"; import { keybindingSections } from "./utils/keybindings.js"; +import { TasksTab } from "./components/TasksTab.js"; +import { SubscriptionsTab } from "./components/SubscriptionsTab.js"; +import { RootsModal } from "./components/RootsModal.js"; import { BodyLines } from "./components/BodyLines.js"; import type { TuiServer } from "./tui-servers.js"; import { errorMessage, redactErrorText } from "./utils/errorText.js"; @@ -110,6 +121,13 @@ const APP_VERSION = readInspectorVersion(import.meta.url); /** Client identity name the TUI reports to servers. */ const TUI_CLIENT_NAME = "inspector-tui"; +/** + * Roots snapshot for `useStoreSnapshot`: module scope so both the reader and + * the fallback are referentially stable across renders (#2432). + */ +const NO_ROOTS: Root[] = []; +const readRoots = (client: InspectorClient): Root[] => client.getRoots(); + // Focus management types type FocusArea = | "serverList" @@ -176,9 +194,11 @@ function App({ const [tabCounts, setTabCounts] = useState<{ info?: number; resources?: number; + subscriptions?: number; prompts?: number; skills?: number; tools?: number; + tasks?: number; messages?: number; requests?: number; logging?: number; @@ -255,6 +275,9 @@ function App({ copyText: string; } | null>(null); + // Roots editor (#2432), opened from the Info tab. + const [rootsModalOpen, setRootsModalOpen] = useState(false); + // InspectorClient instances for each server const [inspectorClients, setInspectorClients] = useState< Record @@ -283,6 +306,13 @@ function App({ const [stderrLogStates, setStderrLogStates] = useState< Record >({}); + // Created with the client, like the stores above, so a task status update or + // a resources/updated that lands while another tab is showing is not lost. + const [requestorTasksStates, setRequestorTasksStates] = useState< + Record + >({}); + const [resourceSubscriptionsStates, setResourceSubscriptionsStates] = + useState>({}); const [dimensions, setDimensions] = useState({ width: process.stdout.columns || 80, height: process.stdout.rows || 24, @@ -340,6 +370,12 @@ function App({ const newMessageLogStates: Record = {}; const newFetchRequestLogStates: Record = {}; const newStderrLogStates: Record = {}; + const newRequestorTasksStates: Record = + {}; + const newResourceSubscriptionsStates: Record< + string, + ResourceSubscriptionsState + > = {}; for (const serverName of serverNames) { if (!(serverName in inspectorClients)) { const { config: serverConfig, settings: savedSettings } = @@ -409,9 +445,8 @@ function App({ const client = new InspectorClient(serverConfig, opts); newClients[serverName] = client; newManagers[serverName] = new ManagedToolsState(client); - newManagedResourcesStates[serverName] = new ManagedResourcesState( - client, - ); + const resourcesState = new ManagedResourcesState(client); + newManagedResourcesStates[serverName] = resourcesState; newManagedResourceTemplatesStates[serverName] = new ManagedResourceTemplatesState(client); newManagedPromptsStates[serverName] = new ManagedPromptsState(client); @@ -419,6 +454,13 @@ function App({ newMessageLogStates[serverName] = new MessageLogState(client); newFetchRequestLogStates[serverName] = new FetchRequestLogState(client); newStderrLogStates[serverName] = new StderrLogState(client); + newRequestorTasksStates[serverName] = new ManagedRequestorTasksState( + client, + ); + // Given the resources store so a subscription carries the listed + // Resource's name rather than a bare URI. + newResourceSubscriptionsStates[serverName] = + new ResourceSubscriptionsState(client, resourcesState); } } if (Object.keys(newClients).length > 0) { @@ -446,6 +488,14 @@ function App({ ...newFetchRequestLogStates, })); setStderrLogStates((prev) => ({ ...prev, ...newStderrLogStates })); + setRequestorTasksStates((prev) => ({ + ...prev, + ...newRequestorTasksStates, + })); + setResourceSubscriptionsStates((prev) => ({ + ...prev, + ...newResourceSubscriptionsStates, + })); } // Omitted on purpose: `inspectorClients` is this effect's own output, so // depending on it would re-run the effect after every client it creates @@ -491,6 +541,12 @@ function App({ Object.values(stderrLogStates).forEach((manager) => { manager.destroy(); }); + Object.values(requestorTasksStates).forEach((manager) => { + manager.destroy(); + }); + Object.values(resourceSubscriptionsStates).forEach((manager) => { + manager.destroy(); + }); Object.values(inspectorClients).forEach((client) => { client.disconnect().catch(() => { // Ignore errors during cleanup @@ -507,6 +563,8 @@ function App({ messageLogStates, fetchRequestLogStates, stderrLogStates, + requestorTasksStates, + resourceSubscriptionsStates, ]); // Preselect the first server on mount @@ -697,6 +755,62 @@ function App({ } }, [activeTab, inspectorStatus, showSkillsTab]); + // Tasks, resource subscriptions and roots (#2432) — all read from core + // stores, so nothing about their lifecycle is decided in the TUI. + const selectedRequestorTasksState = useMemo( + () => + selectedServer && requestorTasksStates[selectedServer] + ? requestorTasksStates[selectedServer] + : null, + [selectedServer, requestorTasksStates], + ); + const { + tasks: requestorTasks, + refresh: refreshRequestorTasks, + clearCompleted: clearCompletedRequestorTasks, + } = useManagedRequestorTasks( + selectedInspectorClient, + selectedRequestorTasksState, + ); + const selectedResourceSubscriptionsState = useMemo( + () => + selectedServer && resourceSubscriptionsStates[selectedServer] + ? resourceSubscriptionsStates[selectedServer] + : null, + [selectedServer, resourceSubscriptionsStates], + ); + const { + subscriptions: resourceSubscriptions, + streamState: subscriptionStreamState, + } = useResourceSubscriptions(selectedResourceSubscriptionsState); + const advertisedRoots = useStoreSnapshot( + selectedInspectorClient ?? null, + "rootsChange", + readRoots, + NO_ROOTS, + ); + // Server-declared, so only knowable once connected — the same gate the web + // client uses for its Resources subscribe control and its Tasks screen. + const showSubscriptionsTab = + inspectorStatus === "connected" && + inspectorCapabilities?.resources?.subscribe === true; + const showTasksTab = + inspectorStatus === "connected" && + (!!inspectorCapabilities?.tasks || + (selectedInspectorClient?.isTasksExtensionNegotiated() ?? false)); + + // Switch away from a tab the server stops serving, for the Skills reason + // above: the bar drops it, but `activeTab` would keep rendering its pane. + useEffect(() => { + if (inspectorStatus !== "connected") return; + if ( + (activeTab === "subscriptions" && !showSubscriptionsTab) || + (activeTab === "tasks" && !showTasksTab) + ) { + setActiveTab("info"); + } + }, [activeTab, inspectorStatus, showSubscriptionsTab, showTasksTab]); + // Connect — on 401 or mid-session auth recovery, run OAuth then retry. type TuiOAuthRunResult = | "success" @@ -1358,6 +1472,25 @@ function App({ ); + const renderTaskDetails = (task: Task, result: CallToolResult | null) => ( + <> + + Task: + + {JSON.stringify(task, null, 2)} + + + {result && ( + + Result: + + {JSON.stringify(result, null, 2)} + + + )} + + ); + const renderMessageDetails = (message: MessageEntry) => ( <> @@ -1420,6 +1553,8 @@ function App({ prompts: managedPrompts.length || 0, skills: managedSkills.length || 0, tools: managedTools.length || 0, + subscriptions: resourceSubscriptions.length, + tasks: requestorTasks.length, messages: inspectorMessages.length || 0, requests: inspectorFetchRequests.length || 0, logging: inspectorStderrLogs.length || 0, @@ -1430,6 +1565,8 @@ function App({ managedPrompts, managedSkills, managedTools, + resourceSubscriptions, + requestorTasks, inspectorMessages, inspectorFetchRequests, inspectorStderrLogs, @@ -1479,7 +1616,8 @@ function App({ resourceTestModal || promptTestModal || detailsModal || - helpOpen + helpOpen || + rootsModalOpen ) { return; } @@ -1526,6 +1664,8 @@ function App({ if (tab.id === "logging" && !showLoggingTab) return false; if (tab.id === "requests" && !showRequestsTab) return false; if (tab.id === "skills" && !showSkillsTab) return false; + if (tab.id === "subscriptions" && !showSubscriptionsTab) return false; + if (tab.id === "tasks" && !showTasksTab) return false; return true; }) .map((tab: { id: TabType; label: string; accelerator: string }) => [ @@ -1540,7 +1680,13 @@ function App({ nextTab === "auth" && activeTab === "auth" && pendingStepUp?.serverName === selectedServer; - if (!authStepUpAccelerator) { + // AuthTab binds `s` to "clear OAuth state" while its pane is focused, so + // the Tasks accelerator must not also fire there (#2432). + const authClearKey = + nextTab === "tasks" && + activeTab === "auth" && + (focus === "tabContentList" || focus === "tabContentDetails"); + if (!authStepUpAccelerator && !authClearKey) { setActiveTab(nextTab); setFocus(nextTab === "auth" ? "tabContentList" : "tabs"); } @@ -1612,9 +1758,11 @@ function App({ "info", "auth", "resources", + "subscriptions", "prompts", "skills", "tools", + "tasks", "messages", "requests", "logging", @@ -1624,6 +1772,8 @@ function App({ if (t === "logging" && !showLoggingTab) return false; if (t === "requests" && !showRequestsTab) return false; if (t === "skills" && !showSkillsTab) return false; + if (t === "subscriptions" && !showSubscriptionsTab) return false; + if (t === "tasks" && !showTasksTab) return false; return true; }); const currentIndex = tabs.indexOf(activeTab); @@ -1680,6 +1830,8 @@ function App({ inspectorClients[selectedServer]?.getServerType() === "streamable-http"), showSkills: showSkillsTab, + showSubscriptions: showSubscriptionsTab, + showTasks: showTasksTab, }); const tabsHeight = tabBarRows(shownTabs, tabCounts, contentWidth); // Server details will be flexible - calculate remaining space for content @@ -1884,6 +2036,8 @@ function App({ : false } showSkills={showSkillsTab} + showSubscriptions={showSubscriptionsTab} + showTasks={showTasksTab} showRequests={ selectedServer && inspectorClients[selectedServer] ? (() => { @@ -1916,7 +2070,15 @@ function App({ width={contentWidth} height={contentHeight} focused={ - focus === "tabContentList" || focus === "tabContentDetails" + (focus === "tabContentList" || + focus === "tabContentDetails") && + !rootsModalOpen + } + roots={advertisedRoots} + onEditRoots={ + selectedInspectorClient + ? () => setRootsModalOpen(true) + : undefined } /> )} @@ -2089,6 +2251,55 @@ function App({ ) } /> + ) : activeTab === "subscriptions" && + showSubscriptionsTab && + selectedInspectorClient ? ( + + ) : activeTab === "tasks" && + showTasksTab && + selectedInspectorClient ? ( + + setDetailsModal({ + title: `Task: ${task.taskId}`, + content: renderTaskDetails(task, result), + }) + } + onAuthRecoveryRequired={onAuthRecoveryRequired} + /> ) : activeTab === "skills" && currentServerState?.status === "connected" && selectedInspectorClient ? ( @@ -2330,6 +2541,18 @@ function App({ /> )} + {/* Roots editor (#2432) - rendered at App level for full screen overlay */} + {rootsModalOpen && ( + setRootsModalOpen(false)} + /> + )} + {/* Details Modal - rendered at App level for full screen overlay. Held back while the help is open: one can arrive asynchronously (a no-argument prompt fetch completing), and both overlays would then diff --git a/clients/tui/src/components/InfoTab.tsx b/clients/tui/src/components/InfoTab.tsx index 6d7dbadd04..6b3b652eea 100644 --- a/clients/tui/src/components/InfoTab.tsx +++ b/clients/tui/src/components/InfoTab.tsx @@ -6,6 +6,7 @@ import type { ServerState, } from "@inspector/core/mcp/index.js"; import type { InspectorServerSettings } from "@inspector/core/mcp/types.js"; +import type { Root } from "@modelcontextprotocol/client"; interface InfoTabProps { serverName: string | null; @@ -19,6 +20,10 @@ interface InfoTabProps { width: number; height: number; focused?: boolean; + /** The roots the client currently advertises to this server (#2432). */ + roots?: Root[]; + /** Open the roots editor — bound to `e` while this pane is focused. */ + onEditRoots?: () => void; } export function InfoTab({ @@ -29,6 +34,8 @@ export function InfoTab({ width, height, focused = false, + roots = [], + onEditRoots, }: InfoTabProps) { const headerPairs = serverSettings?.headers ?? []; // Shared header display for the sse / streamable-http branches (identical for @@ -48,7 +55,9 @@ export function InfoTab({ useInput( (input: string, key: Key) => { if (focused) { - if (key.upArrow) { + if (input === "e" && onEditRoots) { + onEditRoots(); + } else if (key.upArrow) { scrollViewRef.current?.scrollBy(-1); } else if (key.downArrow) { scrollViewRef.current?.scrollBy(1); @@ -202,6 +211,27 @@ export function InfoTab({ Server not connected )} + {/* Roots advertised to the server (#2432) */} + + Roots ({roots.length}) + + + {roots.length === 0 ? ( + None + ) : ( + roots.map((root, idx) => ( + + {root.uri} + {root.name ? ` (${root.name})` : ""} + + )) + )} + @@ -215,6 +245,7 @@ export function InfoTab({ > ↑/↓ to scroll, + to zoom + {onEditRoots ? ", e to edit roots" : ""} )} diff --git a/clients/tui/src/components/RootsModal.tsx b/clients/tui/src/components/RootsModal.tsx new file mode 100644 index 0000000000..efa8a25f4f --- /dev/null +++ b/clients/tui/src/components/RootsModal.tsx @@ -0,0 +1,229 @@ +/** + * Roots editor (#2432): list, add and remove the roots this client advertises + * to the selected server. + * + * A modal rather than a tab because adding a root needs free-text input, and + * only a modal suppresses App's global accelerators (a typed `r` would + * otherwise jump to Resources). It is opened from the Info tab with `e`. + * + * Every change goes through `InspectorClient.setRoots`, which normalizes the + * list with `cleanRoots` and sends `notifications/roots/list_changed`, so the + * server re-requests `roots/list` — the same path the web client's settings + * save takes. The change is for this session only: the TUI does not write + * `mcp.json`, so the configured roots return on the next launch. + */ +import React, { useState } from "react"; +import { Box, Text, useInput, type Key } from "ink"; +import { Form, type FormStructure } from "ink-form"; +import type { Root } from "@modelcontextprotocol/client"; +import type { InspectorClient } from "@inspector/core/mcp/index.js"; +import { useSelectableList } from "../hooks/useSelectableList.js"; + +export const ADD_ROOT_FORM: FormStructure = { + title: "Add Root", + sections: [ + { + title: "Root", + fields: [ + { + name: "uri", + label: "URI (e.g. file:///path)", + type: "string", + required: true, + }, + { name: "name", label: "Name (optional)", type: "string" }, + ], + }, + ], +}; + +/** Build the root a submitted form describes, or explain why it can't. */ +export function rootFromForm( + values: Record, +): Root | { error: string } { + const uri = typeof values.uri === "string" ? values.uri.trim() : ""; + if (!uri) return { error: "A root needs a URI" }; + const name = typeof values.name === "string" ? values.name.trim() : ""; + return name ? { uri, name } : { uri }; +} + +interface RootsModalProps { + roots: Root[]; + inspectorClient: InspectorClient | null; + connected: boolean; + width: number; + height: number; + onClose: () => void; +} + +export function RootsModal({ + roots, + inspectorClient, + connected, + width, + height, + onClose, +}: RootsModalProps) { + const [mode, setMode] = useState<"list" | "add">("list"); + const [error, setError] = useState(null); + const [saving, setSaving] = useState(false); + const modalWidth = width - 2; + const modalHeight = height - 2; + const visibleCount = Math.max(1, modalHeight - 8); + const { selectedIndex, firstVisible, setSelection } = useSelectableList( + roots.length, + visibleCount, + ); + + const save = async (next: Root[]) => { + if (!inspectorClient) return; + setSaving(true); + setError(null); + try { + await inspectorClient.setRoots(next); + setMode("list"); + } catch (err) { + setError(err instanceof Error ? err.message : String(err)); + } finally { + setSaving(false); + } + }; + + const handleSubmit = (values: Record) => { + const root = rootFromForm(values); + if ("error" in root) { + setError(root.error); + setMode("list"); + return; + } + // `save` owns every rejection (its catch surfaces the message). + void save([...roots, root]); + }; + + useInput((input: string, key: Key) => { + if (key.escape) { + // Esc backs out of the form first, and closes the editor from the list. + if (mode === "add") { + setMode("list"); + } else { + onClose(); + } + return; + } + // The form owns every other key while it is open. + if (mode === "add" || saving) return; + + if (key.upArrow && selectedIndex > 0) { + setSelection(selectedIndex - 1); + } else if (key.downArrow && selectedIndex < roots.length - 1) { + setSelection(selectedIndex + 1); + } else if (input === "n" || input === "+") { + if (!connected) { + setError("Connect to the server to change its roots"); + return; + } + setError(null); + setMode("add"); + } else if ((input === "x" || key.delete) && roots[selectedIndex]) { + if (!connected) { + setError("Connect to the server to change its roots"); + return; + } + void save(roots.filter((_, i) => i !== selectedIndex)); + } + }); + + return ( + + + + + Roots ({roots.length}) + + + + {mode === "add" ? "(ESC to go back)" : "(ESC to close)"} + + + + {mode === "add" ? ( + +
+ handleSubmit(values as Record) + } + /> + + ) : ( + + {roots.length === 0 ? ( + No roots are advertised to this server + ) : ( + roots + .slice(firstVisible, firstVisible + visibleCount) + .map((root, i) => { + const index = firstVisible + i; + return ( + + + {index === selectedIndex ? "▶ " : " "} + {root.uri} + {root.name && ({root.name})} + + + ); + }) + )} + + )} + + {saving && ( + + Updating roots… + + )} + {error && ( + + {error} + + )} + {!connected && ( + + + Read-only while disconnected — connect to edit. + + + )} + {mode === "list" && ( + + + n to add, x to remove, ↑/↓ to select. Changes last this session. + + + )} + + + ); +} diff --git a/clients/tui/src/components/SubscriptionsTab.tsx b/clients/tui/src/components/SubscriptionsTab.tsx new file mode 100644 index 0000000000..e618c0c281 --- /dev/null +++ b/clients/tui/src/components/SubscriptionsTab.tsx @@ -0,0 +1,325 @@ +/** + * Subscriptions tab (#2432): subscribe to and unsubscribe from resources, and + * watch the `notifications/resources/updated` feed they produce. + * + * Everything stateful is core's: the subscribed set and each one's + * `lastUpdated` come from `ResourceSubscriptionsState` (through + * `useResourceSubscriptions` in `App`), the toggle is one + * `subscribeToResource` / `unsubscribeFromResource` call — which picks the + * legacy `resources/subscribe` or the modern `subscriptions/listen` filter by + * era — and the feed is read from the protocol message log. The tab owns only + * its selection and the last error. + * + * Its own keys avoid every tab accelerator and the global `c`/`d`, since App's + * handler sees each keypress too: Enter toggles, arrows navigate or scroll. + */ +import React, { useState, useEffect, useRef, useMemo } from "react"; +import { Box, Text, useInput, type Key } from "ink"; +import { ScrollView, type ScrollViewRef } from "ink-scroll-view"; +import type { Resource } from "@modelcontextprotocol/client"; +import type { InspectorClient } from "@inspector/core/mcp/index.js"; +import type { + InspectorResourceSubscription, + MessageEntry, + ResourceSubscriptionStreamState, +} from "@inspector/core/mcp/types.js"; +import { AuthRecoveryRequiredError } from "@inspector/core/auth/challenge.js"; +import { useSelectableList } from "../hooks/useSelectableList.js"; +import { + resourceUpdateFeed, + subscribableResources, +} from "../utils/resourceUpdates.js"; + +/** How each modern-era listen-stream status reads in the details pane. */ +const STREAM_STATUS_TEXT: Record< + ResourceSubscriptionStreamState["status"], + { text: string; color: string } +> = { + connecting: { text: "connecting…", color: "yellow" }, + acknowledged: { text: "acknowledged", color: "green" }, + reconnecting: { text: "reconnecting…", color: "yellow" }, + ended: { text: "ended", color: "gray" }, + "never-acknowledged": { + text: "closed without acknowledging (server answered listen with a result)", + color: "red", + }, +}; + +interface SubscriptionsTabProps { + resources: Resource[]; + subscriptions: InspectorResourceSubscription[]; + streamState: ResourceSubscriptionStreamState; + messages: MessageEntry[]; + inspectorClient: InspectorClient | null; + width: number; + height: number; + focusedPane?: "list" | "details" | null; + modalOpen?: boolean; + onAuthRecoveryRequired?: (error: AuthRecoveryRequiredError) => void; +} + +export function SubscriptionsTab({ + resources, + subscriptions, + streamState, + messages, + inspectorClient, + width, + height, + focusedPane = null, + modalOpen = false, + onAuthRecoveryRequired, +}: SubscriptionsTabProps) { + const rows = useMemo( + () => subscribableResources(resources, subscriptions), + [resources, subscriptions], + ); + const feed = useMemo(() => resourceUpdateFeed(messages), [messages]); + const visibleCount = Math.max(1, height - 7); + const { selectedIndex, firstVisible, setSelection } = useSelectableList( + rows.length, + visibleCount, + ); + const [error, setError] = useState(null); + const [pendingUri, setPendingUri] = useState(null); + const scrollViewRef = useRef(null); + const listWidth = Math.floor(width * 0.4); + const detailWidth = width - listWidth; + const selected = rows[selectedIndex] ?? null; + + const toggle = async (row: (typeof rows)[number]) => { + if (!inspectorClient) return; + const { uri } = row.resource; + setPendingUri(uri); + setError(null); + try { + if (row.subscribed) { + await inspectorClient.unsubscribeFromResource(uri); + } else { + await inspectorClient.subscribeToResource(uri); + } + } catch (err) { + if (err instanceof AuthRecoveryRequiredError) { + onAuthRecoveryRequired?.(err); + return; + } + setError(err instanceof Error ? err.message : String(err)); + } finally { + setPendingUri(null); + } + }; + + useInput( + (_input: string, key: Key) => { + if (key.return && selected && pendingUri === null) { + // `toggle` owns every rejection (its catch surfaces the message), and + // a key handler cannot await. + void toggle(selected); + return; + } + + if (focusedPane === "list") { + if (key.upArrow && selectedIndex > 0) { + setSelection(selectedIndex - 1); + } else if (key.downArrow && selectedIndex < rows.length - 1) { + setSelection(selectedIndex + 1); + } + return; + } + + // Only "details" remains: the hook is inactive for any other pane. + if (key.upArrow) { + scrollViewRef.current?.scrollBy(-1); + } else if (key.downArrow) { + scrollViewRef.current?.scrollBy(1); + } else if (key.pageUp) { + const viewportHeight = scrollViewRef.current?.getViewportHeight() || 1; + scrollViewRef.current?.scrollBy(-viewportHeight); + } else if (key.pageDown) { + const viewportHeight = scrollViewRef.current?.getViewportHeight() || 1; + scrollViewRef.current?.scrollBy(viewportHeight); + } + }, + { + isActive: + !modalOpen && (focusedPane === "list" || focusedPane === "details"), + }, + ); + + useEffect(() => { + scrollViewRef.current?.scrollTo(0); + }, [selectedIndex]); + + const streamText = STREAM_STATUS_TEXT[streamState.status]; + // On the modern era a server may honor only part of the filter; say so for + // the selected URI once the stream has been acknowledged. + const notHonored = + !!selected && + selected.subscribed && + streamState.active && + streamState.status === "acknowledged" && + !streamState.honoredUris.includes(selected.resource.uri); + + return ( + + {/* Resource list with subscription markers */} + + + + Subscriptions ({subscriptions.length}/{rows.length}) + + + {rows.length === 0 ? ( + + No resources to subscribe to + + ) : ( + + {rows + .slice(firstVisible, firstVisible + visibleCount) + .map((row, i) => { + const index = firstVisible + i; + const isSelected = index === selectedIndex; + return ( + + + {isSelected ? "▶ " : " "} + + {row.subscribed ? "●" : "○"} + {" "} + {row.resource.name || row.resource.uri} + + + ); + })} + + )} + + + {/* Selected resource + live update feed */} + + + + {selected + ? selected.resource.name || selected.resource.uri + : "Resource updates"} + + + + + {selected && ( + <> + + + URI: {selected.resource.uri} + + + + + {pendingUri === selected.resource.uri ? ( + Updating subscription… + ) : selected.subscribed ? ( + Subscribed + ) : ( + Not subscribed + )} + {selected.lastUpdated && ( + + {" "} + — last updated {selected.lastUpdated.toLocaleTimeString()} + + )} + + + {notHonored && ( + + + Server did not honor this URI in its listen filter + + + )} + + )} + {streamState.active && ( + + + Listen stream:{" "} + {streamText.text} + + + )} + {error && ( + + {error} + + )} + + Updates ({feed.length}): + + {feed.length === 0 ? ( + + No resources/updated notifications yet + + ) : ( + feed.map((event) => ( + + + {event.timestamp.toLocaleTimeString()} {event.uri} + + + )) + )} + + + {selected && ( + + + {selected.subscribed + ? "Enter to unsubscribe, ↑/↓ to navigate" + : "Enter to subscribe, ↑/↓ to navigate"} + + + )} + + + ); +} diff --git a/clients/tui/src/components/Tabs.tsx b/clients/tui/src/components/Tabs.tsx index 2a23d21689..d8aa74f73f 100644 --- a/clients/tui/src/components/Tabs.tsx +++ b/clients/tui/src/components/Tabs.tsx @@ -29,9 +29,11 @@ interface TabsProps { info?: number; auth?: number; resources?: number; + subscriptions?: number; prompts?: number; skills?: number; tools?: number; + tasks?: number; messages?: number; requests?: number; logging?: number; @@ -47,6 +49,10 @@ interface TabsProps { * known after connecting. */ showSkills?: boolean; + /** Server advertised `resources.subscribe` (#2432). */ + showSubscriptions?: boolean; + /** Server supports Tasks (legacy capability or SEP-2663 extension) (#2432). */ + showTasks?: boolean; } export function Tabs({ @@ -58,6 +64,8 @@ export function Tabs({ showLogging = true, showRequests = false, showSkills = false, + showSubscriptions = false, + showTasks = false, }: TabsProps) { // Shared with `App`, which sizes the pane below this bar from the same list — // see `tabBarRows`. @@ -66,6 +74,8 @@ export function Tabs({ showLogging, showRequests, showSkills, + showSubscriptions, + showTasks, }); return ( diff --git a/clients/tui/src/components/TasksTab.tsx b/clients/tui/src/components/TasksTab.tsx new file mode 100644 index 0000000000..3b7476d668 --- /dev/null +++ b/clients/tui/src/components/TasksTab.tsx @@ -0,0 +1,349 @@ +/** + * Tasks tab (#2432): the TUI's view of requestor tasks — the tasks this client + * created on the server, whether through a task-augmented call or a server that + * answered an ordinary call with a task handle. + * + * Deliberately thin: the list and its live updates come from core's + * `ManagedRequestorTasksState` (through `useManagedRequestorTasks` in `App`), + * and every action here is one `InspectorClient` call. Nothing about task + * lifecycle is decided in this file — a cancelled task stays cancelled because + * the store pins it, not because this view remembers. + * + * Keys are chosen to avoid every tab accelerator and the global `c`/`d`, since + * App's handler sees each keypress too: `x` cancel, `f` refresh, `l` clear + * completed, Enter fetch the result, `+` zoom. + */ +import React, { useState, useEffect, useRef } from "react"; +import { Box, Text, useInput, type Key } from "ink"; +import { ScrollView, type ScrollViewRef } from "ink-scroll-view"; +import type { CallToolResult, Task } from "@modelcontextprotocol/client"; +import type { InspectorClient } from "@inspector/core/mcp/index.js"; +import { AuthRecoveryRequiredError } from "@inspector/core/auth/challenge.js"; +import { useSelectableList } from "../hooks/useSelectableList.js"; + +/** Glyph and color per task status; unknown statuses fall back to gray. */ +const STATUS_STYLE: Record = { + working: { glyph: "◐", color: "yellow" }, + input_required: { glyph: "?", color: "magenta" }, + completed: { glyph: "●", color: "green" }, + failed: { glyph: "✗", color: "red" }, + cancelled: { glyph: "○", color: "gray" }, +}; + +export function taskStatusStyle(status: string): { + glyph: string; + color: string; +} { + return STATUS_STYLE[status] ?? { glyph: "·", color: "gray" }; +} + +/** A task the server may still be working on — the only kind worth cancelling. */ +export function isTaskActive(status: string): boolean { + return status === "working" || status === "input_required"; +} + +/** A task with a terminal outcome the server can hand back via `tasks/result`. */ +export function hasTaskResult(status: string): boolean { + return status === "completed" || status === "failed"; +} + +function errorMessage(err: unknown): string { + return err instanceof Error ? err.message : String(err); +} + +interface TasksTabProps { + tasks: Task[]; + inspectorClient: InspectorClient | null; + width: number; + height: number; + focusedPane?: "list" | "details" | null; + modalOpen?: boolean; + /** Re-list (or re-poll) tasks — `useManagedRequestorTasks().refresh`. */ + onRefresh: () => Promise; + /** Drop terminal tasks — `useManagedRequestorTasks().clearCompleted`. */ + onClearCompleted: () => void; + onViewDetails?: (task: Task, result: CallToolResult | null) => void; + onAuthRecoveryRequired?: (error: AuthRecoveryRequiredError) => void; +} + +export function TasksTab({ + tasks, + inspectorClient, + width, + height, + focusedPane = null, + modalOpen = false, + onRefresh, + onClearCompleted, + onViewDetails, + onAuthRecoveryRequired, +}: TasksTabProps) { + const visibleCount = Math.max(1, height - 7); + const { selectedIndex, firstVisible, setSelection } = useSelectableList( + tasks.length, + visibleCount, + ); + const [error, setError] = useState(null); + const [busy, setBusy] = useState(null); + // Result fetched for one task id; a different selection shows none. + const [result, setResult] = useState<{ + taskId: string; + value: CallToolResult; + } | null>(null); + const scrollViewRef = useRef(null); + const listWidth = Math.floor(width * 0.4); + const detailWidth = width - listWidth; + + const selectedTask = tasks[selectedIndex] ?? null; + const selectedResult = + result && selectedTask && result.taskId === selectedTask.taskId + ? result.value + : null; + + /** Run one task operation, routing auth recovery and errors uniformly. */ + const run = async (label: string, op: () => Promise) => { + setBusy(label); + setError(null); + try { + await op(); + } catch (err) { + if (err instanceof AuthRecoveryRequiredError) { + onAuthRecoveryRequired?.(err); + return; + } + setError(errorMessage(err)); + } finally { + setBusy(null); + } + }; + + useInput( + (input: string, key: Key) => { + if (input === "f") { + // `run` owns every rejection (its catch surfaces the message), and a + // key handler cannot await. + void run("Refreshing…", async () => { + await onRefresh(); + }); + return; + } + if (input === "l") { + onClearCompleted(); + return; + } + if (input === "x" && selectedTask && inspectorClient) { + if (!isTaskActive(selectedTask.status)) return; + const { taskId } = selectedTask; + void run("Cancelling…", () => + inspectorClient.cancelRequestorTask(taskId), + ); + return; + } + if (key.return && selectedTask && inspectorClient) { + if (!hasTaskResult(selectedTask.status)) return; + const { taskId } = selectedTask; + void run("Fetching result…", async () => { + const value = await inspectorClient.getRequestorTaskResult(taskId); + setResult({ taskId, value }); + }); + return; + } + + if (focusedPane === "list") { + if (key.upArrow && selectedIndex > 0) { + setSelection(selectedIndex - 1); + } else if (key.downArrow && selectedIndex < tasks.length - 1) { + setSelection(selectedIndex + 1); + } + return; + } + + // Only "details" remains: the hook is inactive for any other pane. + if (input === "+" && selectedTask && onViewDetails) { + onViewDetails(selectedTask, selectedResult); + return; + } + if (key.upArrow) { + scrollViewRef.current?.scrollBy(-1); + } else if (key.downArrow) { + scrollViewRef.current?.scrollBy(1); + } else if (key.pageUp) { + const viewportHeight = scrollViewRef.current?.getViewportHeight() || 1; + scrollViewRef.current?.scrollBy(-viewportHeight); + } else if (key.pageDown) { + const viewportHeight = scrollViewRef.current?.getViewportHeight() || 1; + scrollViewRef.current?.scrollBy(viewportHeight); + } + }, + { + isActive: + !modalOpen && (focusedPane === "list" || focusedPane === "details"), + }, + ); + + // Reset scroll when selection changes + useEffect(() => { + scrollViewRef.current?.scrollTo(0); + }, [selectedIndex]); + + const selectedStyle = selectedTask + ? taskStatusStyle(selectedTask.status) + : null; + + return ( + + {/* Task list */} + + + + Tasks ({tasks.length}) + + + {tasks.length === 0 ? ( + + No tasks yet (f to refresh) + + ) : ( + + {tasks + .slice(firstVisible, firstVisible + visibleCount) + .map((task, i) => { + const index = firstVisible + i; + const isSelected = index === selectedIndex; + const style = taskStatusStyle(task.status); + return ( + + + {isSelected ? "▶ " : " "} + {style.glyph}{" "} + {task.taskId} + + + ); + })} + + )} + + + {/* Task details */} + + {selectedTask && selectedStyle ? ( + <> + + + {selectedTask.taskId} + + + + + + + Status:{" "} + + {selectedTask.status} + + + + {selectedTask.statusMessage && ( + + {selectedTask.statusMessage} + + )} + + Created: {selectedTask.createdAt} + + {selectedTask.lastUpdatedAt && ( + + Updated: {selectedTask.lastUpdatedAt} + + )} + + + TTL: {selectedTask.ttl === null ? "none" : selectedTask.ttl} + {selectedTask.pollInterval !== undefined && + ` Poll: ${selectedTask.pollInterval}ms`} + + + {busy && ( + + {busy} + + )} + {error && ( + + {error} + + )} + {selectedResult && ( + + Result: + + + {JSON.stringify(selectedResult, null, 2)} + + + + )} + + + + + {[ + hasTaskResult(selectedTask.status) && "Enter result", + isTaskActive(selectedTask.status) && "x cancel", + "f refresh", + "l clear done", + focusedPane === "details" && "+ zoom", + ] + .filter(Boolean) + .join(", ")} + + + + ) : ( + + Select a task to view details + {busy && {busy}} + {error && {error}} + + )} + + + ); +} diff --git a/clients/tui/src/components/tabsConfig.ts b/clients/tui/src/components/tabsConfig.ts index 7d52bd377a..c156765323 100644 --- a/clients/tui/src/components/tabsConfig.ts +++ b/clients/tui/src/components/tabsConfig.ts @@ -2,9 +2,11 @@ export type TabType = | "info" | "auth" | "resources" + | "subscriptions" | "prompts" | "skills" | "tools" + | "tasks" | "messages" | "requests" | "logging"; @@ -23,6 +25,9 @@ export const tabs: { id: TabType; label: string; accelerator: string }[] = [ { id: "info", label: "Info", accelerator: "i" }, { id: "auth", label: "Auth", accelerator: "a" }, { id: "resources", label: "Resources", accelerator: "r" }, + // `u`, not `s`: `s` is the only free letter in `Ta**s**ks`, so Subscriptions + // takes the earliest remaining letter in its own word instead (#2432). + { id: "subscriptions", label: "Subscriptions", accelerator: "u" }, { id: "prompts", label: "Prompts", accelerator: "m" }, // `k`, not `s`: `s` is not in conflict today, but the accelerator has to // appear in the label and be unique, and `S`kills against a future `S`ampling @@ -30,6 +35,10 @@ export const tabs: { id: TabType; label: string; accelerator: string }[] = [ // remaining letter in the word after `s` and `i` (Info). { id: "skills", label: "Skills", accelerator: "k" }, { id: "tools", label: "Tools", accelerator: "t" }, + // `s` is the only letter of `Tasks` not already taken (t/a/k). AuthTab binds + // `s` locally to "clear OAuth state", so App suppresses this accelerator + // while the Auth pane holds focus — the same carve-out the step-up `a` gets. + { id: "tasks", label: "Tasks", accelerator: "s" }, { id: "messages", label: "Protocol", accelerator: "p" }, { id: "requests", label: "Network", accelerator: "n" }, { id: "logging", label: "Console", accelerator: "o" }, @@ -41,6 +50,16 @@ export interface TabVisibility { showLogging: boolean; showRequests: boolean; showSkills: boolean; + /** + * Shown only when the connected server advertised `resources.subscribe` — + * there is nothing to subscribe to otherwise (#2432). + */ + showSubscriptions?: boolean; + /** + * Shown only when the connected server supports Tasks: the legacy + * `capabilities.tasks` or the negotiated SEP-2663 extension (#2432). + */ + showTasks?: boolean; } /** @@ -56,6 +75,8 @@ export function visibleTabs(v: TabVisibility): typeof tabs { if (tab.id === "logging") return v.showLogging; if (tab.id === "requests") return v.showRequests; if (tab.id === "skills") return v.showSkills; + if (tab.id === "subscriptions") return v.showSubscriptions ?? false; + if (tab.id === "tasks") return v.showTasks ?? false; return true; }); } diff --git a/clients/tui/src/utils/resourceUpdates.ts b/clients/tui/src/utils/resourceUpdates.ts new file mode 100644 index 0000000000..fac2c66ad1 --- /dev/null +++ b/clients/tui/src/utils/resourceUpdates.ts @@ -0,0 +1,80 @@ +/** + * Pure helpers behind the TUI Subscriptions tab (#2432). + * + * The live feed is read out of the protocol message log the TUI already keeps + * (`MessageLogState`), rather than from a new store: every + * `notifications/resources/updated` the server sends is already recorded there + * with its timestamp, in both protocol eras, so a feed derived from it cannot + * disagree with the Protocol tab about what arrived. + */ +import type { Resource } from "@modelcontextprotocol/client"; +import type { + InspectorResourceSubscription, + MessageEntry, +} from "@inspector/core/mcp/types.js"; + +export const RESOURCE_UPDATED_METHOD = "notifications/resources/updated"; + +export interface ResourceUpdateEvent { + id: string; + timestamp: Date; + uri: string; +} + +/** + * Every `notifications/resources/updated` in the log, newest first. Entries + * without a string `params.uri` are skipped — they name nothing to show. + */ +export function resourceUpdateFeed( + messages: readonly MessageEntry[], +): ResourceUpdateEvent[] { + const feed: ResourceUpdateEvent[] = []; + for (const entry of messages) { + if (entry.direction !== "notification") continue; + const message = entry.message; + if (!("method" in message) || message.method !== RESOURCE_UPDATED_METHOD) { + continue; + } + const uri = message.params?.uri; + if (typeof uri !== "string") continue; + feed.push({ id: entry.id, timestamp: entry.timestamp, uri }); + } + return feed.reverse(); +} + +export interface SubscribableResource { + resource: Resource; + subscribed: boolean; + lastUpdated?: Date; +} + +/** + * The rows the Subscriptions tab lists: every listed resource, then any + * subscribed URI the list does not contain (a template-expanded URI, or one the + * server has since dropped), so an active subscription can always be found and + * cancelled from here. + */ +export function subscribableResources( + resources: readonly Resource[], + subscriptions: readonly InspectorResourceSubscription[], +): SubscribableResource[] { + const byUri = new Map(subscriptions.map((s) => [s.resource.uri, s])); + const rows: SubscribableResource[] = resources.map((resource) => { + const sub = byUri.get(resource.uri); + return { + resource, + subscribed: sub !== undefined, + lastUpdated: sub?.lastUpdated, + }; + }); + const listed = new Set(resources.map((r) => r.uri)); + for (const sub of subscriptions) { + if (listed.has(sub.resource.uri)) continue; + rows.push({ + resource: sub.resource, + subscribed: true, + lastUpdated: sub.lastUpdated, + }); + } + return rows; +} From 76b01370867d40d963c06f4abab320c0917b9ca9 Mon Sep 17 00:00:00 2001 From: cliffhall Date: Mon, 5 Oct 2026 08:12:09 -0400 Subject: [PATCH 2/5] fix(tui): route wrapped subscription auth errors to recovery; justify test casts (#2432) subscribeToResource/unsubscribeFromResource wrap every failure with the original as cause, so a direct instanceof never saw AuthRecoveryRequiredError. Dig it out with findNestedAuthError, and throw it wrapped in the test the way the real client does. Document the confined partial-fake double casts in the new tests and route App.test's through one typed helper. Co-Authored-By: Claude Opus 5.5 Signed-off-by: cliffhall --- clients/tui/__tests__/App.test.tsx | 22 +++++++++++-------- clients/tui/__tests__/RootsModal.test.tsx | 4 ++++ .../tui/__tests__/SubscriptionsTab.test.tsx | 11 +++++++++- clients/tui/__tests__/TasksTab.test.tsx | 5 +++++ .../tui/src/components/SubscriptionsTab.tsx | 13 ++++++++--- 5 files changed, 42 insertions(+), 13 deletions(-) diff --git a/clients/tui/__tests__/App.test.tsx b/clients/tui/__tests__/App.test.tsx index 021d144dc0..6d70a59252 100644 --- a/clients/tui/__tests__/App.test.tsx +++ b/clients/tui/__tests__/App.test.tsx @@ -2250,6 +2250,16 @@ describe("App (keybinding help, #2436)", () => { }); }); +/** + * The FakeClient App built for the first server. `clientInstances` is typed by + * the narrow config/options shape the mount-option tests read, while the + * instance is the hoisted `FakeClient`, which `vi.hoisted` cannot export as a + * type; the double cast bridges exactly that gap, and `InstanceType` keeps the + * spies typed against the real class rather than an ad-hoc shape. + */ +const firstFakeClient = () => + h.clientInstances[0] as unknown as InstanceType; + describe("App (tasks, subscriptions, roots — #2432)", () => { const task = { taskId: "task-1", @@ -2278,9 +2288,7 @@ describe("App (tasks, subscriptions, roots — #2432)", () => { await expectFrame(r, "Subscriptions (1)"); await press(r, ["u", TAB, ENTER]); await expectFrame(r, "Subscriptions (1/1)"); - const client = h.clientInstances[0] as unknown as { - unsubscribeFromResource: ReturnType; - }; + const client = firstFakeClient(); await waitUntil(() => client.unsubscribeFromResource.mock.calls.length > 0); expect(client.unsubscribeFromResource).toHaveBeenCalledWith("file:///a"); }); @@ -2295,9 +2303,7 @@ describe("App (tasks, subscriptions, roots — #2432)", () => { await expectFrame(r, "task-1"); // Fetch the result, then zoom the details pane into the modal. await press(r, [ENTER, TAB]); - const client = h.clientInstances[0] as unknown as { - getRequestorTaskResult: ReturnType; - }; + const client = firstFakeClient(); await waitUntil(() => client.getRequestorTaskResult.mock.calls.length > 0); await press(r, ["+"]); await press(r, ["f", "l"]); @@ -2377,9 +2383,7 @@ describe("App (tasks, subscriptions, roots — #2432)", () => { // are InfoTab's to render (covered in InfoTab.test.tsx). await expectFrame(r, "Roots (1)"); await press(r, ["i", TAB, "e", "x"]); - const client = h.clientInstances[0] as unknown as { - setRoots: ReturnType; - }; + const client = firstFakeClient(); await waitUntil(() => client.setRoots.mock.calls.length > 0); expect(client.setRoots).toHaveBeenCalledWith([]); // Esc closes the modal rather than exiting the app, and `e` reopens it. diff --git a/clients/tui/__tests__/RootsModal.test.tsx b/clients/tui/__tests__/RootsModal.test.tsx index 510074eeb3..cca2031706 100644 --- a/clients/tui/__tests__/RootsModal.test.tsx +++ b/clients/tui/__tests__/RootsModal.test.tsx @@ -29,6 +29,10 @@ afterEach(() => { delete (globalThis as Record).__INK_FORM_SUBMIT_VALUE__; }); +// A deliberately partial fake: RootsModal calls only `setRoots` on the client, +// and InspectorClient is a class with private members that no structural object +// literal can satisfy, so a single `as` is refused. The double cast is confined +// to this factory, and the intersection keeps `setRoots` typed as the spy. const fakeClient = ( setRoots: ReturnType = vi.fn(async () => {}), ) => diff --git a/clients/tui/__tests__/SubscriptionsTab.test.tsx b/clients/tui/__tests__/SubscriptionsTab.test.tsx index 8b11ac83bd..0d65220316 100644 --- a/clients/tui/__tests__/SubscriptionsTab.test.tsx +++ b/clients/tui/__tests__/SubscriptionsTab.test.tsx @@ -46,6 +46,11 @@ interface FakeOps { unsubscribeFromResource: ReturnType; } +// A deliberately partial fake: SubscriptionsTab calls only the two methods in +// `FakeOps` on the client, and InspectorClient is a class with private members +// that no structural object literal can satisfy, so a single `as` is refused. +// The double cast is confined to this factory, and `FakeOps` keeps the methods +// the tests assert on typed. const fakeClient = (over: Partial = {}): FakeOps & InspectorClient => ({ subscribeToResource: vi.fn(async () => {}), @@ -164,8 +169,12 @@ describe("SubscriptionsTab", () => { CHALLENGE, ); const client = fakeClient({ + // Thrown the way the real client throws it: wrapped, with the recovery + // error as `cause` (inspectorClient.ts subscribeToResource). subscribeToResource: vi.fn(async () => { - throw recovery; + throw new Error("Failed to subscribe to resource: auth", { + cause: recovery, + }); }), }); const onAuthRecoveryRequired = vi.fn(); diff --git a/clients/tui/__tests__/TasksTab.test.tsx b/clients/tui/__tests__/TasksTab.test.tsx index 625a2d468d..886b133415 100644 --- a/clients/tui/__tests__/TasksTab.test.tsx +++ b/clients/tui/__tests__/TasksTab.test.tsx @@ -41,6 +41,11 @@ interface FakeOps { getRequestorTaskResult: ReturnType; } +// A deliberately partial fake: TasksTab calls only the two methods in +// `FakeOps` on the client, and InspectorClient is a class with private members +// that no structural object literal can satisfy, so a single `as` is refused. +// The double cast is confined to this factory, and `FakeOps` keeps the methods +// the tests assert on typed. const fakeClient = (over: Partial = {}): FakeOps & InspectorClient => ({ cancelRequestorTask: vi.fn(async () => {}), diff --git a/clients/tui/src/components/SubscriptionsTab.tsx b/clients/tui/src/components/SubscriptionsTab.tsx index e618c0c281..ab7dccfc00 100644 --- a/clients/tui/src/components/SubscriptionsTab.tsx +++ b/clients/tui/src/components/SubscriptionsTab.tsx @@ -23,7 +23,10 @@ import type { MessageEntry, ResourceSubscriptionStreamState, } from "@inspector/core/mcp/types.js"; -import { AuthRecoveryRequiredError } from "@inspector/core/auth/challenge.js"; +import { + AuthRecoveryRequiredError, + findNestedAuthError, +} from "@inspector/core/auth/challenge.js"; import { useSelectableList } from "../hooks/useSelectableList.js"; import { resourceUpdateFeed, @@ -99,8 +102,12 @@ export function SubscriptionsTab({ await inspectorClient.subscribeToResource(uri); } } catch (err) { - if (err instanceof AuthRecoveryRequiredError) { - onAuthRecoveryRequired?.(err); + // `subscribeToResource` / `unsubscribeFromResource` wrap every failure in + // a new Error with the original as `cause`, so the recovery signal has to + // be dug out of the chain rather than matched on the thrown value. + const authErr = findNestedAuthError(err); + if (authErr instanceof AuthRecoveryRequiredError) { + onAuthRecoveryRequired?.(authErr); return; } setError(err instanceof Error ? err.message : String(err)); From 7b322afaf0dfc6ab1e62e88d02e8ce73fbdc1215 Mon Sep 17 00:00:00 2001 From: cliffhall Date: Mon, 5 Oct 2026 08:44:15 -0400 Subject: [PATCH 3/5] fix: own task auto-refresh rejections; serialize TUI task actions (#2432) ManagedRequestorTasksState's connect/tasksListChanged listeners voided refresh(), so a rejected tasks/list escaped as an unhandled rejection -- fatal in a Node host like the TUI, which now constructs this store. The listeners now catch it; a caller's explicit refresh() still rejects. TasksTab ignores a key while an operation is in flight, so repeated f presses cannot run overlapping paginated refreshes. Also update the Skills accelerator comment now that s belongs to Tasks. Co-Authored-By: Claude Opus 5.5 Signed-off-by: cliffhall --- clients/tui/__tests__/TasksTab.test.tsx | 8 +++++++- clients/tui/src/components/TasksTab.tsx | 8 ++++++++ clients/tui/src/components/tabsConfig.ts | 7 +++---- .../mcp/state/managedRequestorTasksState.test.ts | 15 +++++++++++++++ core/mcp/state/managedRequestorTasksState.ts | 12 ++++++++++-- 5 files changed, 43 insertions(+), 7 deletions(-) diff --git a/clients/tui/__tests__/TasksTab.test.tsx b/clients/tui/__tests__/TasksTab.test.tsx index 886b133415..87021bef93 100644 --- a/clients/tui/__tests__/TasksTab.test.tsx +++ b/clients/tui/__tests__/TasksTab.test.tsx @@ -175,10 +175,16 @@ describe("TasksTab", () => { const pending = new Promise((resolve) => { release = () => resolve([]); }); - const { stdin, lastFrame } = renderTab({ onRefresh: () => pending }); + const onRefresh = vi.fn(() => pending); + const { stdin, lastFrame } = renderTab({ onRefresh }); stdin.write("f"); await tick(); expect(lastFrame()).toContain("Refreshing…"); + // A second press while the first is in flight starts nothing. + stdin.write("f"); + stdin.write("x"); + await tick(); + expect(onRefresh).toHaveBeenCalledTimes(1); release(); await tick(); expect(lastFrame()).not.toContain("Refreshing…"); diff --git a/clients/tui/src/components/TasksTab.tsx b/clients/tui/src/components/TasksTab.tsx index 3b7476d668..3ddbe4be32 100644 --- a/clients/tui/src/components/TasksTab.tsx +++ b/clients/tui/src/components/TasksTab.tsx @@ -100,8 +100,15 @@ export function TasksTab({ ? result.value : null; + // One operation at a time. A ref rather than `busy`, because two keypresses + // can land before the state update re-renders, and overlapping refreshes + // walk the store's paginated list concurrently and duplicate rows. + const inFlightRef = useRef(false); + /** Run one task operation, routing auth recovery and errors uniformly. */ const run = async (label: string, op: () => Promise) => { + if (inFlightRef.current) return; + inFlightRef.current = true; setBusy(label); setError(null); try { @@ -113,6 +120,7 @@ export function TasksTab({ } setError(errorMessage(err)); } finally { + inFlightRef.current = false; setBusy(null); } }; diff --git a/clients/tui/src/components/tabsConfig.ts b/clients/tui/src/components/tabsConfig.ts index c156765323..bfb954aa55 100644 --- a/clients/tui/src/components/tabsConfig.ts +++ b/clients/tui/src/components/tabsConfig.ts @@ -29,10 +29,9 @@ export const tabs: { id: TabType; label: string; accelerator: string }[] = [ // takes the earliest remaining letter in its own word instead (#2432). { id: "subscriptions", label: "Subscriptions", accelerator: "u" }, { id: "prompts", label: "Prompts", accelerator: "m" }, - // `k`, not `s`: `s` is not in conflict today, but the accelerator has to - // appear in the label and be unique, and `S`kills against a future `S`ampling - // or `S`ettings is the collision this rule anticipates. `k` is the earliest - // remaining letter in the word after `s` and `i` (Info). + // `k`, not `s`: `s` is reserved for Tasks (Ta**s**ks has no other free + // letter), and the accelerator has to appear in the label and be unique. + // `k` is the earliest remaining letter in the word after `s` and `i` (Info). { id: "skills", label: "Skills", accelerator: "k" }, { id: "tools", label: "Tools", accelerator: "t" }, // `s` is the only letter of `Tasks` not already taken (t/a/k). AuthTab binds 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 b96c42d822..9d18f11457 100644 --- a/clients/web/src/test/core/mcp/state/managedRequestorTasksState.test.ts +++ b/clients/web/src/test/core/mcp/state/managedRequestorTasksState.test.ts @@ -182,6 +182,21 @@ describe("ManagedRequestorTasksState", () => { expect(next.map((t) => t.taskId)).toEqual(["t1", "t2"]); }); + it("owns a rejected automatic refresh instead of leaking it (#2432)", async () => { + // A leaked rejection fails this run as an unhandled error; the explicit + // refresh afterwards proves the store still works and the list held. + client.setStatus("connected"); + client.listRequestorTasks.mockRejectedValueOnce(new Error("tasks/list")); + client.listRequestorTasks.mockRejectedValueOnce(new Error("tasks/list")); + client.dispatchTypedEvent("connect"); + client.dispatchTypedEvent("tasksListChanged"); + await new Promise((resolve) => setTimeout(resolve, 0)); + expect(client.listRequestorTasks).toHaveBeenCalledTimes(2); + expect(state.getTasks()).toEqual([]); + client.queueTaskPages({ tasks: [task("t1")] }); + await expect(state.refresh()).resolves.toHaveLength(1); + }); + it("statusChange to disconnected clears tasks and dispatches tasksChange", async () => { client.setStatus("connected"); client.queueTaskPages({ tasks: [task("t1")] }); diff --git a/core/mcp/state/managedRequestorTasksState.ts b/core/mcp/state/managedRequestorTasksState.ts index 20576d5fc4..d8a8499061 100644 --- a/core/mcp/state/managedRequestorTasksState.ts +++ b/core/mcp/state/managedRequestorTasksState.ts @@ -55,11 +55,19 @@ export class ManagedRequestorTasksState extends TypedEventTarget { + this.refresh().catch(() => {}); + }; const onConnect = (): void => { - void this.refresh(); + refreshQuietly(); }; const onTasksListChanged = (): void => { - void this.refresh(); + refreshQuietly(); }; const onStatusChange = (): void => { if (isTerminalStatus(this.client?.getStatus())) { From d9eb21e13d51297c2086b3200c24887fc9ce2a7b Mon Sep 17 00:00:00 2001 From: cliffhall Date: Mon, 5 Oct 2026 09:53:20 -0400 Subject: [PATCH 4/5] fix(tui): guard roots, subscription and clear actions against re-entry (#2432) React state cannot guard two keys delivered before a re-render, so a burst could save roots twice from the same stale list or send two resources/subscribe requests. Add ref guards beside the status state, and ignore task clear while a refresh is walking the store's list. Co-Authored-By: Claude Opus 5.5 Signed-off-by: cliffhall --- clients/tui/__tests__/RootsModal.test.tsx | 2 ++ clients/tui/__tests__/SubscriptionsTab.test.tsx | 2 ++ clients/tui/__tests__/TasksTab.test.tsx | 1 + clients/tui/src/components/RootsModal.tsx | 11 +++++++++-- clients/tui/src/components/SubscriptionsTab.tsx | 11 +++++++++-- clients/tui/src/components/TasksTab.tsx | 3 +++ 6 files changed, 26 insertions(+), 4 deletions(-) diff --git a/clients/tui/__tests__/RootsModal.test.tsx b/clients/tui/__tests__/RootsModal.test.tsx index cca2031706..9ad5219f13 100644 --- a/clients/tui/__tests__/RootsModal.test.tsx +++ b/clients/tui/__tests__/RootsModal.test.tsx @@ -175,6 +175,8 @@ describe("RootsModal", () => { ); const { stdin } = renderModal({ inspectorClient: fakeClient(setRoots) }); await tick(); + // Two keys in one burst, before any re-render, still save once. + stdin.write("x"); stdin.write("x"); await tick(); stdin.write("x"); diff --git a/clients/tui/__tests__/SubscriptionsTab.test.tsx b/clients/tui/__tests__/SubscriptionsTab.test.tsx index 0d65220316..4af825d403 100644 --- a/clients/tui/__tests__/SubscriptionsTab.test.tsx +++ b/clients/tui/__tests__/SubscriptionsTab.test.tsx @@ -130,6 +130,8 @@ describe("SubscriptionsTab", () => { }); const client = fakeClient({ subscribeToResource: vi.fn(() => pending) }); const { stdin, lastFrame } = renderTab({ inspectorClient: client }); + // Two presses in one burst, before any re-render, start one request. + stdin.write("\r"); stdin.write("\r"); await tick(); expect(lastFrame()).toContain("Updating subscription…"); diff --git a/clients/tui/__tests__/TasksTab.test.tsx b/clients/tui/__tests__/TasksTab.test.tsx index 87021bef93..6984d887dd 100644 --- a/clients/tui/__tests__/TasksTab.test.tsx +++ b/clients/tui/__tests__/TasksTab.test.tsx @@ -183,6 +183,7 @@ describe("TasksTab", () => { // A second press while the first is in flight starts nothing. stdin.write("f"); stdin.write("x"); + stdin.write("l"); await tick(); expect(onRefresh).toHaveBeenCalledTimes(1); release(); diff --git a/clients/tui/src/components/RootsModal.tsx b/clients/tui/src/components/RootsModal.tsx index efa8a25f4f..f503a63a79 100644 --- a/clients/tui/src/components/RootsModal.tsx +++ b/clients/tui/src/components/RootsModal.tsx @@ -12,7 +12,7 @@ * save takes. The change is for this session only: the TUI does not write * `mcp.json`, so the configured roots return on the next launch. */ -import React, { useState } from "react"; +import React, { useRef, useState } from "react"; import { Box, Text, useInput, type Key } from "ink"; import { Form, type FormStructure } from "ink-form"; import type { Root } from "@modelcontextprotocol/client"; @@ -75,8 +75,14 @@ export function RootsModal({ visibleCount, ); + // `saving` drives the status line; this ref is the guard, because two keys + // can land before React re-renders and both would save from the same stale + // `roots`, announcing roots/list_changed twice for one edit. + const savingRef = useRef(false); + const save = async (next: Root[]) => { - if (!inspectorClient) return; + if (!inspectorClient || savingRef.current) return; + savingRef.current = true; setSaving(true); setError(null); try { @@ -85,6 +91,7 @@ export function RootsModal({ } catch (err) { setError(err instanceof Error ? err.message : String(err)); } finally { + savingRef.current = false; setSaving(false); } }; diff --git a/clients/tui/src/components/SubscriptionsTab.tsx b/clients/tui/src/components/SubscriptionsTab.tsx index ab7dccfc00..d216604f95 100644 --- a/clients/tui/src/components/SubscriptionsTab.tsx +++ b/clients/tui/src/components/SubscriptionsTab.tsx @@ -90,8 +90,14 @@ export function SubscriptionsTab({ const detailWidth = width - listWidth; const selected = rows[selectedIndex] ?? null; + // `pendingUri` drives the status line; this ref is the guard, because two + // Enter presses can land before React re-renders and both would start a + // request (two `resources/subscribe` on the legacy era). + const inFlightRef = useRef(false); + const toggle = async (row: (typeof rows)[number]) => { - if (!inspectorClient) return; + if (!inspectorClient || inFlightRef.current) return; + inFlightRef.current = true; const { uri } = row.resource; setPendingUri(uri); setError(null); @@ -112,13 +118,14 @@ export function SubscriptionsTab({ } setError(err instanceof Error ? err.message : String(err)); } finally { + inFlightRef.current = false; setPendingUri(null); } }; useInput( (_input: string, key: Key) => { - if (key.return && selected && pendingUri === null) { + if (key.return && selected) { // `toggle` owns every rejection (its catch surfaces the message), and // a key handler cannot await. void toggle(selected); diff --git a/clients/tui/src/components/TasksTab.tsx b/clients/tui/src/components/TasksTab.tsx index 3ddbe4be32..fa55a7122e 100644 --- a/clients/tui/src/components/TasksTab.tsx +++ b/clients/tui/src/components/TasksTab.tsx @@ -136,6 +136,9 @@ export function TasksTab({ return; } if (input === "l") { + // Not during a refresh: the store has already emptied its list for the + // page walk, so a clear now is lost and the tasks reappear after it. + if (inFlightRef.current) return; onClearCompleted(); return; } From 1248fd9d7d16dc479cac1991249ad4e701df119b Mon Sep 17 00:00:00 2001 From: cliffhall Date: Mon, 5 Oct 2026 12:41:45 -0400 Subject: [PATCH 5/5] fix(tui): wire Tasks, Subscriptions and Roots into copy and help (#2432) Integration with the TUI PRs merged ahead of this one, as proven together on the #2567 rollup (#2601): - #2421 made `copyText` required on the details dialog; the Tasks tab's `+` zoom now supplies it (the task, plus its result once fetched). - #2436's help table is typed per tab, so the new Subscriptions and Tasks tabs get rows, and the Info tab lists `e` (edit roots). - The roots editor and the help overlay both suppress global input, and the help's visible-tab list includes the new tabs (merge resolution). Co-Authored-By: Claude Opus 5.5 Signed-off-by: cliffhall --- clients/tui/src/App.tsx | 3 +++ clients/tui/src/utils/keybindings.ts | 18 +++++++++++++++++- 2 files changed, 20 insertions(+), 1 deletion(-) diff --git a/clients/tui/src/App.tsx b/clients/tui/src/App.tsx index 1f4c31bc4c..754cd859e0 100644 --- a/clients/tui/src/App.tsx +++ b/clients/tui/src/App.tsx @@ -2296,6 +2296,9 @@ function App({ setDetailsModal({ title: `Task: ${task.taskId}`, content: renderTaskDetails(task, result), + copyText: toCopyText( + result === null ? task : { task, result }, + ), }) } onAuthRecoveryRequired={onAuthRecoveryRequired} diff --git a/clients/tui/src/utils/keybindings.ts b/clients/tui/src/utils/keybindings.ts index a173d93e7c..b9f6eeb054 100644 --- a/clients/tui/src/utils/keybindings.ts +++ b/clients/tui/src/utils/keybindings.ts @@ -78,7 +78,10 @@ const PANE_SCROLL: readonly KeyBinding[] = [ /** Bindings specific to one tab, shown only while that tab is active. */ export const TAB_BINDINGS: Readonly> = { - info: PANE_SCROLL, + info: [ + ...PANE_SCROLL, + { keys: "e", action: "Edit the advertised roots (content focused)" }, + ], auth: [ ...PANE_SCROLL, { keys: "s", action: "Clear OAuth state (disconnects if connected)" }, @@ -127,6 +130,19 @@ export const TAB_BINDINGS: Readonly> = { ...DETAILS_SCROLL, ], logging: PANE_SCROLL, + subscriptions: [ + { keys: "↑/↓", action: "Select a resource (list focused)" }, + { keys: "Enter", action: "Subscribe to or unsubscribe from the resource" }, + ...PANE_SCROLL, + ], + tasks: [ + { keys: "↑/↓", action: "Select a task (list focused)" }, + { keys: "Enter", action: "Fetch the task's result" }, + { keys: "x", action: "Cancel the selected task" }, + { keys: "f", action: "Refresh the task list" }, + { keys: "l", action: "Clear finished tasks" }, + ...DETAILS_SCROLL, + ], }; /** Bindings inside the help overlay itself. */