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
1 change: 1 addition & 0 deletions clients/tui/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -83,6 +83,7 @@ The TUI provides terminal-native tabs and panes for interacting with your MCP se
- **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)).
- **Sampling and elicitation requests**: The TUI advertises `sampling` and both elicitation modes (`form` and `url`), and a server request that arrives while a call is in flight (`sampling/createMessage`, `elicitation/create`) opens a full-screen modal above everything else. Requests are answered one at a time, sampling first, through the same core queue the web client uses. A **sampling** request shows its messages, system prompt and token budget above a response form (text, model, stop reason). Submitting the form approves it. A **form elicitation** renders its requested schema with the tool form, and submitting it accepts. In both, **Escape** opens a one-line prompt (`r` reject for sampling; `d` decline or `c` cancel for elicitation), and any other key returns to the form without losing what was typed. A **URL elicitation** shows the message and URL: `o` opens it in the browser (http/https only), **Enter** reports it done, `d` declines and **Escape** cancels ([#2829](https://github.com/modelcontextprotocol/inspector/issues/2829)).
- **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. **`v`** verifies every skill in the listing in one run — the whole catalog, even while a `/` filter narrows the list — under the server's [catalog budget](../../docs/mcp-server-configuration.md#inspector-specific-per-server-fields); a line under the list tallies the verdicts held for the current listing (`✓` verified, `✗` failed, `…` stopped by the budget, `?` unverifiable, `·` not yet checked) ([#2590](https://github.com/modelcontextprotocol/inspector/issues/2590)). 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).
Expand Down
101 changes: 101 additions & 0 deletions clients/tui/__tests__/App.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,8 @@ const h = vi.hoisted(() => {
subscriptions: unknown[];
roots: unknown[];
tasksExtension: boolean;
pendingSamples: unknown[];
pendingElicitations: unknown[];
}
const ctrl: Ctrl = {
status: "disconnected",
Expand All @@ -65,6 +67,8 @@ const h = vi.hoisted(() => {
subscriptions: [],
roots: [],
tasksExtension: false,
pendingSamples: [],
pendingElicitations: [],
};
const refreshTasks = vi.fn(async () => []);
const clearCompletedTasks = vi.fn();
Expand Down Expand Up @@ -146,6 +150,8 @@ const h = vi.hoisted(() => {
defaultMetadata?: unknown;
environment?: { fetch?: unknown; oauthFetch?: unknown };
connectionTimeoutRemedy?: string;
sample?: unknown;
elicit?: unknown;
};
}> = [];
// What the mocked `createProxyFetch` returns. `undefined` is the real
Expand Down Expand Up @@ -315,6 +321,10 @@ const h = vi.hoisted(() => {
// 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),
usePendingClientRequests: vi.fn(() => ({
pendingSamples: ctrl.pendingSamples,
pendingElicitations: ctrl.pendingElicitations,
})),
};
});

Expand Down Expand Up @@ -379,6 +389,9 @@ vi.mock("@inspector/core/react/useResourceSubscriptions.js", () => ({
vi.mock("@inspector/core/react/useStoreSnapshot.js", () => ({
useStoreSnapshot: h.useStoreSnapshot,
}));
vi.mock("@inspector/core/react/usePendingClientRequests.js", () => ({
usePendingClientRequests: h.usePendingClientRequests,
}));
vi.mock("@inspector/core/auth/index.js", async (importOriginal) => {
const actual =
await importOriginal<typeof import("@inspector/core/auth/index.js")>();
Expand Down Expand Up @@ -773,6 +786,8 @@ beforeEach(() => {
subscriptions: [],
roots: [],
tasksExtension: false,
pendingSamples: [],
pendingElicitations: [],
});
h.refreshTasks.mockClear();
h.clearCompletedTasks.mockClear();
Expand Down Expand Up @@ -2538,3 +2553,89 @@ describe("App (tasks, subscriptions, roots — #2432)", () => {
expect(client.setRoots).toHaveBeenCalledTimes(2);
});
});

describe("App (pending sampling / elicitation requests, #2829)", () => {
it("advertises sampling and both elicitation modes", async () => {
const r = await mount(oneStdio());
await expectFrame(r, "MCP Servers");
const client = h.clientInstances.at(-1);
expect(client?.opts?.sample).toBe(true);
expect(client?.opts?.elicit).toEqual({ form: true, url: true });
});

it("answers a sampling request over the tool modal without closing it", async () => {
h.ctrl.status = "connected";
h.ctrl.tools = [sampleTool];
const r = await mount(oneStdio());
await press(r, ["t", TAB, ENTER]);
await expectFrame(r, "Test Tool: alpha");

const sample = {
id: "s1",
origin: "server-request",
request: {
params: {
maxTokens: 10,
messages: [{ role: "user", content: { type: "text", text: "Hi" } }],
},
},
respond: vi.fn(async () => {}),
reject: vi.fn(async () => {}),
};
h.ctrl.pendingSamples = [sample];
r.rerender(
<App
mcpServers={oneStdio()}
clientConfig={emptyClientConfig}
callbackUrlConfig={callbackUrlConfig}
/>,
);
await expectFrame(r, "Sampling request");

// Esc belongs to the request now: it opens the reject prompt rather than
// closing the tool modal underneath.
await press(r, [ESC]);
await expectFrame(r, "Reject this sampling request?");
await press(r, ["r"]);
expect(sample.reject).toHaveBeenCalledTimes(1);

h.ctrl.pendingSamples = [];
r.rerender(
<App
mcpServers={oneStdio()}
clientConfig={emptyClientConfig}
callbackUrlConfig={callbackUrlConfig}
/>,
);
await expectFrame(r, "Test Tool: alpha");
expect(r.lastFrame() ?? "").not.toContain("Sampling request");
await press(r, [ESC]);
expect(r.lastFrame() ?? "").not.toContain("Test Tool: alpha");
});

it("holds the global accelerators while a request is open", async () => {
h.ctrl.status = "connected";
h.ctrl.pendingElicitations = [
{
id: "e1",
origin: "server-request",
request: {
params: {
message: "Pick a name",
requestedSchema: {
type: "object",
properties: { name: { type: "string" } },
},
},
},
respond: vi.fn(async () => {}),
reject: vi.fn(),
},
];
const r = await mount(oneStdio());
await expectFrame(r, "Elicitation request");
await expectFrame(r, "Pick a name");
await press(r, ["?"]);
expect(r.lastFrame() ?? "").not.toContain("Keyboard shortcuts");
});
});
Loading
Loading