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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions clients/tui/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 `<tool-name>-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).
Expand Down
191 changes: 191 additions & 0 deletions clients/tui/__tests__/App.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand All @@ -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);
Expand Down Expand Up @@ -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<InspectorClient["authenticate"]>) =>
clientSpies.authenticate(...a);
clearOAuthTokens = (
Expand Down Expand Up @@ -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),
};
});

Expand All @@ -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(),
Expand Down Expand Up @@ -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<typeof import("@inspector/core/auth/index.js")>();
Expand Down Expand Up @@ -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();
Expand Down Expand Up @@ -2201,3 +2249,146 @@ describe("App (keybinding help, #2436)", () => {
expect(r.lastFrame() ?? "").not.toContain("Keyboard shortcuts");
});
});

/**
* 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<typeof h.FakeClient>;

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 = firstFakeClient();
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 = firstFakeClient();
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(
<App
mcpServers={oneStdio()}
clientConfig={emptyClientConfig}
callbackUrlConfig={callbackUrlConfig}
/>,
);
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 = 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.
await press(r, [ESC, "e", "x"]);
await waitUntil(() => client.setRoots.mock.calls.length > 1);
expect(client.setRoots).toHaveBeenCalledTimes(2);
});
});
68 changes: 68 additions & 0 deletions clients/tui/__tests__/InfoTab.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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(
<InfoTab
serverName="my-server"
serverConfig={stdioConfig}
serverState={baseState}
width={80}
height={60}
roots={[{ uri: "file:///a", name: "alpha" }, { uri: "file:///b" }]}
/>,
);
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(
<InfoTab
serverName="my-server"
serverConfig={stdioConfig}
serverState={baseState}
width={80}
height={60}
/>,
);
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(
<InfoTab
serverName="my-server"
serverConfig={stdioConfig}
serverState={baseState}
width={80}
height={60}
focused
onEditRoots={onEditRoots}
/>,
);
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(
<InfoTab
serverName="my-server"
serverConfig={stdioConfig}
serverState={baseState}
width={80}
height={60}
focused
/>,
);
stdin.write("e");
await tick();
expect(lastFrame() ?? "").not.toContain("e to edit roots");
});
});
});
Loading
Loading