Merge branch 'v2/main' into v2/feat/2144-oauth-revocation
This commit is contained in:
commit
5ba2fadd3b
22 changed files with 3326 additions and 1516 deletions
45
README.md
45
README.md
|
|
@ -165,6 +165,51 @@ Each config below is a ready-made server for exercising one feature by hand. Loa
|
|||
| `subscriptions-{legacy,modern}-http.json` | Resource subscriptions, both eras | [#1630](https://github.com/modelcontextprotocol/inspector/issues/1630) |
|
||||
| `subscriptions-never-acknowledged-http.json` | A `subscriptions/listen` answered with a bare result | [#2097](https://github.com/modelcontextprotocol/inspector/issues/2097) |
|
||||
| `tasks-{legacy,modern}-http.json` | Tasks, both eras | [#1631](https://github.com/modelcontextprotocol/inspector/issues/1631) |
|
||||
| `cancellation-modern-http.json` | Cancelling a call by closing its response stream | [#2140](https://github.com/modelcontextprotocol/inspector/issues/2140) |
|
||||
|
||||
#### Cancelling a call
|
||||
|
||||
`cancellation-modern-http.json` serves `slow_task`, which reports progress once
|
||||
a second for up to 60 seconds and stops early if it is cancelled, printing how
|
||||
far it got to **the server's terminal**. Connect with **Protocol Era = Modern**.
|
||||
|
||||
Watch the terminal you started the server in, run `slow_task` from the Tools
|
||||
tab, and click **Cancel** after a few seconds. The progress must stop
|
||||
immediately, the Inspector must report the call cancelled, and the server must
|
||||
print `[slow_task] cancelled after Ns`. On the broken build the progress kept
|
||||
arriving until the tool completed all 60 seconds and the server printed
|
||||
`completed all 60s without being cancelled`, because the Inspector was sending
|
||||
the wrong cancellation signal
|
||||
([#2140](https://github.com/modelcontextprotocol/inspector/issues/2140)).
|
||||
|
||||
The server's terminal is the place to watch, not the Inspector's result panel:
|
||||
cancellation closes the very stream the tool's result would travel on, so on a
|
||||
successful cancel the tool's return value is undeliverable by construction and
|
||||
the Inspector shows a cancelled call rather than a result. Which is the point —
|
||||
what has to stop is the *work*, and only the server can report that.
|
||||
|
||||
The 2026-07-28 spec makes this
|
||||
[transport-specific](https://modelcontextprotocol.io/specification/2026-07-28/basic/patterns/cancellation#transport-specific-cancellation):
|
||||
for Streamable HTTP, **closing the request's SSE response stream is the
|
||||
cancellation signal**, and a `notifications/cancelled` is "neither required nor
|
||||
expected"; stdio, which has no per-request stream to close, keeps the
|
||||
notification. A spec-compliant server therefore answers the notification `202
|
||||
Accepted` and drops it — which is precisely what the reporter observed, with the
|
||||
task running on to completion while the Inspector reported it cancelled.
|
||||
|
||||
The SDK already implements that fork, off `transport.hasPerRequestStream`. Every
|
||||
Inspector connection is wrapped in `MessageTrackingTransport` (it feeds the
|
||||
Protocol and Network tabs), which did not forward the flag — so the SDK saw
|
||||
`undefined` and took the stdio branch on **every** client, CLI and TUI included.
|
||||
The web client needed two more links in the chain: its browser-side transport
|
||||
answers for the real upstream one that lives on the Node backend, and the abort
|
||||
has to survive the `POST /api/mcp/send` hop to reach it.
|
||||
|
||||
Watch it on the wire in the **Protocol** tab: cancelling now emits no
|
||||
`notifications/cancelled` frame at all, and the `tools/call` entry ends as an
|
||||
aborted request rather than a completed one. Switch the same server to
|
||||
**Legacy** and the notification comes back — that era has no per-stream
|
||||
mechanism, so it is still the correct signal there.
|
||||
|
||||
#### MCP Apps
|
||||
|
||||
|
|
|
|||
|
|
@ -453,122 +453,67 @@ vi.mock("./lib/publishAppDocument", () => ({
|
|||
// that invoke the App's connect / call-tool / get-prompt / read-resource /
|
||||
// set-log-level handlers.
|
||||
vi.mock("./components/views/InspectorView/InspectorView", () => ({
|
||||
InspectorView: (props: {
|
||||
toolCallState?: { status?: string };
|
||||
toolsUi?: {
|
||||
selectedToolKey?: string;
|
||||
formValues: Record<string, unknown>;
|
||||
search: string;
|
||||
};
|
||||
promptsUi?: {
|
||||
selectedPromptName?: string;
|
||||
argumentValues: Record<string, string>;
|
||||
submittedFor?: string;
|
||||
search: string;
|
||||
};
|
||||
logsUi?: { filterText: string; visibleLevels: Record<string, boolean> };
|
||||
getPromptState?: { status?: string };
|
||||
readResourceState?: { status?: string };
|
||||
currentLogLevel?: string;
|
||||
activeTab?: string;
|
||||
activeServer?: string;
|
||||
erroredServerId?: string;
|
||||
initializeResult?: { serverInfo: { name: string; version: string } };
|
||||
onActiveTabChange: (tab: string) => void;
|
||||
onConnectionInfo: () => void;
|
||||
onToggleConnection: (id: string) => void;
|
||||
onToolsUiChange: (next: {
|
||||
selectedToolKey?: string;
|
||||
formValues: Record<string, unknown>;
|
||||
search: string;
|
||||
}) => void;
|
||||
onPromptsUiChange: (next: {
|
||||
selectedPromptName?: string;
|
||||
argumentValues: Record<string, string>;
|
||||
submittedFor?: string;
|
||||
search: string;
|
||||
}) => void;
|
||||
onLogsUiChange: (next: {
|
||||
filterText: string;
|
||||
visibleLevels: Record<string, boolean>;
|
||||
}) => void;
|
||||
progressByTaskId?: Record<string, unknown>;
|
||||
onCallTool: (
|
||||
name: string,
|
||||
args: Record<string, unknown>,
|
||||
runAsTask?: boolean,
|
||||
) => void;
|
||||
onGetPrompt: (name: string, args: Record<string, string>) => void;
|
||||
onReadResource: (uri: string) => void;
|
||||
onSetLogLevel: (level: string) => void;
|
||||
onCancelTask: (taskId: string) => void;
|
||||
onCancelToolCall: () => void;
|
||||
onClearCompletedTasks: () => void;
|
||||
onRefreshTasks: () => void;
|
||||
onServerSettings: (id: string) => void;
|
||||
onServerEdit: (id: string) => void;
|
||||
onServerAdd: () => void;
|
||||
highlightedServerIds?: string[];
|
||||
onClearProtocol: () => void;
|
||||
onReplayProtocol: (id: string) => void;
|
||||
onTogglePinProtocol: (id: string) => void;
|
||||
pinnedProtocolIds?: Set<string>;
|
||||
onRefreshTools: () => void;
|
||||
toolsPagination: {
|
||||
paginated: boolean;
|
||||
canLoadMore: boolean;
|
||||
loadedPages: number;
|
||||
onPaginatedChange: (v: boolean) => void;
|
||||
onLoadMore: () => void;
|
||||
};
|
||||
}) => (
|
||||
InspectorView: (props: InspectorViewProps) => (
|
||||
<div>
|
||||
<span data-testid="tool-status">
|
||||
{props.toolCallState?.status ?? "none"}
|
||||
{props.tools.toolCallState?.status ?? "none"}
|
||||
</span>
|
||||
<span data-testid="task-progress-keys">
|
||||
{Object.keys(props.progressByTaskId ?? {}).join(",") || "none"}
|
||||
{Object.keys(props.tasks.progressByTaskId ?? {}).join(",") || "none"}
|
||||
</span>
|
||||
<span data-testid="selected-tool">
|
||||
{props.toolsUi?.selectedToolKey ?? "none"}
|
||||
{props.tools.toolsUi?.selectedToolKey ?? "none"}
|
||||
</span>
|
||||
<span data-testid="tool-search">
|
||||
{props.tools.toolsUi?.search || "none"}
|
||||
</span>
|
||||
<span data-testid="tool-search">{props.toolsUi?.search || "none"}</span>
|
||||
<span data-testid="selected-prompt">
|
||||
{props.promptsUi?.selectedPromptName ?? "none"}
|
||||
{props.prompts.promptsUi?.selectedPromptName ?? "none"}
|
||||
</span>
|
||||
<span data-testid="log-filter">
|
||||
{props.logs.logsUi?.filterText || "none"}
|
||||
</span>
|
||||
<span data-testid="log-filter">{props.logsUi?.filterText || "none"}</span>
|
||||
<span data-testid="prompt-status">
|
||||
{props.getPromptState?.status ?? "none"}
|
||||
{props.prompts.getPromptState?.status ?? "none"}
|
||||
</span>
|
||||
<span data-testid="resource-status">
|
||||
{props.readResourceState?.status ?? "none"}
|
||||
{props.resources.readResourceState?.status ?? "none"}
|
||||
</span>
|
||||
<span data-testid="log-level">{props.currentLogLevel}</span>
|
||||
<span data-testid="active-tab">{props.activeTab ?? "none"}</span>
|
||||
<span data-testid="log-level">{props.logs.currentLogLevel}</span>
|
||||
<span data-testid="active-tab">{props.shell.activeTab ?? "none"}</span>
|
||||
<span data-testid="init-result">
|
||||
{props.initializeResult
|
||||
? `name:${props.initializeResult.serverInfo.name || "(empty)"}`
|
||||
{props.connection.initializeResult
|
||||
? `name:${props.connection.initializeResult.serverInfo.name || "(empty)"}`
|
||||
: "none"}
|
||||
</span>
|
||||
<span data-testid="errored-server">
|
||||
{props.erroredServerId ?? "none"}
|
||||
{props.connection.erroredServerId ?? "none"}
|
||||
</span>
|
||||
<span data-testid="active-server">{props.activeServer ?? "none"}</span>
|
||||
<button onClick={() => props.onActiveTabChange("Servers")}>
|
||||
<span data-testid="active-server">
|
||||
{props.connection.activeServer ?? "none"}
|
||||
</span>
|
||||
<button onClick={() => props.shell.onActiveTabChange("Servers")}>
|
||||
switch-servers-tab
|
||||
</button>
|
||||
<button onClick={() => props.onToggleConnection("A")}>connect</button>
|
||||
<button onClick={() => props.connection.onToggleConnection("A")}>
|
||||
connect
|
||||
</button>
|
||||
{/* A second target, so a test can drive an A -> B -> A switch (#2095). */}
|
||||
<button onClick={() => props.onToggleConnection("B")}>connect-b</button>
|
||||
<button onClick={() => props.onConnectionInfo()}>
|
||||
<button onClick={() => props.connection.onToggleConnection("B")}>
|
||||
connect-b
|
||||
</button>
|
||||
{/* The header's explicit Disconnect, which routes to the standalone
|
||||
`onDisconnect` rather than through the toggle. */}
|
||||
<button onClick={() => props.connection.onDisconnect()}>
|
||||
disconnect
|
||||
</button>
|
||||
<button onClick={() => props.servers.onConnectionInfo("A")}>
|
||||
open-connection-info
|
||||
</button>
|
||||
<button
|
||||
onClick={() =>
|
||||
props.onToolsUiChange({
|
||||
formValues: {},
|
||||
search: "",
|
||||
...props.toolsUi,
|
||||
props.tools.onToolsUiChange({
|
||||
...props.tools.toolsUi,
|
||||
selectedToolKey: "0:get_acts",
|
||||
})
|
||||
}
|
||||
|
|
@ -577,10 +522,8 @@ vi.mock("./components/views/InspectorView/InspectorView", () => ({
|
|||
</button>
|
||||
<button
|
||||
onClick={() =>
|
||||
props.onToolsUiChange({
|
||||
formValues: {},
|
||||
search: "",
|
||||
...props.toolsUi,
|
||||
props.tools.onToolsUiChange({
|
||||
...props.tools.toolsUi,
|
||||
selectedToolKey: "1:other_tool",
|
||||
})
|
||||
}
|
||||
|
|
@ -589,9 +532,8 @@ vi.mock("./components/views/InspectorView/InspectorView", () => ({
|
|||
</button>
|
||||
<button
|
||||
onClick={() =>
|
||||
props.onToolsUiChange({
|
||||
formValues: {},
|
||||
...props.toolsUi,
|
||||
props.tools.onToolsUiChange({
|
||||
...props.tools.toolsUi,
|
||||
search: "act",
|
||||
})
|
||||
}
|
||||
|
|
@ -600,10 +542,8 @@ vi.mock("./components/views/InspectorView/InspectorView", () => ({
|
|||
</button>
|
||||
<button
|
||||
onClick={() =>
|
||||
props.onPromptsUiChange({
|
||||
argumentValues: {},
|
||||
search: "",
|
||||
...props.promptsUi,
|
||||
props.prompts.onPromptsUiChange({
|
||||
...props.prompts.promptsUi,
|
||||
selectedPromptName: "greet",
|
||||
})
|
||||
}
|
||||
|
|
@ -612,70 +552,99 @@ vi.mock("./components/views/InspectorView/InspectorView", () => ({
|
|||
</button>
|
||||
<button
|
||||
onClick={() =>
|
||||
props.onLogsUiChange({
|
||||
visibleLevels: {},
|
||||
...props.logsUi,
|
||||
props.logs.onLogsUiChange({
|
||||
...props.logs.logsUi,
|
||||
filterText: "err",
|
||||
})
|
||||
}
|
||||
>
|
||||
set-log-filter
|
||||
</button>
|
||||
<button onClick={() => props.onCallTool("get_acts", {})}>call</button>
|
||||
<button onClick={() => props.onCallTool("get_acts", {}, true)}>
|
||||
<button onClick={() => props.tools.onCallTool("get_acts", {})}>
|
||||
call
|
||||
</button>
|
||||
<button onClick={() => props.tools.onCallTool("get_acts", {}, true)}>
|
||||
call-as-task
|
||||
</button>
|
||||
<button onClick={() => props.onCancelTask("task-1")}>cancel-task</button>
|
||||
<button onClick={() => props.onCancelToolCall()}>cancel-tool-call</button>
|
||||
<button onClick={() => props.onClearCompletedTasks()}>
|
||||
<button onClick={() => props.tasks.onCancelTask("task-1")}>
|
||||
cancel-task
|
||||
</button>
|
||||
<button onClick={() => props.tools.onCancelToolCall?.()}>
|
||||
cancel-tool-call
|
||||
</button>
|
||||
<button onClick={() => props.tasks.onClearCompletedTasks()}>
|
||||
clear-completed
|
||||
</button>
|
||||
<button onClick={() => props.onRefreshTasks()}>refresh-tasks</button>
|
||||
<button onClick={() => props.onGetPrompt("greet", {})}>get-prompt</button>
|
||||
<button onClick={() => props.onReadResource("res://x")}>
|
||||
<button onClick={() => props.tasks.onRefreshTasks()}>
|
||||
refresh-tasks
|
||||
</button>
|
||||
<button onClick={() => props.prompts.onGetPrompt("greet", {})}>
|
||||
get-prompt
|
||||
</button>
|
||||
<button onClick={() => props.resources.onReadResource("res://x")}>
|
||||
read-resource
|
||||
</button>
|
||||
<button onClick={() => props.onSetLogLevel("debug")}>set-level</button>
|
||||
<button onClick={() => props.onServerSettings("A")}>open-settings</button>
|
||||
<button onClick={() => props.logs.onSetLogLevel("debug")}>
|
||||
set-level
|
||||
</button>
|
||||
<button onClick={() => props.servers.onServerSettings("A")}>
|
||||
open-settings
|
||||
</button>
|
||||
{/* The real server grid (and its Add / Edit controls) lives inside this
|
||||
mocked view, so the config modal is only reachable through these
|
||||
callbacks — and the highlight batch only observable through this prop. */}
|
||||
<button onClick={() => props.onServerEdit("A")}>edit-server</button>
|
||||
<button onClick={() => props.onServerAdd()}>add-server</button>
|
||||
<button onClick={() => props.servers.onServerEdit("A")}>
|
||||
edit-server
|
||||
</button>
|
||||
<button onClick={() => props.servers.onServerAdd()}>add-server</button>
|
||||
{/* The real drag-and-drop reorder lives inside this mocked view, so the
|
||||
callback is only reachable through a control like this one. */}
|
||||
<button onClick={() => props.servers.onServerReorder(["B", "A"])}>
|
||||
reorder-servers
|
||||
</button>
|
||||
<span data-testid="highlighted-servers">
|
||||
{(props.highlightedServerIds ?? []).join(",") || "none"}
|
||||
{(props.servers.highlightedServerIds ?? []).join(",") || "none"}
|
||||
</span>
|
||||
<span data-testid="pinned-history">
|
||||
{Array.from(props.pinnedProtocolIds ?? []).join(",")}
|
||||
{Array.from(props.protocol.pinnedProtocolIds ?? []).join(",")}
|
||||
</span>
|
||||
<button onClick={() => props.onTogglePinProtocol("hist-1")}>
|
||||
<button onClick={() => props.protocol.onTogglePinProtocol("hist-1")}>
|
||||
toggle-pin
|
||||
</button>
|
||||
<button onClick={() => props.onReplayProtocol("hist-1")}>
|
||||
<button onClick={() => props.protocol.onReplayProtocol("hist-1")}>
|
||||
replay-history
|
||||
</button>
|
||||
<button onClick={() => props.onClearProtocol()}>clear-history</button>
|
||||
<button onClick={() => props.protocol.onClearProtocol()}>
|
||||
clear-history
|
||||
</button>
|
||||
<span data-testid="tools-paginated">
|
||||
{String(props.toolsPagination.paginated)}
|
||||
{String(props.tools.toolsPagination.paginated)}
|
||||
</span>
|
||||
<span data-testid="tools-loaded-pages">
|
||||
{props.toolsPagination.loadedPages}
|
||||
{props.tools.toolsPagination.loadedPages}
|
||||
</span>
|
||||
<button onClick={() => props.toolsPagination.onPaginatedChange(true)}>
|
||||
<button
|
||||
onClick={() => props.tools.toolsPagination.onPaginatedChange(true)}
|
||||
>
|
||||
paginated-on
|
||||
</button>
|
||||
<button onClick={() => props.toolsPagination.onPaginatedChange(false)}>
|
||||
<button
|
||||
onClick={() => props.tools.toolsPagination.onPaginatedChange(false)}
|
||||
>
|
||||
paginated-off
|
||||
</button>
|
||||
<button onClick={() => props.toolsPagination.onLoadMore()}>
|
||||
<button onClick={() => props.tools.toolsPagination.onLoadMore()}>
|
||||
load-more-tools
|
||||
</button>
|
||||
<button onClick={() => props.onRefreshTools()}>refresh-tools</button>
|
||||
<button onClick={() => props.tools.onRefreshTools()}>
|
||||
refresh-tools
|
||||
</button>
|
||||
</div>
|
||||
),
|
||||
}));
|
||||
|
||||
import App from "./App";
|
||||
import type { InspectorViewProps } from "./components/views/InspectorView/InspectorView";
|
||||
import { SERVER_INFO_NOT_REPORTED_LABEL } from "./components/groups/ConnectionInfoContent/ConnectionInfoContent";
|
||||
import { OAUTH_CALLBACK_PATH } from "./utils/oauthFlow.js";
|
||||
import { INSPECTOR_SERVERS_TAB } from "./utils/inspectorTabs.js";
|
||||
|
|
@ -3845,6 +3814,30 @@ describe("App background command rejections (#2049)", () => {
|
|||
return user;
|
||||
};
|
||||
|
||||
// A complete `useServers` return with one injected mutator, so the mock stays
|
||||
// type-checked against the hook's contract rather than spread from a call.
|
||||
const serversWithReorder = (
|
||||
reorderServers: ReturnType<typeof useServers>["reorderServers"],
|
||||
): ReturnType<typeof useServers> => ({
|
||||
servers: [
|
||||
{
|
||||
id: "A",
|
||||
name: "PlotRocket",
|
||||
config: { type: "streamable-http", url: "https://api.example.com/mcp" },
|
||||
connection: { status: "disconnected" },
|
||||
},
|
||||
],
|
||||
loading: false,
|
||||
error: undefined,
|
||||
refresh: vi.fn().mockResolvedValue(undefined),
|
||||
addServer: vi.fn().mockResolvedValue(undefined),
|
||||
updateServer: vi.fn().mockResolvedValue(undefined),
|
||||
updateServerSettings: vi.fn().mockResolvedValue(undefined),
|
||||
removeServer: vi.fn().mockResolvedValue(undefined),
|
||||
reorderServers,
|
||||
importSource: vi.fn().mockResolvedValue({ servers: {} }),
|
||||
});
|
||||
|
||||
it("does not leak an unhandled rejection when an all-pages Refresh fails", async () => {
|
||||
vi.mocked(useManagedTools).mockReturnValue({
|
||||
tools: [],
|
||||
|
|
@ -3922,6 +3915,156 @@ describe("App background command rejections (#2049)", () => {
|
|||
rejections.stop();
|
||||
}
|
||||
});
|
||||
|
||||
// `onToggleConnection` and `onDisconnect` both close a live session with
|
||||
// `try { await disconnect() } finally { finalizeExplicitDisconnect() }` — a
|
||||
// `finally`, not a `catch` — so a transport that fails to close rejects out
|
||||
// of the handler. App discards neither: both are terminated with a `.catch`
|
||||
// that toasts, because a bare `void` would turn an ordinary click into a
|
||||
// global unhandled rejection with nothing shown to the user (#2130 review).
|
||||
const CLOSE_FAILURE = new Error("close boom");
|
||||
|
||||
it("toasts a failed transport close from the connection toggle instead of leaking it", async () => {
|
||||
const rejections = captureUnhandledRejections();
|
||||
try {
|
||||
const user = await connect();
|
||||
const client = clientInstances[0] as EventTarget & {
|
||||
disconnect: ReturnType<typeof vi.fn>;
|
||||
};
|
||||
client.disconnect.mockRejectedValueOnce(CLOSE_FAILURE);
|
||||
|
||||
// Second click on the same, now-connected server takes the disconnect
|
||||
// branch of the toggle.
|
||||
await user.click(screen.getByText("connect"));
|
||||
|
||||
await waitFor(() =>
|
||||
expect(notificationsMock.show).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
title: "Failed to change the connection",
|
||||
message: "close boom",
|
||||
color: "red",
|
||||
}),
|
||||
),
|
||||
);
|
||||
await settleRejections();
|
||||
expect(rejections.seen).toEqual([]);
|
||||
} finally {
|
||||
rejections.stop();
|
||||
}
|
||||
});
|
||||
|
||||
// The four command handlers await `handleCommandScopedAuthRecovery` from
|
||||
// *inside* their catch blocks, and a rejection thrown from a catch is not
|
||||
// caught by that same catch — so it escapes the handler entirely. That helper
|
||||
// awaits `checkAuthChallengeSatisfied`, which reaches the backend and can
|
||||
// reject. Before #2130's review this escaped a bare `void` as a global
|
||||
// unhandled rejection with nothing shown to the user.
|
||||
it("toasts an auth-recovery failure that escapes a tool call instead of leaking it", async () => {
|
||||
const rejections = captureUnhandledRejections();
|
||||
try {
|
||||
const user = await connect();
|
||||
const client = clientInstances[0] as EventTarget & {
|
||||
callTool: ReturnType<typeof vi.fn>;
|
||||
};
|
||||
client.callTool.mockRejectedValueOnce(
|
||||
new AuthRecoveryRequiredError(
|
||||
new URL("https://as.example.com/authorize"),
|
||||
{ reason: "unauthorized" },
|
||||
),
|
||||
);
|
||||
// The recovery helper's first await is the one that breaks.
|
||||
rejectNextChallengeCheck(new Error("challenge check failed"));
|
||||
|
||||
await user.click(screen.getByText("call"));
|
||||
|
||||
await waitFor(() =>
|
||||
expect(notificationsMock.show).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
title: "Tool call failed",
|
||||
message: "challenge check failed",
|
||||
color: "red",
|
||||
}),
|
||||
),
|
||||
);
|
||||
await settleRejections();
|
||||
expect(rejections.seen).toEqual([]);
|
||||
} finally {
|
||||
rejections.stop();
|
||||
}
|
||||
});
|
||||
|
||||
// The reorder handler was lifted out of the JSX into a named callback with a
|
||||
// `.catch` that toasts (#2130). `reorderServers` reverts its own optimistic
|
||||
// ordering and re-throws — on a 409 from a racing external edit, or a network
|
||||
// error — so without the toast the drag just silently bounces back.
|
||||
it("forwards a reorder to the server list", async () => {
|
||||
const reorderServers = vi.fn().mockResolvedValue(undefined);
|
||||
vi.mocked(useServers).mockReturnValue(serversWithReorder(reorderServers));
|
||||
const user = userEvent.setup();
|
||||
renderWithMantine(<App />);
|
||||
|
||||
await user.click(screen.getByText("reorder-servers"));
|
||||
|
||||
await waitFor(() =>
|
||||
expect(reorderServers).toHaveBeenCalledWith(["B", "A"]),
|
||||
);
|
||||
expect(notificationsMock.show).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("toasts a failed reorder instead of leaking it", async () => {
|
||||
const reorderServers = vi
|
||||
.fn()
|
||||
.mockRejectedValue(new Error("stale server list"));
|
||||
vi.mocked(useServers).mockReturnValue(serversWithReorder(reorderServers));
|
||||
const rejections = captureUnhandledRejections();
|
||||
try {
|
||||
const user = userEvent.setup();
|
||||
renderWithMantine(<App />);
|
||||
|
||||
await user.click(screen.getByText("reorder-servers"));
|
||||
|
||||
await waitFor(() =>
|
||||
expect(notificationsMock.show).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
title: "Failed to reorder servers",
|
||||
message: "stale server list",
|
||||
color: "red",
|
||||
}),
|
||||
),
|
||||
);
|
||||
await settleRejections();
|
||||
expect(rejections.seen).toEqual([]);
|
||||
} finally {
|
||||
rejections.stop();
|
||||
}
|
||||
});
|
||||
|
||||
it("toasts a failed transport close from the explicit Disconnect instead of leaking it", async () => {
|
||||
const rejections = captureUnhandledRejections();
|
||||
try {
|
||||
const user = await connect();
|
||||
const client = clientInstances[0] as EventTarget & {
|
||||
disconnect: ReturnType<typeof vi.fn>;
|
||||
};
|
||||
client.disconnect.mockRejectedValueOnce(CLOSE_FAILURE);
|
||||
|
||||
await user.click(screen.getByText("disconnect"));
|
||||
|
||||
await waitFor(() =>
|
||||
expect(notificationsMock.show).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
title: "Failed to disconnect",
|
||||
message: "close boom",
|
||||
color: "red",
|
||||
}),
|
||||
),
|
||||
);
|
||||
await settleRejections();
|
||||
expect(rejections.seen).toEqual([]);
|
||||
} finally {
|
||||
rejections.stop();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
/** A complete `useManagedResources` return, so the mock stays type-checked against the hook's contract. */
|
||||
|
|
|
|||
|
|
@ -64,6 +64,20 @@ import { useInitialConfig } from "@inspector/core/react/useInitialConfig.js";
|
|||
import { refreshingPersist } from "./lib/refreshingPersist";
|
||||
import { usePendingClientRequests } from "@inspector/core/react/usePendingClientRequests.js";
|
||||
import { InspectorView } from "./components/views/InspectorView/InspectorView";
|
||||
import type {
|
||||
AppsPanelProps,
|
||||
ConnectionProps,
|
||||
ConsolePanelProps,
|
||||
LogsPanelProps,
|
||||
NetworkPanelProps,
|
||||
PromptsPanelProps,
|
||||
ProtocolPanelProps,
|
||||
ResourcesPanelProps,
|
||||
ServerListProps,
|
||||
ShellProps,
|
||||
TasksPanelProps,
|
||||
ToolsPanelProps,
|
||||
} from "./components/views/InspectorView/types";
|
||||
import type { ReadResourceState } from "./components/screens/ResourcesScreen/ResourcesScreen";
|
||||
import { AppElicitationHost } from "./components/elements/AppElicitation/AppElicitationHost";
|
||||
import type { LogEntryData } from "./components/elements/LogEntry/LogEntry";
|
||||
|
|
@ -109,6 +123,21 @@ import { FetchBodyDroppedToastMessage } from "./components/elements/Toasts/Fetch
|
|||
import { OutputValidationToastMessage } from "./components/elements/Toasts/OutputValidationToastMessage";
|
||||
import { ReAuthBannerBar } from "./components/groups/ReAuthBanner/ReAuthBannerBar";
|
||||
|
||||
/**
|
||||
* Terminates a dispatched handler's promise by reporting the failure, for the
|
||||
* `InspectorView` action wrappers below. Module scope because it closes over
|
||||
* nothing — the title is the only thing that varies per action.
|
||||
*/
|
||||
function reportDispatchFailure(title: string) {
|
||||
return (err: unknown) => {
|
||||
notifications.show({
|
||||
title,
|
||||
message: err instanceof Error ? err.message : String(err),
|
||||
color: "red",
|
||||
});
|
||||
};
|
||||
}
|
||||
|
||||
function App() {
|
||||
const { onToggleTheme } = useThemeToggle();
|
||||
|
||||
|
|
@ -1518,6 +1547,304 @@ function App() {
|
|||
[pendingElicitations, inspectorClient],
|
||||
);
|
||||
|
||||
// --- InspectorView prop bundles (#2130) -----------------------------------
|
||||
//
|
||||
// The view takes one prop per domain, so the JSX below reads as a component
|
||||
// tree rather than a wall of ~130 props. Everything the bundles need is
|
||||
// assembled here: the handlers that used to be multi-line closures declared
|
||||
// inline in the JSX are named callbacks, and the `void`-discarding wrappers
|
||||
// carry their justification once rather than at a call site.
|
||||
|
||||
const onServerAdd = useCallback(() => {
|
||||
setHighlightedServerIds([]);
|
||||
setConfigModal({ mode: "add" });
|
||||
}, []);
|
||||
|
||||
const onServerImportConfig = useCallback(() => {
|
||||
setHighlightedServerIds([]);
|
||||
setImportConfigOpen(true);
|
||||
}, []);
|
||||
|
||||
const onServerImportJson = useCallback(() => {
|
||||
setHighlightedServerIds([]);
|
||||
setImportJsonOpen(true);
|
||||
}, []);
|
||||
|
||||
const onServerEdit = useCallback((id: string) => {
|
||||
setConfigModal({ mode: "edit", targetId: id });
|
||||
}, []);
|
||||
|
||||
const onServerClone = useCallback((id: string) => {
|
||||
setHighlightedServerIds([]);
|
||||
setConfigModal({ mode: "clone", targetId: id });
|
||||
}, []);
|
||||
|
||||
const onServerRemove = useCallback(
|
||||
(id: string) => {
|
||||
const target = servers.find((s) => s.id === id);
|
||||
if (target) setRemoveTarget(target);
|
||||
},
|
||||
[servers],
|
||||
);
|
||||
|
||||
const onServerReorderFromList = useCallback(
|
||||
(orderedIds: string[]) => {
|
||||
// reorderServers reverts the optimistic order via an internal refresh()
|
||||
// and re-throws on failure (409 from a racing external edit, or a network
|
||||
// error). Surface that to the user so the drag doesn't silently bounce
|
||||
// back — matching the toast pattern every other mutation here uses.
|
||||
reorderServers(orderedIds).catch((err: unknown) => {
|
||||
notifications.show({
|
||||
title: "Failed to reorder servers",
|
||||
message: err instanceof Error ? err.message : String(err),
|
||||
color: "red",
|
||||
});
|
||||
});
|
||||
},
|
||||
[reorderServers],
|
||||
);
|
||||
|
||||
const openClientSettings = useCallback(() => setClientSettingsOpen(true), []);
|
||||
const openConnectionInfo = useCallback(
|
||||
() => setConnectionInfoModalOpen(true),
|
||||
[],
|
||||
);
|
||||
const openServerSettings = useCallback(
|
||||
(id: string) => setSettingsModalTargetId(id),
|
||||
[],
|
||||
);
|
||||
|
||||
// Every `InspectorView` action prop is synchronous while the handler behind
|
||||
// it is async, so each wrapper below has to terminate its promise somehow.
|
||||
// They all do it the same way: a reporting `.catch`, never a bare `void`.
|
||||
//
|
||||
// The uniformity is the point. Deciding per handler whether it "owns its
|
||||
// failures" is a judgement this PR got wrong twice (#2130 review) — the
|
||||
// handlers really do catch their ordinary errors, but a rejection can still
|
||||
// escape by a route the reading misses:
|
||||
//
|
||||
// - `onToggleConnection` awaits `initialConfigSettledRef` and constructs
|
||||
// the client via `setupClientForServer` outside its own try/catch, and
|
||||
// its already-connected branch is a `try/finally` that lets a failed
|
||||
// transport close propagate. `onDisconnect` is that `try/finally` alone.
|
||||
// - `onCallTool` / `onGetPrompt` / `onReadResource` / `onOpenApp` await
|
||||
// `handleCommandScopedAuthRecovery` from *inside* their catch blocks, and
|
||||
// a rejection thrown from a catch is not caught by that same catch. That
|
||||
// helper awaits `checkAuthChallengeSatisfied` and `pushRemoteAuthState`,
|
||||
// both of which reach the backend and can reject.
|
||||
//
|
||||
// A handler that does surface its own failure resolves, so it never reaches
|
||||
// this `.catch` and there is no double-toast; what lands here is only the
|
||||
// escape, which would otherwise be a global unhandled rejection with nothing
|
||||
// shown to the user.
|
||||
const dispatchToggleConnection = useCallback(
|
||||
(id: string) => {
|
||||
onToggleConnection(id).catch(
|
||||
reportDispatchFailure("Failed to change the connection"),
|
||||
);
|
||||
},
|
||||
[onToggleConnection],
|
||||
);
|
||||
const dispatchDisconnect = useCallback(() => {
|
||||
onDisconnect().catch(reportDispatchFailure("Failed to disconnect"));
|
||||
}, [onDisconnect]);
|
||||
const dispatchCallTool = useCallback(
|
||||
(name: string, args: Record<string, unknown>, runAsTask?: boolean) => {
|
||||
onCallTool(name, args, runAsTask).catch(
|
||||
reportDispatchFailure("Tool call failed"),
|
||||
);
|
||||
},
|
||||
[onCallTool],
|
||||
);
|
||||
const dispatchGetPrompt = useCallback(
|
||||
(name: string, args: Record<string, string>) => {
|
||||
onGetPrompt(name, args).catch(
|
||||
reportDispatchFailure("Failed to get prompt"),
|
||||
);
|
||||
},
|
||||
[onGetPrompt],
|
||||
);
|
||||
const dispatchReadResource = useCallback(
|
||||
(uri: string) => {
|
||||
onReadResource(uri).catch(
|
||||
reportDispatchFailure("Failed to read resource"),
|
||||
);
|
||||
},
|
||||
[onReadResource],
|
||||
);
|
||||
const dispatchCancelTask = useCallback(
|
||||
(taskId: string) => {
|
||||
onCancelTask(taskId).catch(
|
||||
reportDispatchFailure("Failed to cancel task"),
|
||||
);
|
||||
},
|
||||
[onCancelTask],
|
||||
);
|
||||
const dispatchOpenApp = useCallback(
|
||||
(name: string, args: Record<string, unknown>) => {
|
||||
onOpenApp(name, args).catch(reportDispatchFailure("Failed to open app"));
|
||||
},
|
||||
[onOpenApp],
|
||||
);
|
||||
|
||||
const shellProps: ShellProps = {
|
||||
deepLink,
|
||||
deepLinkStatus,
|
||||
version: inspectorVersion,
|
||||
malformedListItems: shownMalformedListItems,
|
||||
activeTab,
|
||||
onActiveTabChange: setActiveTab,
|
||||
onToggleTheme,
|
||||
onOpenClientSettings: openClientSettings,
|
||||
};
|
||||
|
||||
const connectionProps: ConnectionProps = {
|
||||
activeServer: activeServerId,
|
||||
erroredServerId: failedServerId,
|
||||
connectedServerId,
|
||||
connectionStatus,
|
||||
connectErrorMessage,
|
||||
initializeResult,
|
||||
latencyMs,
|
||||
protocolEra,
|
||||
onCompleteArgument,
|
||||
completionsSupported: capabilities?.completions !== undefined,
|
||||
onToggleConnection: dispatchToggleConnection,
|
||||
onDisconnect: dispatchDisconnect,
|
||||
};
|
||||
|
||||
const serverListProps: ServerListProps = {
|
||||
servers,
|
||||
serverListWritable,
|
||||
highlightedServerIds,
|
||||
onClearHighlight: clearHighlight,
|
||||
onServerAdd,
|
||||
onServerImportConfig,
|
||||
onServerImportJson,
|
||||
onServerExport,
|
||||
onConnectionInfo: openConnectionInfo,
|
||||
onServerSettings: openServerSettings,
|
||||
onServerEdit,
|
||||
onServerClone,
|
||||
onServerRemove,
|
||||
onServerReorder: onServerReorderFromList,
|
||||
};
|
||||
|
||||
const toolsPanelProps: ToolsPanelProps = {
|
||||
tools,
|
||||
excludedTools,
|
||||
toolsListChanged,
|
||||
toolsLoadError: toolsPagination.error,
|
||||
toolsUi: ui.toolsUi,
|
||||
toolCallState,
|
||||
toolsPagination: toolsPaginationControls,
|
||||
serverSupportsTaskToolCalls:
|
||||
!!capabilities?.tasks?.requests?.tools?.call ||
|
||||
(inspectorClient?.isTasksExtensionNegotiated() ?? false),
|
||||
onToolsUiChange,
|
||||
onCallTool: dispatchCallTool,
|
||||
onCancelToolCall,
|
||||
onClearToolResult,
|
||||
onRefreshTools,
|
||||
onReadResourceContents,
|
||||
};
|
||||
|
||||
const promptsPanelProps: PromptsPanelProps = {
|
||||
prompts,
|
||||
promptsListChanged,
|
||||
promptsLoadError: promptsPagination.error,
|
||||
promptsUi: ui.promptsUi,
|
||||
getPromptState,
|
||||
promptsPagination: promptsPaginationControls,
|
||||
onPromptsUiChange: setUi.setPromptsUi,
|
||||
onGetPrompt: dispatchGetPrompt,
|
||||
onRefreshPrompts,
|
||||
};
|
||||
|
||||
const resourcesPanelProps: ResourcesPanelProps = {
|
||||
resources,
|
||||
resourceTemplates,
|
||||
subscriptions,
|
||||
subscriptionStreamState,
|
||||
subscriptionsSupported: capabilities?.resources?.subscribe === true,
|
||||
resourcesListChanged,
|
||||
resourcesLoadError: resourcesPagination.error ?? resourceTemplatesLoadError,
|
||||
resourcesUi: ui.resourcesUi,
|
||||
readResourceState: effectiveReadResourceState,
|
||||
resourcesPagination: resourcesPaginationControls,
|
||||
onResourcesUiChange: setUi.setResourcesUi,
|
||||
onReadResource: dispatchReadResource,
|
||||
onSubscribeResource,
|
||||
onUnsubscribeResource,
|
||||
onRefreshResources,
|
||||
};
|
||||
|
||||
const appsPanelProps: AppsPanelProps = {
|
||||
appsUi: ui.appsUi,
|
||||
sandboxPath: sandboxUrl,
|
||||
bridgeFactory: sandboxBridgeFactory,
|
||||
appRendererRef,
|
||||
onAppsUiChange: setUi.setAppsUi,
|
||||
onSelectApp,
|
||||
onOpenApp: dispatchOpenApp,
|
||||
onCloseApp,
|
||||
onAppError,
|
||||
// The Apps tab is a filtered view of the tools list, so refreshing it is
|
||||
// refreshing tools.
|
||||
onRefreshApps: onRefreshTools,
|
||||
};
|
||||
|
||||
const tasksPanelProps: TasksPanelProps = {
|
||||
tasks,
|
||||
progressByTaskId,
|
||||
tasksUi: ui.tasksUi,
|
||||
onTasksUiChange: setUi.setTasksUi,
|
||||
onCancelTask: dispatchCancelTask,
|
||||
onClearCompletedTasks,
|
||||
onRefreshTasks,
|
||||
};
|
||||
|
||||
const logsPanelProps: LogsPanelProps = {
|
||||
logs,
|
||||
logsUi: ui.logsUi,
|
||||
currentLogLevel,
|
||||
modernLogLevel,
|
||||
onSetLogLevel,
|
||||
onSetModernLogLevel,
|
||||
onLogsUiChange: setUi.setLogsUi,
|
||||
onClearLogs,
|
||||
onExportLogs,
|
||||
};
|
||||
|
||||
const protocolPanelProps: ProtocolPanelProps = {
|
||||
protocol: protocolEntries,
|
||||
protocolUi: ui.protocolUi,
|
||||
pinnedProtocolIds,
|
||||
onProtocolUiChange: setUi.setProtocolUi,
|
||||
onClearProtocol,
|
||||
onExportProtocol,
|
||||
onClearProtocolSection,
|
||||
onExportProtocolSection,
|
||||
onReplayProtocol,
|
||||
onTogglePinProtocol: togglePinProtocol,
|
||||
};
|
||||
|
||||
const networkPanelProps: NetworkPanelProps = {
|
||||
network: fetchRequests,
|
||||
networkUi: ui.networkUi,
|
||||
onNetworkUiChange: setUi.setNetworkUi,
|
||||
onClearNetwork,
|
||||
onExportNetwork,
|
||||
};
|
||||
|
||||
const consolePanelProps: ConsolePanelProps = {
|
||||
stderrLogs,
|
||||
consoleUi: ui.consoleUi,
|
||||
onConsoleUiChange: setUi.setConsoleUi,
|
||||
onClearConsole,
|
||||
onExportConsole,
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<Box>
|
||||
|
|
@ -1533,171 +1860,18 @@ function App() {
|
|||
</ReAuthBannerBar>
|
||||
) : null}
|
||||
<InspectorView
|
||||
deepLink={deepLink}
|
||||
deepLinkStatus={deepLinkStatus}
|
||||
servers={servers}
|
||||
serverListWritable={serverListWritable}
|
||||
activeServer={activeServerId}
|
||||
erroredServerId={failedServerId}
|
||||
connectedServerId={connectedServerId}
|
||||
version={inspectorVersion}
|
||||
connectionStatus={connectionStatus}
|
||||
connectErrorMessage={connectErrorMessage}
|
||||
initializeResult={initializeResult}
|
||||
latencyMs={latencyMs}
|
||||
tools={tools}
|
||||
excludedTools={excludedTools}
|
||||
malformedListItems={shownMalformedListItems}
|
||||
prompts={prompts}
|
||||
resources={resources}
|
||||
resourceTemplates={resourceTemplates}
|
||||
toolsListChanged={toolsListChanged}
|
||||
promptsListChanged={promptsListChanged}
|
||||
resourcesListChanged={resourcesListChanged}
|
||||
toolsLoadError={toolsPagination.error}
|
||||
promptsLoadError={promptsPagination.error}
|
||||
resourcesLoadError={
|
||||
resourcesPagination.error ?? resourceTemplatesLoadError
|
||||
}
|
||||
subscriptions={subscriptions}
|
||||
subscriptionStreamState={subscriptionStreamState}
|
||||
logs={logs}
|
||||
tasks={tasks}
|
||||
progressByTaskId={progressByTaskId}
|
||||
protocol={protocolEntries}
|
||||
protocolEra={protocolEra}
|
||||
network={fetchRequests}
|
||||
stderrLogs={stderrLogs}
|
||||
toolCallState={toolCallState}
|
||||
getPromptState={getPromptState}
|
||||
readResourceState={effectiveReadResourceState}
|
||||
toolsUi={ui.toolsUi}
|
||||
promptsUi={ui.promptsUi}
|
||||
resourcesUi={ui.resourcesUi}
|
||||
appsUi={ui.appsUi}
|
||||
tasksUi={ui.tasksUi}
|
||||
logsUi={ui.logsUi}
|
||||
protocolUi={ui.protocolUi}
|
||||
networkUi={ui.networkUi}
|
||||
consoleUi={ui.consoleUi}
|
||||
activeTab={activeTab}
|
||||
onActiveTabChange={setActiveTab}
|
||||
currentLogLevel={currentLogLevel}
|
||||
sandboxPath={sandboxUrl}
|
||||
bridgeFactory={sandboxBridgeFactory}
|
||||
appRendererRef={appRendererRef}
|
||||
onToggleTheme={onToggleTheme}
|
||||
onOpenClientSettings={() => setClientSettingsOpen(true)}
|
||||
onToggleConnection={(id) => {
|
||||
void onToggleConnection(id);
|
||||
}}
|
||||
onDisconnect={() => {
|
||||
void onDisconnect();
|
||||
}}
|
||||
onServerAdd={() => {
|
||||
setHighlightedServerIds([]);
|
||||
setConfigModal({ mode: "add" });
|
||||
}}
|
||||
onServerImportConfig={() => {
|
||||
setHighlightedServerIds([]);
|
||||
setImportConfigOpen(true);
|
||||
}}
|
||||
onServerImportJson={() => {
|
||||
setHighlightedServerIds([]);
|
||||
setImportJsonOpen(true);
|
||||
}}
|
||||
onServerExport={onServerExport}
|
||||
onConnectionInfo={() => setConnectionInfoModalOpen(true)}
|
||||
onServerSettings={(id) => setSettingsModalTargetId(id)}
|
||||
onServerEdit={(id) => setConfigModal({ mode: "edit", targetId: id })}
|
||||
onServerClone={(id) => {
|
||||
setHighlightedServerIds([]);
|
||||
setConfigModal({ mode: "clone", targetId: id });
|
||||
}}
|
||||
onServerRemove={(id) => {
|
||||
const target = servers.find((s) => s.id === id);
|
||||
if (target) setRemoveTarget(target);
|
||||
}}
|
||||
onServerReorder={(orderedIds) => {
|
||||
// reorderServers reverts the optimistic order via an internal
|
||||
// refresh() and re-throws on failure (409 from a racing external
|
||||
// edit, or a network error). Surface that to the user so the drag
|
||||
// doesn't silently bounce back — matching the toast pattern every
|
||||
// other mutation here uses.
|
||||
reorderServers(orderedIds).catch((err: unknown) => {
|
||||
notifications.show({
|
||||
title: "Failed to reorder servers",
|
||||
message: err instanceof Error ? err.message : String(err),
|
||||
color: "red",
|
||||
});
|
||||
});
|
||||
}}
|
||||
highlightedServerIds={highlightedServerIds}
|
||||
onClearHighlight={clearHighlight}
|
||||
serverSupportsTaskToolCalls={
|
||||
!!capabilities?.tasks?.requests?.tools?.call ||
|
||||
(inspectorClient?.isTasksExtensionNegotiated() ?? false)
|
||||
}
|
||||
onToolsUiChange={onToolsUiChange}
|
||||
onCallTool={(name, args, runAsTask) => {
|
||||
void onCallTool(name, args, runAsTask);
|
||||
}}
|
||||
onCancelToolCall={onCancelToolCall}
|
||||
onClearToolResult={onClearToolResult}
|
||||
onReadResourceContents={onReadResourceContents}
|
||||
onRefreshTools={onRefreshTools}
|
||||
toolsPagination={toolsPaginationControls}
|
||||
promptsPagination={promptsPaginationControls}
|
||||
resourcesPagination={resourcesPaginationControls}
|
||||
onPromptsUiChange={setUi.setPromptsUi}
|
||||
onGetPrompt={(name, args) => {
|
||||
void onGetPrompt(name, args);
|
||||
}}
|
||||
onRefreshPrompts={onRefreshPrompts}
|
||||
onResourcesUiChange={setUi.setResourcesUi}
|
||||
onReadResource={(uri) => {
|
||||
void onReadResource(uri);
|
||||
}}
|
||||
onSubscribeResource={onSubscribeResource}
|
||||
onUnsubscribeResource={onUnsubscribeResource}
|
||||
onRefreshResources={onRefreshResources}
|
||||
onCompleteArgument={onCompleteArgument}
|
||||
completionsSupported={capabilities?.completions !== undefined}
|
||||
subscriptionsSupported={capabilities?.resources?.subscribe === true}
|
||||
onTasksUiChange={setUi.setTasksUi}
|
||||
onCancelTask={(taskId) => {
|
||||
void onCancelTask(taskId);
|
||||
}}
|
||||
onClearCompletedTasks={onClearCompletedTasks}
|
||||
onRefreshTasks={onRefreshTasks}
|
||||
onSetLogLevel={onSetLogLevel}
|
||||
modernLogLevel={modernLogLevel}
|
||||
onSetModernLogLevel={onSetModernLogLevel}
|
||||
onLogsUiChange={setUi.setLogsUi}
|
||||
onClearLogs={onClearLogs}
|
||||
onExportLogs={onExportLogs}
|
||||
onProtocolUiChange={setUi.setProtocolUi}
|
||||
onClearProtocol={onClearProtocol}
|
||||
onExportProtocol={onExportProtocol}
|
||||
onClearProtocolSection={onClearProtocolSection}
|
||||
onExportProtocolSection={onExportProtocolSection}
|
||||
onReplayProtocol={onReplayProtocol}
|
||||
onTogglePinProtocol={togglePinProtocol}
|
||||
pinnedProtocolIds={pinnedProtocolIds}
|
||||
onNetworkUiChange={setUi.setNetworkUi}
|
||||
onClearNetwork={onClearNetwork}
|
||||
onExportNetwork={onExportNetwork}
|
||||
onConsoleUiChange={setUi.setConsoleUi}
|
||||
onClearConsole={onClearConsole}
|
||||
onExportConsole={onExportConsole}
|
||||
onAppsUiChange={setUi.setAppsUi}
|
||||
onSelectApp={onSelectApp}
|
||||
onOpenApp={(name, args) => {
|
||||
void onOpenApp(name, args);
|
||||
}}
|
||||
onCloseApp={onCloseApp}
|
||||
onAppError={onAppError}
|
||||
onRefreshApps={onRefreshTools}
|
||||
shell={shellProps}
|
||||
connection={connectionProps}
|
||||
servers={serverListProps}
|
||||
tools={toolsPanelProps}
|
||||
prompts={promptsPanelProps}
|
||||
resources={resourcesPanelProps}
|
||||
apps={appsPanelProps}
|
||||
tasks={tasksPanelProps}
|
||||
logs={logsPanelProps}
|
||||
protocol={protocolPanelProps}
|
||||
network={networkPanelProps}
|
||||
console={consolePanelProps}
|
||||
/>
|
||||
</Box>
|
||||
<AppElicitationHost
|
||||
|
|
|
|||
|
|
@ -18,6 +18,20 @@ import type { Meta, StoryObj } from "@storybook/react-vite";
|
|||
import { useState } from "react";
|
||||
import { expect, fn } from "storybook/test";
|
||||
import { InspectorView } from "./InspectorView";
|
||||
import type {
|
||||
AppsPanelProps,
|
||||
ConnectionProps,
|
||||
ConsolePanelProps,
|
||||
LogsPanelProps,
|
||||
NetworkPanelProps,
|
||||
PromptsPanelProps,
|
||||
ProtocolPanelProps,
|
||||
ResourcesPanelProps,
|
||||
ServerListProps,
|
||||
ShellProps,
|
||||
TasksPanelProps,
|
||||
ToolsPanelProps,
|
||||
} from "./types";
|
||||
import { noopPagination } from "../../../test/fixtures/pagination";
|
||||
import {
|
||||
EMPTY_TOOLS_UI,
|
||||
|
|
@ -324,116 +338,164 @@ const demoInitializeResult: InitializeResult = {
|
|||
serverInfo: { name: "Local Dev Server", version: "1.2.0" },
|
||||
};
|
||||
|
||||
// One constant per domain bundle, so a story can override a single field
|
||||
// without replacing its whole bundle — Storybook merges args only at the top
|
||||
// level, and each of these IS one top-level arg now (#2130).
|
||||
const shellArgs: ShellProps = {
|
||||
activeTab: "Servers",
|
||||
onActiveTabChange: fn(),
|
||||
onToggleTheme: fn(),
|
||||
onOpenClientSettings: fn(),
|
||||
};
|
||||
|
||||
// Stories default to "disconnected"; per-story overrides drive the connected /
|
||||
// error narratives.
|
||||
const connectionArgs: ConnectionProps = {
|
||||
activeServer: undefined,
|
||||
connectionStatus: "disconnected",
|
||||
initializeResult: undefined,
|
||||
latencyMs: undefined,
|
||||
onToggleConnection: fn(),
|
||||
onDisconnect: fn(),
|
||||
};
|
||||
|
||||
// Callbacks are wired to storybook spies so play functions can assert on
|
||||
// dispatch. Real wiring routes these to InspectorClient methods (the app shell
|
||||
// at clients/web/src/App.tsx).
|
||||
const serversArgs: ServerListProps = {
|
||||
servers: demoServers,
|
||||
onServerAdd: fn(),
|
||||
onServerImportConfig: fn(),
|
||||
onServerImportJson: fn(),
|
||||
onServerExport: fn(),
|
||||
onConnectionInfo: fn(),
|
||||
onServerSettings: fn(),
|
||||
onServerEdit: fn(),
|
||||
onServerClone: fn(),
|
||||
onServerRemove: fn(),
|
||||
onServerReorder: fn(),
|
||||
};
|
||||
|
||||
const toolsArgs: ToolsPanelProps = {
|
||||
tools: demoTools,
|
||||
toolsListChanged: false,
|
||||
toolsUi: EMPTY_TOOLS_UI,
|
||||
toolsPagination: noopPagination,
|
||||
serverSupportsTaskToolCalls: false,
|
||||
onToolsUiChange: fn(),
|
||||
onCallTool: fn(),
|
||||
onRefreshTools: fn(),
|
||||
};
|
||||
|
||||
const promptsArgs: PromptsPanelProps = {
|
||||
prompts: demoPrompts,
|
||||
promptsListChanged: false,
|
||||
promptsUi: EMPTY_PROMPTS_UI,
|
||||
promptsPagination: noopPagination,
|
||||
onPromptsUiChange: fn(),
|
||||
onGetPrompt: fn(),
|
||||
onRefreshPrompts: fn(),
|
||||
};
|
||||
|
||||
const resourcesArgs: ResourcesPanelProps = {
|
||||
resources: demoResources,
|
||||
resourceTemplates: demoResourceTemplates,
|
||||
subscriptions: demoSubscriptions,
|
||||
resourcesListChanged: false,
|
||||
resourcesUi: EMPTY_RESOURCES_UI,
|
||||
resourcesPagination: noopPagination,
|
||||
onResourcesUiChange: fn(),
|
||||
onReadResource: fn(),
|
||||
onSubscribeResource: fn(),
|
||||
onUnsubscribeResource: fn(),
|
||||
onRefreshResources: fn(),
|
||||
};
|
||||
|
||||
const appsArgs: AppsPanelProps = {
|
||||
appsUi: EMPTY_APPS_UI,
|
||||
sandboxPath: "about:blank",
|
||||
bridgeFactory: noopBridgeFactory,
|
||||
appRendererRef: { current: null },
|
||||
onAppsUiChange: fn(),
|
||||
onSelectApp: fn(),
|
||||
onOpenApp: fn(),
|
||||
onCloseApp: fn(),
|
||||
onAppError: fn(),
|
||||
onRefreshApps: fn(),
|
||||
};
|
||||
|
||||
const tasksArgs: TasksPanelProps = {
|
||||
tasks: demoTasks,
|
||||
progressByTaskId: demoProgressByTaskId,
|
||||
tasksUi: EMPTY_TASKS_UI,
|
||||
onTasksUiChange: fn(),
|
||||
onCancelTask: fn(),
|
||||
onClearCompletedTasks: fn(),
|
||||
onRefreshTasks: fn(),
|
||||
};
|
||||
|
||||
const logsArgs: LogsPanelProps = {
|
||||
logs: demoLogs,
|
||||
logsUi: EMPTY_LOGS_UI,
|
||||
currentLogLevel: "info",
|
||||
onSetLogLevel: fn(),
|
||||
onLogsUiChange: fn(),
|
||||
onClearLogs: fn(),
|
||||
onExportLogs: fn(),
|
||||
};
|
||||
|
||||
const protocolArgs: ProtocolPanelProps = {
|
||||
protocol: demoHistory,
|
||||
protocolUi: EMPTY_PROTOCOL_UI,
|
||||
onProtocolUiChange: fn(),
|
||||
onClearProtocol: fn(),
|
||||
onExportProtocol: fn(),
|
||||
onClearProtocolSection: fn(),
|
||||
onExportProtocolSection: fn(),
|
||||
onReplayProtocol: fn(),
|
||||
onTogglePinProtocol: fn(),
|
||||
};
|
||||
|
||||
const networkArgs: NetworkPanelProps = {
|
||||
network: demoNetwork,
|
||||
networkUi: EMPTY_NETWORK_UI,
|
||||
onNetworkUiChange: fn(),
|
||||
onClearNetwork: fn(),
|
||||
onExportNetwork: fn(),
|
||||
};
|
||||
|
||||
const consoleArgs: ConsolePanelProps = {
|
||||
stderrLogs: demoStderr,
|
||||
consoleUi: EMPTY_CONSOLE_UI,
|
||||
onConsoleUiChange: fn(),
|
||||
onClearConsole: fn(),
|
||||
onExportConsole: fn(),
|
||||
};
|
||||
|
||||
const meta: Meta<typeof InspectorView> = {
|
||||
title: "Views/InspectorView",
|
||||
component: InspectorView,
|
||||
parameters: { layout: "fullscreen" },
|
||||
args: {
|
||||
// Data
|
||||
servers: demoServers,
|
||||
tools: demoTools,
|
||||
prompts: demoPrompts,
|
||||
resources: demoResources,
|
||||
resourceTemplates: demoResourceTemplates,
|
||||
subscriptions: demoSubscriptions,
|
||||
logs: demoLogs,
|
||||
tasks: demoTasks,
|
||||
progressByTaskId: demoProgressByTaskId,
|
||||
protocol: demoHistory,
|
||||
network: demoNetwork,
|
||||
stderrLogs: demoStderr,
|
||||
|
||||
// Connection state — stories default to "disconnected"; per-story
|
||||
// overrides drive the connected / error narratives.
|
||||
activeServer: undefined,
|
||||
connectionStatus: "disconnected",
|
||||
initializeResult: undefined,
|
||||
latencyMs: undefined,
|
||||
|
||||
// Misc state
|
||||
currentLogLevel: "info",
|
||||
sandboxPath: "about:blank",
|
||||
bridgeFactory: noopBridgeFactory,
|
||||
appRendererRef: { current: null },
|
||||
|
||||
// Per-screen UI state (search / filter / selection), one object per screen.
|
||||
toolsUi: EMPTY_TOOLS_UI,
|
||||
promptsUi: EMPTY_PROMPTS_UI,
|
||||
resourcesUi: EMPTY_RESOURCES_UI,
|
||||
appsUi: EMPTY_APPS_UI,
|
||||
tasksUi: EMPTY_TASKS_UI,
|
||||
logsUi: EMPTY_LOGS_UI,
|
||||
protocolUi: EMPTY_PROTOCOL_UI,
|
||||
networkUi: EMPTY_NETWORK_UI,
|
||||
consoleUi: EMPTY_CONSOLE_UI,
|
||||
|
||||
// Callbacks — all wired to storybook spies so play functions can assert
|
||||
// on dispatch. Real wiring routes these to InspectorClient methods (the
|
||||
// app shell at clients/web/src/App.tsx).
|
||||
onToggleTheme: fn(),
|
||||
onOpenClientSettings: fn(),
|
||||
onToggleConnection: fn(),
|
||||
onDisconnect: fn(),
|
||||
onServerAdd: fn(),
|
||||
onServerImportConfig: fn(),
|
||||
onServerImportJson: fn(),
|
||||
onServerExport: fn(),
|
||||
onConnectionInfo: fn(),
|
||||
onServerSettings: fn(),
|
||||
onServerEdit: fn(),
|
||||
onServerClone: fn(),
|
||||
onServerRemove: fn(),
|
||||
onServerReorder: fn(),
|
||||
serverSupportsTaskToolCalls: false,
|
||||
onToolsUiChange: fn(),
|
||||
onCallTool: fn(),
|
||||
onRefreshTools: fn(),
|
||||
toolsPagination: noopPagination,
|
||||
promptsPagination: noopPagination,
|
||||
resourcesPagination: noopPagination,
|
||||
onPromptsUiChange: fn(),
|
||||
onGetPrompt: fn(),
|
||||
onRefreshPrompts: fn(),
|
||||
onResourcesUiChange: fn(),
|
||||
onReadResource: fn(),
|
||||
onSubscribeResource: fn(),
|
||||
onUnsubscribeResource: fn(),
|
||||
onRefreshResources: fn(),
|
||||
onTasksUiChange: fn(),
|
||||
onCancelTask: fn(),
|
||||
onClearCompletedTasks: fn(),
|
||||
onRefreshTasks: fn(),
|
||||
onSetLogLevel: fn(),
|
||||
onLogsUiChange: fn(),
|
||||
onClearLogs: fn(),
|
||||
onExportLogs: fn(),
|
||||
onProtocolUiChange: fn(),
|
||||
onClearProtocol: fn(),
|
||||
onExportProtocol: fn(),
|
||||
onReplayProtocol: fn(),
|
||||
onTogglePinProtocol: fn(),
|
||||
onNetworkUiChange: fn(),
|
||||
onClearNetwork: fn(),
|
||||
onExportNetwork: fn(),
|
||||
onConsoleUiChange: fn(),
|
||||
onClearConsole: fn(),
|
||||
onExportConsole: fn(),
|
||||
onAppsUiChange: fn(),
|
||||
onSelectApp: fn(),
|
||||
onOpenApp: fn(),
|
||||
onCloseApp: fn(),
|
||||
onAppError: fn(),
|
||||
onRefreshApps: fn(),
|
||||
activeTab: "Servers",
|
||||
onActiveTabChange: fn(),
|
||||
shell: shellArgs,
|
||||
connection: connectionArgs,
|
||||
servers: serversArgs,
|
||||
tools: toolsArgs,
|
||||
prompts: promptsArgs,
|
||||
resources: resourcesArgs,
|
||||
apps: appsArgs,
|
||||
tasks: tasksArgs,
|
||||
logs: logsArgs,
|
||||
protocol: protocolArgs,
|
||||
network: networkArgs,
|
||||
console: consoleArgs,
|
||||
},
|
||||
render: (args) => {
|
||||
const [activeTab, setActiveTab] = useState(args.activeTab ?? "Servers");
|
||||
const [activeTab, setActiveTab] = useState(args.shell.activeTab);
|
||||
return (
|
||||
<InspectorView
|
||||
{...args}
|
||||
activeTab={activeTab}
|
||||
onActiveTabChange={setActiveTab}
|
||||
shell={{ ...args.shell, activeTab, onActiveTabChange: setActiveTab }}
|
||||
/>
|
||||
);
|
||||
},
|
||||
|
|
@ -463,7 +525,7 @@ export const Default: Story = {
|
|||
|
||||
export const NoServers: Story = {
|
||||
args: {
|
||||
servers: [],
|
||||
servers: { ...serversArgs, servers: [] },
|
||||
},
|
||||
};
|
||||
|
||||
|
|
@ -480,7 +542,7 @@ const manyServers: ServerEntry[] = Array.from({ length: 24 }, (_, i) => ({
|
|||
|
||||
export const ManyServers: Story = {
|
||||
args: {
|
||||
servers: manyServers,
|
||||
servers: { ...serversArgs, servers: manyServers },
|
||||
},
|
||||
play: async ({ canvasElement }) => {
|
||||
// Even under enough content to overflow, the shell stays viewport-clamped
|
||||
|
|
@ -509,10 +571,13 @@ export const ManyServers: Story = {
|
|||
// regression / storybook play function coverage.
|
||||
export const Connected: Story = {
|
||||
args: {
|
||||
activeServer: demoServers[0]!.id,
|
||||
connectionStatus: "connected",
|
||||
initializeResult: demoInitializeResult,
|
||||
latencyMs: 142,
|
||||
connection: {
|
||||
...connectionArgs,
|
||||
activeServer: demoServers[0]!.id,
|
||||
connectionStatus: "connected",
|
||||
initializeResult: demoInitializeResult,
|
||||
latencyMs: 142,
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
|
|
@ -524,10 +589,13 @@ export const ConnectionError: Story = {
|
|||
// connect-attempt-failure signal that gates the failure column; `network: []`
|
||||
// (and empty Protocol) keep it a pure stdio failure so only Console is offered.
|
||||
args: {
|
||||
activeServer: demoServers[0]!.id,
|
||||
erroredServerId: demoServers[0]!.id,
|
||||
connectionStatus: "error",
|
||||
network: [],
|
||||
stderrLogs: demoStderr,
|
||||
connection: {
|
||||
...connectionArgs,
|
||||
activeServer: demoServers[0]!.id,
|
||||
erroredServerId: demoServers[0]!.id,
|
||||
connectionStatus: "error",
|
||||
},
|
||||
network: { ...networkArgs, network: [] },
|
||||
console: { ...consoleArgs, stderrLogs: demoStderr },
|
||||
},
|
||||
};
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load diff
|
|
@ -1,14 +1,4 @@
|
|||
import {
|
||||
useEffect,
|
||||
useMemo,
|
||||
useRef,
|
||||
useState,
|
||||
type ReactNode,
|
||||
type Ref,
|
||||
} from "react";
|
||||
import type { MalformedListItem } from "@inspector/core/mcp";
|
||||
import type { ReplayParamsOverride } from "../../../lib/protocolReplay";
|
||||
import type { DeepLink, DeepLinkParseStatus } from "../../../utils/deepLink";
|
||||
import { useEffect, useMemo, useRef, useState, type ReactNode } from "react";
|
||||
import {
|
||||
AppShell,
|
||||
Flex,
|
||||
|
|
@ -18,81 +8,38 @@ import {
|
|||
type MantineTransition,
|
||||
} from "@mantine/core";
|
||||
import { useLocalStorage } from "@mantine/hooks";
|
||||
import type {
|
||||
Implementation,
|
||||
InitializeResult,
|
||||
LoggingLevel,
|
||||
Prompt,
|
||||
ProtocolEra,
|
||||
ReadResourceResult,
|
||||
Resource,
|
||||
ResourceTemplateType as ResourceTemplate,
|
||||
Task,
|
||||
Tool,
|
||||
} from "@modelcontextprotocol/client";
|
||||
import type {
|
||||
ConnectionStatus,
|
||||
ExcludedTool,
|
||||
FetchRequestEntry,
|
||||
InspectorResourceSubscription,
|
||||
MessageEntry,
|
||||
ResourceSubscriptionStreamState,
|
||||
ServerEntry,
|
||||
StderrLogEntry,
|
||||
} from "@inspector/core/mcp/types.js";
|
||||
import type { Implementation, Tool } from "@modelcontextprotocol/client";
|
||||
import type { ServerEntry } from "@inspector/core/mcp/types.js";
|
||||
import { isTerminalStatus } from "@inspector/core/mcp/types.js";
|
||||
import { isAppTool } from "@inspector/core/mcp/apps.js";
|
||||
import { TASKS_EXTENSION_KEY } from "@inspector/core/mcp/modernTaskSchemas.js";
|
||||
import { ViewHeader } from "../../groups/ViewHeader/ViewHeader";
|
||||
import { VersionBadge } from "../../elements/VersionBadge/VersionBadge";
|
||||
import { CopyrightBadge } from "../../elements/CopyrightBadge/CopyrightBadge";
|
||||
import type { ListPaginationControlsProps } from "../../elements/ListPaginationControls/ListPaginationControls";
|
||||
import { ServerListScreen } from "../../screens/ServerListScreen/ServerListScreen";
|
||||
import {
|
||||
ToolsScreen,
|
||||
type ToolCallState,
|
||||
type ToolsUiState,
|
||||
} from "../../screens/ToolsScreen/ToolsScreen";
|
||||
import {
|
||||
AppsScreen,
|
||||
type AppsUiState,
|
||||
} from "../../screens/AppsScreen/AppsScreen";
|
||||
import { ToolsScreen } from "../../screens/ToolsScreen/ToolsScreen";
|
||||
import { AppsScreen } from "../../screens/AppsScreen/AppsScreen";
|
||||
import { PromptsScreen } from "../../screens/PromptsScreen/PromptsScreen";
|
||||
import { ResourcesScreen } from "../../screens/ResourcesScreen/ResourcesScreen";
|
||||
import { LoggingScreen } from "../../screens/LoggingScreen/LoggingScreen";
|
||||
import { TasksScreen } from "../../screens/TasksScreen/TasksScreen";
|
||||
import { ProtocolScreen } from "../../screens/ProtocolScreen/ProtocolScreen";
|
||||
import { NetworkScreen } from "../../screens/NetworkScreen/NetworkScreen";
|
||||
import { ConsoleScreen } from "../../screens/ConsoleScreen/ConsoleScreen";
|
||||
import type {
|
||||
AppRendererHandle,
|
||||
BridgeFactory,
|
||||
} from "../../elements/AppRenderer/AppRenderer";
|
||||
import {
|
||||
PromptsScreen,
|
||||
type GetPromptState,
|
||||
type PromptsUiState,
|
||||
} from "../../screens/PromptsScreen/PromptsScreen";
|
||||
import {
|
||||
ResourcesScreen,
|
||||
type ReadResourceState,
|
||||
type ResourcesUiState,
|
||||
} from "../../screens/ResourcesScreen/ResourcesScreen";
|
||||
import {
|
||||
LoggingScreen,
|
||||
type LogsUiState,
|
||||
} from "../../screens/LoggingScreen/LoggingScreen";
|
||||
import type { LogEntryData } from "../../elements/LogEntry/LogEntry";
|
||||
import {
|
||||
TasksScreen,
|
||||
type TasksUiState,
|
||||
} from "../../screens/TasksScreen/TasksScreen";
|
||||
import type { TaskProgress } from "../../groups/TaskCard/TaskCard";
|
||||
import {
|
||||
ProtocolScreen,
|
||||
type ProtocolUiState,
|
||||
} from "../../screens/ProtocolScreen/ProtocolScreen";
|
||||
import {
|
||||
NetworkScreen,
|
||||
type NetworkUiState,
|
||||
} from "../../screens/NetworkScreen/NetworkScreen";
|
||||
import {
|
||||
ConsoleScreen,
|
||||
type ConsoleUiState,
|
||||
} from "../../screens/ConsoleScreen/ConsoleScreen";
|
||||
AppsPanelProps,
|
||||
ConnectionProps,
|
||||
ConsolePanelProps,
|
||||
LogsPanelProps,
|
||||
NetworkPanelProps,
|
||||
PromptsPanelProps,
|
||||
ProtocolPanelProps,
|
||||
ResourcesPanelProps,
|
||||
ServerListProps,
|
||||
ShellProps,
|
||||
TasksPanelProps,
|
||||
ToolsPanelProps,
|
||||
} from "./types";
|
||||
import type { SortDirection } from "../../elements/SortToggle/SortToggle";
|
||||
import { ScreenStage } from "../../elements/ScreenStage/ScreenStage";
|
||||
import { MonitoringScreen } from "../../groups/MonitoringScreen/MonitoringScreen";
|
||||
|
|
@ -360,402 +307,193 @@ const MonitorColumnGroup = Group.withProps({
|
|||
flex: "0 0 auto",
|
||||
});
|
||||
|
||||
/**
|
||||
* `InspectorView` takes one prop per domain rather than a wall of ~130 flat
|
||||
* ones (#2130). Each bundle's shape lives in `./types`; the fields inside keep
|
||||
* the names they had as flat props, so this is a regrouping rather than a
|
||||
* rename.
|
||||
*
|
||||
* The bundles stop here: the function below destructures each one back into
|
||||
* the same locals it already used, and the screens beneath receive the same
|
||||
* individual props they always did.
|
||||
*/
|
||||
export interface InspectorViewProps {
|
||||
/**
|
||||
* Validated deep-link parameters from the page URL. When present and
|
||||
* `openApp` is set, the parent switches to the Apps tab and pre-selects that
|
||||
* app (with `appArgs` as the form values) once the connection is up and the
|
||||
* app list contains it. The connect itself is driven by the parent.
|
||||
*/
|
||||
deepLink?: DeepLink;
|
||||
/**
|
||||
* Outcome of parsing the initial-URL deep link, surfaced as `data-deeplink`
|
||||
* on the `connection-status` testid. Distinguishes "no deep link" from
|
||||
* "rejected" (token mismatch / bad serverUrl) — both leave `data-status`
|
||||
* idle, so an automated driver otherwise cannot tell them apart.
|
||||
*/
|
||||
deepLinkStatus?: DeepLinkParseStatus;
|
||||
|
||||
// Server list (static config; runtime connection state comes from the
|
||||
// separate fields below and is merged into each card by this component).
|
||||
servers: ServerEntry[];
|
||||
/**
|
||||
* Whether the server list is writable (catalog) or read-only (a `--config`
|
||||
* session file / ad-hoc launch). When false, the Servers screen hides all
|
||||
* catalog mutation controls. Defaults to true.
|
||||
*/
|
||||
serverListWritable?: boolean;
|
||||
|
||||
// Connection state — driven by the parent via `useInspectorClient`.
|
||||
activeServer?: string;
|
||||
/**
|
||||
* Id of the server whose last connection attempt failed (#1621). Its card in
|
||||
* the Servers screen draws a red border until another server is connected or
|
||||
* a new connection is attempted. Independent of `activeServer`, which the
|
||||
* parent clears on the failure's `disconnect` event.
|
||||
*
|
||||
* It is also the sole signal that opens the monitoring sidebar onto the
|
||||
* failure's diagnostics (#2108). That used to be gated on the `"error"`
|
||||
* connection status, which an OAuth failure never reaches — the parent drives
|
||||
* that leg and tears the client down itself, settling the session at
|
||||
* `"disconnected"`. So the parent must set this for *every* connect-attempt
|
||||
* failure, auth legs included, and clear it as each new attempt starts.
|
||||
*/
|
||||
erroredServerId?: string;
|
||||
/**
|
||||
* Id of the server that just connected successfully (#1682). Its card draws
|
||||
* the green highlight border and scrolls into view once the monitoring
|
||||
* sidebar has opened — the success mirror of `erroredServerId`.
|
||||
*/
|
||||
connectedServerId?: string;
|
||||
/**
|
||||
* The Inspector build version (root `package.json`), shown at the left of the
|
||||
* footer row (#1682). Absent on a legacy backend that omits it — the version
|
||||
* label then renders nothing.
|
||||
*/
|
||||
version?: string;
|
||||
connectionStatus: ConnectionStatus;
|
||||
/**
|
||||
* Last connection-level error message (handshake failure, OAuth start
|
||||
* failure, deep-link automation failure). Surfaced as `data-error-message`
|
||||
* on the header's `connection-status` testid so an automated driver can read
|
||||
* *why* a connect failed without scraping a transient toast.
|
||||
*/
|
||||
connectErrorMessage?: string;
|
||||
initializeResult?: InitializeResult;
|
||||
latencyMs?: number;
|
||||
|
||||
// Primitive lists, log streams, task state — all sourced from the
|
||||
// per-primitive `useManaged*` / `useMessageLog` hooks in the parent.
|
||||
tools: Tool[];
|
||||
/** Tools excluded from `tools/list` for invalid `x-mcp-header` annotations
|
||||
* (SEP-2243); shown in the Tools sidebar with the reason (#1632). */
|
||||
excludedTools?: ExcludedTool[];
|
||||
/**
|
||||
* Entries dropped from a list result as malformed, across every list method.
|
||||
* Each list panel filters for its own and warns about them (#1909).
|
||||
*/
|
||||
malformedListItems?: MalformedListItem[];
|
||||
prompts: Prompt[];
|
||||
resources: Resource[];
|
||||
resourceTemplates: ResourceTemplate[];
|
||||
subscriptions: InspectorResourceSubscription[];
|
||||
/**
|
||||
* Modern-era `subscriptions/listen` stream state (#1630). Drives the
|
||||
* Resources screen's stream badge/dot; `active: false` (or omitted) on the
|
||||
* legacy era.
|
||||
*/
|
||||
subscriptionStreamState?: ResourceSubscriptionStreamState;
|
||||
|
||||
// "List changed since last refresh" flags, sourced from the managed-state
|
||||
// layer (#1402). They light the per-screen list-changed indicator. Apps is a
|
||||
// filtered view of tools, so it shares the tools flag.
|
||||
toolsListChanged: boolean;
|
||||
promptsListChanged: boolean;
|
||||
resourcesListChanged: boolean;
|
||||
|
||||
// Last list-load failure per screen, sourced from the same managed-state
|
||||
// layer. Rendered above the sidebar list so a failed load (including the
|
||||
// connect-time one) can't read as "this server has none" (#1953).
|
||||
toolsLoadError?: Error | null;
|
||||
promptsLoadError?: Error | null;
|
||||
/**
|
||||
* The Resources sidebar lists resources AND templates behind a single
|
||||
* Refresh, so App passes whichever of the two loads failed.
|
||||
*/
|
||||
resourcesLoadError?: Error | null;
|
||||
logs: LogEntryData[];
|
||||
tasks: Task[];
|
||||
progressByTaskId?: Record<string, TaskProgress>;
|
||||
protocol: MessageEntry[];
|
||||
/** Negotiated protocol era (SEP §7.8), for the Protocol view's era badge. */
|
||||
protocolEra?: ProtocolEra;
|
||||
network: FetchRequestEntry[];
|
||||
/** Captured stdio stderr (the Console screen). Empty for HTTP servers. (#1621) */
|
||||
stderrLogs: StderrLogEntry[];
|
||||
|
||||
// Per-screen "operation in flight" states (panel-level; optional because
|
||||
// the underlying screens accept them as optional).
|
||||
toolCallState?: ToolCallState;
|
||||
getPromptState?: GetPromptState;
|
||||
readResourceState?: ReadResourceState;
|
||||
|
||||
// Per-screen selection / search / filter state, one object per screen. Owned
|
||||
// by the parent (App) so it persists across tab navigation within a live
|
||||
// session — the screens unmount on tab switch, so screen-local state would be
|
||||
// lost (#1417). Each is paired with an `on{Screen}UiChange` setter below.
|
||||
toolsUi: ToolsUiState;
|
||||
promptsUi: PromptsUiState;
|
||||
resourcesUi: ResourcesUiState;
|
||||
appsUi: AppsUiState;
|
||||
tasksUi: TasksUiState;
|
||||
logsUi: LogsUiState;
|
||||
protocolUi: ProtocolUiState;
|
||||
networkUi: NetworkUiState;
|
||||
consoleUi: ConsoleUiState;
|
||||
|
||||
/** Active inspector tab (lifted to App for OAuth resume). */
|
||||
activeTab: string;
|
||||
onActiveTabChange: (tab: string) => void;
|
||||
|
||||
// Logging level. The MCP `logging/setLevel` request has no echo
|
||||
// notification, so the parent keeps the optimistic current value.
|
||||
currentLogLevel: LoggingLevel;
|
||||
|
||||
// MCP Apps sandbox. The parent's web environment provides the sandbox iframe
|
||||
// URL (undefined when the sandbox controller is unavailable), the per-app
|
||||
// bridge factory, and the renderer handle the parent uses to push tool
|
||||
// input/result into the running app and tear it down.
|
||||
sandboxPath?: string;
|
||||
bridgeFactory: BridgeFactory;
|
||||
appRendererRef: Ref<AppRendererHandle>;
|
||||
|
||||
// Protocol pinning. Optional because pin state isn't persisted yet (#1244
|
||||
// is single-PR; persistence is a separate concern).
|
||||
pinnedProtocolIds?: Set<string>;
|
||||
|
||||
// Theme toggle (lives in the parent so the color scheme can also flow
|
||||
// into other top-level UI later).
|
||||
onToggleTheme: () => void;
|
||||
/** Open install-level client settings (client.json / EMA IdP). */
|
||||
onOpenClientSettings: () => void;
|
||||
|
||||
// Connection lifecycle (dispatched to `useInspectorClient.connect/disconnect`).
|
||||
onToggleConnection: (id: string) => void;
|
||||
onDisconnect: () => void;
|
||||
|
||||
// Server list actions.
|
||||
onServerAdd: () => void;
|
||||
onServerImportConfig: () => void;
|
||||
onServerImportJson: () => void;
|
||||
/** Download the current server list as a canonical `mcp.json` file. */
|
||||
onServerExport: () => void;
|
||||
onConnectionInfo: (id: string) => void;
|
||||
onServerSettings: (id: string) => void;
|
||||
onServerEdit: (id: string) => void;
|
||||
onServerClone: (id: string) => void;
|
||||
onServerRemove: (id: string) => void;
|
||||
/** Persist a new server ordering (drag-and-drop / keyboard reorder). */
|
||||
onServerReorder: (orderedIds: string[]) => void;
|
||||
/** Ids of freshly-added servers to highlight on the list (first is scrolled to). */
|
||||
highlightedServerIds?: string[];
|
||||
/** Clears the freshly-added highlight for a server (on click of its card). */
|
||||
onClearHighlight?: (id: string) => void;
|
||||
|
||||
// Per-primitive actions (route to `inspectorClient` methods / hook refresh).
|
||||
// Each `on{Screen}UiChange` persists that screen's lifted UI state (#1417).
|
||||
/** Whether the connected server advertises task-augmented tool calls. */
|
||||
serverSupportsTaskToolCalls: boolean;
|
||||
onToolsUiChange: (next: ToolsUiState) => void;
|
||||
onCallTool: (
|
||||
name: string,
|
||||
args: Record<string, unknown>,
|
||||
runAsTask?: boolean,
|
||||
) => void;
|
||||
onCancelToolCall?: () => void;
|
||||
onClearToolResult?: () => void;
|
||||
onRefreshTools: () => void;
|
||||
/** Pagination controls for the Tools list (#1721). */
|
||||
toolsPagination: ListPaginationControlsProps;
|
||||
/** Pagination controls for the Prompts list (#1721). */
|
||||
promptsPagination: ListPaginationControlsProps;
|
||||
/** Pagination controls for the Resources list (#1721). */
|
||||
resourcesPagination: ListPaginationControlsProps;
|
||||
/**
|
||||
* Read-on-demand handler for `resource_link` blocks in a tool result.
|
||||
* Returns the linked resource's contents so the result panel can inline them.
|
||||
*/
|
||||
onReadResourceContents?: (uri: string) => Promise<ReadResourceResult>;
|
||||
|
||||
onPromptsUiChange: (next: PromptsUiState) => void;
|
||||
onGetPrompt: (name: string, args: Record<string, string>) => void;
|
||||
onCopyPromptMessages?: () => void;
|
||||
onRefreshPrompts: () => void;
|
||||
|
||||
onResourcesUiChange: (next: ResourcesUiState) => void;
|
||||
onReadResource: (uri: string) => void;
|
||||
onSubscribeResource: (uri: string) => void;
|
||||
onUnsubscribeResource: (uri: string) => void;
|
||||
onRefreshResources: () => void;
|
||||
onCompleteArgument?: (
|
||||
ref:
|
||||
| { type: "ref/resource"; uri: string }
|
||||
| { type: "ref/prompt"; name: string },
|
||||
argumentName: string,
|
||||
argumentValue: string,
|
||||
context: Record<string, string>,
|
||||
) => Promise<string[]>;
|
||||
completionsSupported?: boolean;
|
||||
/**
|
||||
* Whether the connected server advertises the `resources.subscribe`
|
||||
* capability. When false, the Resources screen hides the Subscribe/
|
||||
* Unsubscribe button and the Subscriptions accordion section.
|
||||
*/
|
||||
subscriptionsSupported?: boolean;
|
||||
|
||||
onTasksUiChange: (next: TasksUiState) => void;
|
||||
onCancelTask: (taskId: string) => void;
|
||||
onClearCompletedTasks: () => void;
|
||||
onRefreshTasks: () => void;
|
||||
|
||||
onSetLogLevel: (level: LoggingLevel) => void;
|
||||
/**
|
||||
* Modern-era per-request log level currently stamped, or `null` when opted
|
||||
* out (#1629). On modern connections the Logs sidebar shows a per-request
|
||||
* opt-in control instead of the legacy `logging/setLevel` selector.
|
||||
*/
|
||||
modernLogLevel?: LoggingLevel | null;
|
||||
/** Set (or clear, with `null`) the modern per-request log level. */
|
||||
onSetModernLogLevel?: (level: LoggingLevel | null) => void;
|
||||
onLogsUiChange: (next: LogsUiState) => void;
|
||||
onClearLogs: () => void;
|
||||
onExportLogs: () => void;
|
||||
|
||||
onProtocolUiChange: (next: ProtocolUiState) => void;
|
||||
onClearProtocol: () => void;
|
||||
onExportProtocol: () => void;
|
||||
onClearProtocolSection: (section: "pinned" | "history") => void;
|
||||
onExportProtocolSection: (section: "pinned" | "history") => void;
|
||||
onReplayProtocol: (id: string, overrideParams?: ReplayParamsOverride) => void;
|
||||
onTogglePinProtocol: (id: string) => void;
|
||||
|
||||
onNetworkUiChange: (next: NetworkUiState) => void;
|
||||
onClearNetwork: () => void;
|
||||
onExportNetwork: () => void;
|
||||
|
||||
onConsoleUiChange: (next: ConsoleUiState) => void;
|
||||
onClearConsole: () => void;
|
||||
onExportConsole: () => void;
|
||||
|
||||
onAppsUiChange: (next: AppsUiState) => void;
|
||||
onSelectApp: (name: string) => void;
|
||||
onOpenApp: (name: string, args: Record<string, unknown>) => void;
|
||||
onCloseApp: () => void;
|
||||
onAppError: (err: Error) => void;
|
||||
onRefreshApps: () => void;
|
||||
/** App chrome: active tab, theme/settings entry points, footer, deep link. */
|
||||
shell: ShellProps;
|
||||
/** Live connection state plus the capability facts several screens read. */
|
||||
connection: ConnectionProps;
|
||||
/** The server catalog and every action on it. */
|
||||
servers: ServerListProps;
|
||||
tools: ToolsPanelProps;
|
||||
prompts: PromptsPanelProps;
|
||||
resources: ResourcesPanelProps;
|
||||
apps: AppsPanelProps;
|
||||
tasks: TasksPanelProps;
|
||||
logs: LogsPanelProps;
|
||||
protocol: ProtocolPanelProps;
|
||||
network: NetworkPanelProps;
|
||||
console: ConsolePanelProps;
|
||||
}
|
||||
|
||||
export function InspectorView({
|
||||
deepLink,
|
||||
deepLinkStatus,
|
||||
servers: serversInput,
|
||||
serverListWritable = true,
|
||||
activeServer,
|
||||
erroredServerId,
|
||||
connectedServerId,
|
||||
version,
|
||||
connectionStatus,
|
||||
connectErrorMessage,
|
||||
initializeResult,
|
||||
latencyMs,
|
||||
tools,
|
||||
excludedTools = [],
|
||||
malformedListItems = [],
|
||||
prompts,
|
||||
resources,
|
||||
resourceTemplates,
|
||||
toolsListChanged,
|
||||
promptsListChanged,
|
||||
resourcesListChanged,
|
||||
toolsLoadError,
|
||||
promptsLoadError,
|
||||
resourcesLoadError,
|
||||
subscriptions,
|
||||
subscriptionStreamState,
|
||||
logs,
|
||||
tasks,
|
||||
progressByTaskId,
|
||||
protocol,
|
||||
protocolEra,
|
||||
network,
|
||||
stderrLogs,
|
||||
toolCallState,
|
||||
getPromptState,
|
||||
readResourceState,
|
||||
toolsUi,
|
||||
promptsUi,
|
||||
resourcesUi,
|
||||
appsUi,
|
||||
tasksUi,
|
||||
logsUi,
|
||||
protocolUi,
|
||||
networkUi,
|
||||
consoleUi,
|
||||
currentLogLevel,
|
||||
sandboxPath,
|
||||
bridgeFactory,
|
||||
appRendererRef,
|
||||
pinnedProtocolIds,
|
||||
onToggleTheme,
|
||||
onOpenClientSettings,
|
||||
onToggleConnection,
|
||||
onDisconnect,
|
||||
onServerAdd,
|
||||
onServerImportConfig,
|
||||
onServerImportJson,
|
||||
onServerExport,
|
||||
onConnectionInfo,
|
||||
onServerSettings,
|
||||
onServerEdit,
|
||||
onServerClone,
|
||||
onServerRemove,
|
||||
onServerReorder,
|
||||
highlightedServerIds,
|
||||
onClearHighlight,
|
||||
serverSupportsTaskToolCalls,
|
||||
onToolsUiChange,
|
||||
onCallTool,
|
||||
onCancelToolCall,
|
||||
onClearToolResult,
|
||||
onReadResourceContents,
|
||||
onRefreshTools,
|
||||
toolsPagination,
|
||||
promptsPagination,
|
||||
resourcesPagination,
|
||||
onPromptsUiChange,
|
||||
onGetPrompt,
|
||||
onCopyPromptMessages,
|
||||
onRefreshPrompts,
|
||||
onResourcesUiChange,
|
||||
onReadResource,
|
||||
onSubscribeResource,
|
||||
onUnsubscribeResource,
|
||||
onRefreshResources,
|
||||
onCompleteArgument,
|
||||
completionsSupported,
|
||||
subscriptionsSupported,
|
||||
onTasksUiChange,
|
||||
onCancelTask,
|
||||
onClearCompletedTasks,
|
||||
onRefreshTasks,
|
||||
onSetLogLevel,
|
||||
modernLogLevel = null,
|
||||
onSetModernLogLevel,
|
||||
onLogsUiChange,
|
||||
onClearLogs,
|
||||
onExportLogs,
|
||||
onProtocolUiChange,
|
||||
onClearProtocol,
|
||||
onExportProtocol,
|
||||
onClearProtocolSection,
|
||||
onExportProtocolSection,
|
||||
onReplayProtocol,
|
||||
onTogglePinProtocol,
|
||||
onNetworkUiChange,
|
||||
onClearNetwork,
|
||||
onExportNetwork,
|
||||
onConsoleUiChange,
|
||||
onClearConsole,
|
||||
onExportConsole,
|
||||
onAppsUiChange,
|
||||
onSelectApp,
|
||||
onOpenApp,
|
||||
onCloseApp,
|
||||
onAppError,
|
||||
onRefreshApps,
|
||||
activeTab: activeTabProp,
|
||||
onActiveTabChange,
|
||||
shell,
|
||||
connection,
|
||||
servers: serverList,
|
||||
tools: toolsPanel,
|
||||
prompts: promptsPanel,
|
||||
resources: resourcesPanel,
|
||||
apps: appsPanel,
|
||||
tasks: tasksPanel,
|
||||
logs: logsPanel,
|
||||
protocol: protocolPanel,
|
||||
network: networkPanel,
|
||||
console: consolePanel,
|
||||
}: InspectorViewProps) {
|
||||
// Unpack the bundles back into the locals the body already uses, so the
|
||||
// grouping is confined to the prop boundary.
|
||||
const {
|
||||
deepLink,
|
||||
deepLinkStatus,
|
||||
version,
|
||||
malformedListItems = [],
|
||||
activeTab: activeTabProp,
|
||||
onActiveTabChange,
|
||||
onToggleTheme,
|
||||
onOpenClientSettings,
|
||||
} = shell;
|
||||
const {
|
||||
activeServer,
|
||||
erroredServerId,
|
||||
connectedServerId,
|
||||
connectionStatus,
|
||||
connectErrorMessage,
|
||||
initializeResult,
|
||||
latencyMs,
|
||||
protocolEra,
|
||||
onCompleteArgument,
|
||||
completionsSupported,
|
||||
onToggleConnection,
|
||||
onDisconnect,
|
||||
} = connection;
|
||||
const {
|
||||
servers: serversInput,
|
||||
serverListWritable = true,
|
||||
highlightedServerIds,
|
||||
onClearHighlight,
|
||||
onServerAdd,
|
||||
onServerImportConfig,
|
||||
onServerImportJson,
|
||||
onServerExport,
|
||||
onConnectionInfo,
|
||||
onServerSettings,
|
||||
onServerEdit,
|
||||
onServerClone,
|
||||
onServerRemove,
|
||||
onServerReorder,
|
||||
} = serverList;
|
||||
const {
|
||||
tools,
|
||||
excludedTools = [],
|
||||
toolsListChanged,
|
||||
toolsLoadError,
|
||||
toolsUi,
|
||||
toolCallState,
|
||||
toolsPagination,
|
||||
serverSupportsTaskToolCalls,
|
||||
onToolsUiChange,
|
||||
onCallTool,
|
||||
onCancelToolCall,
|
||||
onClearToolResult,
|
||||
onRefreshTools,
|
||||
onReadResourceContents,
|
||||
} = toolsPanel;
|
||||
const {
|
||||
prompts,
|
||||
promptsListChanged,
|
||||
promptsLoadError,
|
||||
promptsUi,
|
||||
getPromptState,
|
||||
promptsPagination,
|
||||
onPromptsUiChange,
|
||||
onGetPrompt,
|
||||
onCopyPromptMessages,
|
||||
onRefreshPrompts,
|
||||
} = promptsPanel;
|
||||
const {
|
||||
resources,
|
||||
resourceTemplates,
|
||||
subscriptions,
|
||||
subscriptionStreamState,
|
||||
subscriptionsSupported,
|
||||
resourcesListChanged,
|
||||
resourcesLoadError,
|
||||
resourcesUi,
|
||||
readResourceState,
|
||||
resourcesPagination,
|
||||
onResourcesUiChange,
|
||||
onReadResource,
|
||||
onSubscribeResource,
|
||||
onUnsubscribeResource,
|
||||
onRefreshResources,
|
||||
} = resourcesPanel;
|
||||
const {
|
||||
appsUi,
|
||||
sandboxPath,
|
||||
bridgeFactory,
|
||||
appRendererRef,
|
||||
onAppsUiChange,
|
||||
onSelectApp,
|
||||
onOpenApp,
|
||||
onCloseApp,
|
||||
onAppError,
|
||||
onRefreshApps,
|
||||
} = appsPanel;
|
||||
const {
|
||||
tasks,
|
||||
progressByTaskId,
|
||||
tasksUi,
|
||||
onTasksUiChange,
|
||||
onCancelTask,
|
||||
onClearCompletedTasks,
|
||||
onRefreshTasks,
|
||||
} = tasksPanel;
|
||||
const {
|
||||
logs,
|
||||
logsUi,
|
||||
currentLogLevel,
|
||||
modernLogLevel = null,
|
||||
onSetLogLevel,
|
||||
onSetModernLogLevel,
|
||||
onLogsUiChange,
|
||||
onClearLogs,
|
||||
onExportLogs,
|
||||
} = logsPanel;
|
||||
const {
|
||||
protocol,
|
||||
protocolUi,
|
||||
pinnedProtocolIds,
|
||||
onProtocolUiChange,
|
||||
onClearProtocol,
|
||||
onExportProtocol,
|
||||
onClearProtocolSection,
|
||||
onExportProtocolSection,
|
||||
onReplayProtocol,
|
||||
onTogglePinProtocol,
|
||||
} = protocolPanel;
|
||||
const {
|
||||
network,
|
||||
networkUi,
|
||||
onNetworkUiChange,
|
||||
onClearNetwork,
|
||||
onExportNetwork,
|
||||
} = networkPanel;
|
||||
const {
|
||||
stderrLogs,
|
||||
consoleUi,
|
||||
onConsoleUiChange,
|
||||
onClearConsole,
|
||||
onExportConsole,
|
||||
} = consolePanel;
|
||||
// UI-only state. Connection state, primitive lists, and all action
|
||||
// dispatching live in the parent; this component only owns view-local
|
||||
// toggles (sort direction, list compact). Tab selection is lifted (#1417).
|
||||
|
|
|
|||
379
clients/web/src/components/views/InspectorView/types.ts
Normal file
379
clients/web/src/components/views/InspectorView/types.ts
Normal file
|
|
@ -0,0 +1,379 @@
|
|||
// Domain prop bundles for `InspectorView` (#2130).
|
||||
//
|
||||
// The view used to take ~130 flat props, which made App.tsx's JSX a prop wall
|
||||
// rather than a component tree. Each bundle below groups one domain's data,
|
||||
// UI state, and actions into a single object, so the call site reads as the
|
||||
// tree it is and a hook can hand over exactly one bundle.
|
||||
//
|
||||
// Two conventions keep the change reviewable and keep it from rippling
|
||||
// downward:
|
||||
//
|
||||
// - **Field names are unchanged.** Every field carries the exact name it had
|
||||
// as a flat prop, so the bundles are a regrouping, not a rename.
|
||||
// - **The bundles stop here.** `InspectorView` destructures them back into
|
||||
// the same locals and passes them down to the screens as before, so
|
||||
// nothing below the view knows they exist.
|
||||
import type { Ref } from "react";
|
||||
import type {
|
||||
InitializeResult,
|
||||
LoggingLevel,
|
||||
Prompt,
|
||||
ProtocolEra,
|
||||
ReadResourceResult,
|
||||
Resource,
|
||||
ResourceTemplateType as ResourceTemplate,
|
||||
Task,
|
||||
Tool,
|
||||
} from "@modelcontextprotocol/client";
|
||||
import type { MalformedListItem } from "@inspector/core/mcp";
|
||||
import type {
|
||||
ConnectionStatus,
|
||||
ExcludedTool,
|
||||
FetchRequestEntry,
|
||||
InspectorResourceSubscription,
|
||||
MessageEntry,
|
||||
ResourceSubscriptionStreamState,
|
||||
ServerEntry,
|
||||
StderrLogEntry,
|
||||
} from "@inspector/core/mcp/types.js";
|
||||
import type { ReplayParamsOverride } from "../../../lib/protocolReplay";
|
||||
import type { DeepLink, DeepLinkParseStatus } from "../../../utils/deepLink";
|
||||
import type { ListPaginationControlsProps } from "../../elements/ListPaginationControls/ListPaginationControls";
|
||||
import type {
|
||||
AppRendererHandle,
|
||||
BridgeFactory,
|
||||
} from "../../elements/AppRenderer/AppRenderer";
|
||||
import type { LogEntryData } from "../../elements/LogEntry/LogEntry";
|
||||
import type { TaskProgress } from "../../groups/TaskCard/TaskCard";
|
||||
import type {
|
||||
ToolCallState,
|
||||
ToolsUiState,
|
||||
} from "../../screens/ToolsScreen/ToolsScreen";
|
||||
import type { AppsUiState } from "../../screens/AppsScreen/AppsScreen";
|
||||
import type {
|
||||
GetPromptState,
|
||||
PromptsUiState,
|
||||
} from "../../screens/PromptsScreen/PromptsScreen";
|
||||
import type {
|
||||
ReadResourceState,
|
||||
ResourcesUiState,
|
||||
} from "../../screens/ResourcesScreen/ResourcesScreen";
|
||||
import type { LogsUiState } from "../../screens/LoggingScreen/LoggingScreen";
|
||||
import type { TasksUiState } from "../../screens/TasksScreen/TasksScreen";
|
||||
import type { ProtocolUiState } from "../../screens/ProtocolScreen/ProtocolScreen";
|
||||
import type { NetworkUiState } from "../../screens/NetworkScreen/NetworkScreen";
|
||||
import type { ConsoleUiState } from "../../screens/ConsoleScreen/ConsoleScreen";
|
||||
|
||||
/**
|
||||
* App chrome and the few things that belong to no single screen: the active
|
||||
* tab, the theme/client-settings entry points, the footer version, the
|
||||
* deep-link parameters, and the malformed-list-item log that three separate
|
||||
* screens each filter for their own entries.
|
||||
*/
|
||||
export interface ShellProps {
|
||||
/**
|
||||
* Validated deep-link parameters from the page URL. When present and
|
||||
* `openApp` is set, the parent switches to the Apps tab and pre-selects that
|
||||
* app (with `appArgs` as the form values) once the connection is up and the
|
||||
* app list contains it. The connect itself is driven by the parent.
|
||||
*/
|
||||
deepLink?: DeepLink;
|
||||
/**
|
||||
* Outcome of parsing the initial-URL deep link, surfaced as `data-deeplink`
|
||||
* on the `connection-status` testid. Distinguishes "no deep link" from
|
||||
* "rejected" (token mismatch / bad serverUrl) — both leave `data-status`
|
||||
* idle, so an automated driver otherwise cannot tell them apart.
|
||||
*/
|
||||
deepLinkStatus?: DeepLinkParseStatus;
|
||||
/**
|
||||
* The Inspector build version (root `package.json`), shown at the left of the
|
||||
* footer row (#1682). Absent on a legacy backend that omits it — the version
|
||||
* label then renders nothing.
|
||||
*/
|
||||
version?: string;
|
||||
/**
|
||||
* Entries dropped from a list result as malformed, across every list method.
|
||||
* Each list panel filters for its own and warns about them (#1909). It spans
|
||||
* three screens, so it has no single domain owner.
|
||||
*/
|
||||
malformedListItems?: MalformedListItem[];
|
||||
/** Active inspector tab (lifted to App for OAuth resume). */
|
||||
activeTab: string;
|
||||
onActiveTabChange: (tab: string) => void;
|
||||
/**
|
||||
* Theme toggle (lives in the parent so the color scheme can also flow into
|
||||
* other top-level UI later).
|
||||
*/
|
||||
onToggleTheme: () => void;
|
||||
/** Open install-level client settings (client.json / EMA IdP). */
|
||||
onOpenClientSettings: () => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Live connection state and the capability facts more than one screen reads.
|
||||
* Driven by the parent via `useInspectorClient`.
|
||||
*/
|
||||
export interface ConnectionProps {
|
||||
activeServer?: string;
|
||||
/**
|
||||
* Id of the server whose last connection attempt failed (#1621). Its card in
|
||||
* the Servers screen draws a red border until another server is connected or
|
||||
* a new connection is attempted. Independent of `activeServer`, which the
|
||||
* parent clears on the failure's `disconnect` event.
|
||||
*
|
||||
* It is also the sole signal that opens the monitoring sidebar onto the
|
||||
* failure's diagnostics (#2108). That used to be gated on the `"error"`
|
||||
* connection status, which an OAuth failure never reaches — the parent drives
|
||||
* that leg and tears the client down itself, settling the session at
|
||||
* `"disconnected"`. So the parent must set this for *every* connect-attempt
|
||||
* failure, auth legs included, and clear it as each new attempt starts.
|
||||
*/
|
||||
erroredServerId?: string;
|
||||
/**
|
||||
* Id of the server that just connected successfully (#1682). Its card draws
|
||||
* the green highlight border and scrolls into view once the monitoring
|
||||
* sidebar has opened — the success mirror of `erroredServerId`.
|
||||
*/
|
||||
connectedServerId?: string;
|
||||
connectionStatus: ConnectionStatus;
|
||||
/**
|
||||
* Last connection-level error message (handshake failure, OAuth start
|
||||
* failure, deep-link automation failure). Surfaced as `data-error-message`
|
||||
* on the header's `connection-status` testid so an automated driver can read
|
||||
* *why* a connect failed without scraping a transient toast.
|
||||
*/
|
||||
connectErrorMessage?: string;
|
||||
initializeResult?: InitializeResult;
|
||||
latencyMs?: number;
|
||||
/** Negotiated protocol era (SEP §7.8), for the Protocol view's era badge. */
|
||||
protocolEra?: ProtocolEra;
|
||||
/**
|
||||
* Argument completion, shared by the Prompts and Resources screens — hence
|
||||
* here rather than in either one.
|
||||
*/
|
||||
onCompleteArgument?: (
|
||||
ref:
|
||||
| { type: "ref/resource"; uri: string }
|
||||
| { type: "ref/prompt"; name: string },
|
||||
argumentName: string,
|
||||
argumentValue: string,
|
||||
context: Record<string, string>,
|
||||
) => Promise<string[]>;
|
||||
completionsSupported?: boolean;
|
||||
// Connection lifecycle (dispatched to `useInspectorClient.connect/disconnect`).
|
||||
onToggleConnection: (id: string) => void;
|
||||
onDisconnect: () => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* The server catalog and every action on it. Static config only — the runtime
|
||||
* connection state comes from {@link ConnectionProps} and is merged into each
|
||||
* card by the view.
|
||||
*/
|
||||
export interface ServerListProps {
|
||||
servers: ServerEntry[];
|
||||
/**
|
||||
* Whether the server list is writable (catalog) or read-only (a `--config`
|
||||
* session file / ad-hoc launch). When false, the Servers screen hides all
|
||||
* catalog mutation controls. Defaults to true.
|
||||
*/
|
||||
serverListWritable?: boolean;
|
||||
/** Ids of freshly-added servers to highlight on the list (first is scrolled to). */
|
||||
highlightedServerIds?: string[];
|
||||
/** Clears the freshly-added highlight for a server (on click of its card). */
|
||||
onClearHighlight?: (id: string) => void;
|
||||
onServerAdd: () => void;
|
||||
onServerImportConfig: () => void;
|
||||
onServerImportJson: () => void;
|
||||
/** Download the current server list as a canonical `mcp.json` file. */
|
||||
onServerExport: () => void;
|
||||
onConnectionInfo: (id: string) => void;
|
||||
onServerSettings: (id: string) => void;
|
||||
onServerEdit: (id: string) => void;
|
||||
onServerClone: (id: string) => void;
|
||||
onServerRemove: (id: string) => void;
|
||||
/** Persist a new server ordering (drag-and-drop / keyboard reorder). */
|
||||
onServerReorder: (orderedIds: string[]) => void;
|
||||
}
|
||||
|
||||
/** The Tools screen: its list, lifted UI state, and actions. */
|
||||
export interface ToolsPanelProps {
|
||||
tools: Tool[];
|
||||
/**
|
||||
* Tools excluded from `tools/list` for invalid `x-mcp-header` annotations
|
||||
* (SEP-2243); shown in the Tools sidebar with the reason (#1632).
|
||||
*/
|
||||
excludedTools?: ExcludedTool[];
|
||||
/** "List changed since last refresh", from the managed-state layer (#1402). */
|
||||
toolsListChanged: boolean;
|
||||
/**
|
||||
* Last list-load failure, rendered above the sidebar list so a failed load
|
||||
* (including the connect-time one) can't read as "this server has none"
|
||||
* (#1953).
|
||||
*/
|
||||
toolsLoadError?: Error | null;
|
||||
/**
|
||||
* Lifted selection / search / filter state. Owned by the parent so it
|
||||
* persists across tab navigation within a live session — the screens unmount
|
||||
* on tab switch, so screen-local state would be lost (#1417).
|
||||
*/
|
||||
toolsUi: ToolsUiState;
|
||||
/** Panel-level "operation in flight" state. */
|
||||
toolCallState?: ToolCallState;
|
||||
/** Pagination controls for the Tools list (#1721). */
|
||||
toolsPagination: ListPaginationControlsProps;
|
||||
/** Whether the connected server advertises task-augmented tool calls. */
|
||||
serverSupportsTaskToolCalls: boolean;
|
||||
onToolsUiChange: (next: ToolsUiState) => void;
|
||||
onCallTool: (
|
||||
name: string,
|
||||
args: Record<string, unknown>,
|
||||
runAsTask?: boolean,
|
||||
) => void;
|
||||
onCancelToolCall?: () => void;
|
||||
onClearToolResult?: () => void;
|
||||
onRefreshTools: () => void;
|
||||
/**
|
||||
* Read-on-demand handler for `resource_link` blocks in a tool result.
|
||||
* Returns the linked resource's contents so the result panel can inline them.
|
||||
*/
|
||||
onReadResourceContents?: (uri: string) => Promise<ReadResourceResult>;
|
||||
}
|
||||
|
||||
/** The Prompts screen: its list, lifted UI state, and actions. */
|
||||
export interface PromptsPanelProps {
|
||||
prompts: Prompt[];
|
||||
promptsListChanged: boolean;
|
||||
promptsLoadError?: Error | null;
|
||||
promptsUi: PromptsUiState;
|
||||
getPromptState?: GetPromptState;
|
||||
/** Pagination controls for the Prompts list (#1721). */
|
||||
promptsPagination: ListPaginationControlsProps;
|
||||
onPromptsUiChange: (next: PromptsUiState) => void;
|
||||
onGetPrompt: (name: string, args: Record<string, string>) => void;
|
||||
onCopyPromptMessages?: () => void;
|
||||
onRefreshPrompts: () => void;
|
||||
}
|
||||
|
||||
/** The Resources screen: resources, templates, subscriptions, and actions. */
|
||||
export interface ResourcesPanelProps {
|
||||
resources: Resource[];
|
||||
resourceTemplates: ResourceTemplate[];
|
||||
subscriptions: InspectorResourceSubscription[];
|
||||
/**
|
||||
* Modern-era `subscriptions/listen` stream state (#1630). Drives the
|
||||
* Resources screen's stream badge/dot; `active: false` (or omitted) on the
|
||||
* legacy era.
|
||||
*/
|
||||
subscriptionStreamState?: ResourceSubscriptionStreamState;
|
||||
/**
|
||||
* Whether the connected server advertises the `resources.subscribe`
|
||||
* capability. When false, the Resources screen hides the Subscribe/
|
||||
* Unsubscribe button and the Subscriptions accordion section.
|
||||
*/
|
||||
subscriptionsSupported?: boolean;
|
||||
resourcesListChanged: boolean;
|
||||
/**
|
||||
* The Resources sidebar lists resources AND templates behind a single
|
||||
* Refresh, so App passes whichever of the two loads failed.
|
||||
*/
|
||||
resourcesLoadError?: Error | null;
|
||||
resourcesUi: ResourcesUiState;
|
||||
readResourceState?: ReadResourceState;
|
||||
/** Pagination controls for the Resources list (#1721). */
|
||||
resourcesPagination: ListPaginationControlsProps;
|
||||
onResourcesUiChange: (next: ResourcesUiState) => void;
|
||||
onReadResource: (uri: string) => void;
|
||||
onSubscribeResource: (uri: string) => void;
|
||||
onUnsubscribeResource: (uri: string) => void;
|
||||
onRefreshResources: () => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* The Apps screen and its sandbox wiring. The parent's web environment
|
||||
* provides the sandbox iframe URL (undefined when the sandbox controller is
|
||||
* unavailable), the per-app bridge factory, and the renderer handle the parent
|
||||
* uses to push tool input/result into the running app and tear it down.
|
||||
*/
|
||||
export interface AppsPanelProps {
|
||||
appsUi: AppsUiState;
|
||||
sandboxPath?: string;
|
||||
bridgeFactory: BridgeFactory;
|
||||
appRendererRef: Ref<AppRendererHandle>;
|
||||
onAppsUiChange: (next: AppsUiState) => void;
|
||||
onSelectApp: (name: string) => void;
|
||||
onOpenApp: (name: string, args: Record<string, unknown>) => void;
|
||||
onCloseApp: () => void;
|
||||
onAppError: (err: Error) => void;
|
||||
onRefreshApps: () => void;
|
||||
}
|
||||
|
||||
/** The Tasks monitor: the task list, its progress map, and actions. */
|
||||
export interface TasksPanelProps {
|
||||
tasks: Task[];
|
||||
progressByTaskId?: Record<string, TaskProgress>;
|
||||
tasksUi: TasksUiState;
|
||||
onTasksUiChange: (next: TasksUiState) => void;
|
||||
onCancelTask: (taskId: string) => void;
|
||||
onClearCompletedTasks: () => void;
|
||||
onRefreshTasks: () => void;
|
||||
}
|
||||
|
||||
/** The Logs monitor, including both eras' log-level controls. */
|
||||
export interface LogsPanelProps {
|
||||
logs: LogEntryData[];
|
||||
logsUi: LogsUiState;
|
||||
/**
|
||||
* The MCP `logging/setLevel` request has no echo notification, so the parent
|
||||
* keeps the optimistic current value.
|
||||
*/
|
||||
currentLogLevel: LoggingLevel;
|
||||
/**
|
||||
* Modern-era per-request log level currently stamped, or `null` when opted
|
||||
* out (#1629). On modern connections the Logs sidebar shows a per-request
|
||||
* opt-in control instead of the legacy `logging/setLevel` selector.
|
||||
*/
|
||||
modernLogLevel?: LoggingLevel | null;
|
||||
onSetLogLevel: (level: LoggingLevel) => void;
|
||||
/** Set (or clear, with `null`) the modern per-request log level. */
|
||||
onSetModernLogLevel?: (level: LoggingLevel | null) => void;
|
||||
onLogsUiChange: (next: LogsUiState) => void;
|
||||
onClearLogs: () => void;
|
||||
onExportLogs: () => void;
|
||||
}
|
||||
|
||||
/** The Protocol monitor: the message log, pinning, replay, and exports. */
|
||||
export interface ProtocolPanelProps {
|
||||
protocol: MessageEntry[];
|
||||
protocolUi: ProtocolUiState;
|
||||
/**
|
||||
* Pinned message ids. Optional because pin state isn't persisted yet (#1244
|
||||
* is single-PR; persistence is a separate concern).
|
||||
*/
|
||||
pinnedProtocolIds?: Set<string>;
|
||||
onProtocolUiChange: (next: ProtocolUiState) => void;
|
||||
onClearProtocol: () => void;
|
||||
onExportProtocol: () => void;
|
||||
onClearProtocolSection: (section: "pinned" | "history") => void;
|
||||
onExportProtocolSection: (section: "pinned" | "history") => void;
|
||||
onReplayProtocol: (id: string, overrideParams?: ReplayParamsOverride) => void;
|
||||
onTogglePinProtocol: (id: string) => void;
|
||||
}
|
||||
|
||||
/** The Network monitor: captured HTTP traffic and its actions. */
|
||||
export interface NetworkPanelProps {
|
||||
network: FetchRequestEntry[];
|
||||
networkUi: NetworkUiState;
|
||||
onNetworkUiChange: (next: NetworkUiState) => void;
|
||||
onClearNetwork: () => void;
|
||||
onExportNetwork: () => void;
|
||||
}
|
||||
|
||||
/** The Console monitor: captured stdio stderr. Empty for HTTP servers (#1621). */
|
||||
export interface ConsolePanelProps {
|
||||
stderrLogs: StderrLogEntry[];
|
||||
consoleUi: ConsoleUiState;
|
||||
onConsoleUiChange: (next: ConsoleUiState) => void;
|
||||
onClearConsole: () => void;
|
||||
onExportConsole: () => void;
|
||||
}
|
||||
|
|
@ -12,6 +12,8 @@ class FakeTransport implements Transport {
|
|||
sessionId?: string;
|
||||
// Optional on the SDK Transport interface; stdio omits it, HTTP defines it.
|
||||
setProtocolVersion?: (version: string) => void;
|
||||
// Optional too: Streamable HTTP advertises it, stdio and SSE say nothing.
|
||||
hasPerRequestStream?: boolean;
|
||||
async start(): Promise<void> {}
|
||||
async send(message: JSONRPCMessage): Promise<void> {
|
||||
this.sent.push(message);
|
||||
|
|
@ -272,4 +274,19 @@ describe("MessageTrackingTransport.setProtocolVersion", () => {
|
|||
expect(() => tracked.setProtocolVersion("2025-06-18")).not.toThrow();
|
||||
expect(tracked.protocolVersion).toBe("2025-06-18");
|
||||
});
|
||||
|
||||
// #2140: the SDK reads `hasPerRequestStream` off the transport it was handed
|
||||
// — always this wrapper — to choose between aborting a request's own stream
|
||||
// and POSTing `notifications/cancelled`. A wrapper that answers for the base
|
||||
// transport instead of forwarding its answer breaks Cancel on every client.
|
||||
it("forwards the base transport's per-request-stream capability", () => {
|
||||
const { tracked, base } = makeTracked();
|
||||
expect(tracked.hasPerRequestStream).toBeUndefined();
|
||||
|
||||
base.hasPerRequestStream = true;
|
||||
expect(tracked.hasPerRequestStream).toBe(true);
|
||||
|
||||
base.hasPerRequestStream = false;
|
||||
expect(tracked.hasPerRequestStream).toBe(false);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -569,4 +569,121 @@ describe("RemoteClientTransport", () => {
|
|||
|
||||
await transport.close();
|
||||
});
|
||||
|
||||
// #2140: the Cancel button's wire signal is chosen by the SDK from the
|
||||
// transport's `hasPerRequestStream`. The real upstream transport lives on the
|
||||
// backend, so this transport answers for it — and must answer per server
|
||||
// type, since only Streamable HTTP gives a request a stream of its own to
|
||||
// close.
|
||||
describe("per-request-stream cancellation (#2140)", () => {
|
||||
it("advertises hasPerRequestStream only for streamable-http", () => {
|
||||
const forType = (c: MCPServerConfig) =>
|
||||
new RemoteClientTransport(
|
||||
{ baseUrl, fetchFn: vi.fn<typeof fetch>() },
|
||||
c,
|
||||
).hasPerRequestStream;
|
||||
|
||||
expect(
|
||||
forType({ type: "streamable-http", url: "http://server.example/mcp" }),
|
||||
).toBe(true);
|
||||
// stdio and SSE multiplex every request over one shared channel, so
|
||||
// aborting anything would cancel the whole session rather than the call.
|
||||
// The SDK must keep sending `notifications/cancelled` for them.
|
||||
expect(forType(config)).toBe(false);
|
||||
expect(forType({ type: "sse", url: "http://server.example/sse" })).toBe(
|
||||
false,
|
||||
);
|
||||
});
|
||||
|
||||
it("applies the SDK's requestSignal to the browser-to-backend send fetch", async () => {
|
||||
const seenInits: (RequestInit | undefined)[] = [];
|
||||
let sse = createPushableSseStream();
|
||||
const fetchFn = vi
|
||||
.fn<typeof fetch>()
|
||||
.mockImplementation(async (input, init) => {
|
||||
const url = String(input);
|
||||
if (url.endsWith("/api/mcp/connect")) {
|
||||
return new Response(JSON.stringify({ sessionId: "abc" }), {
|
||||
status: 200,
|
||||
});
|
||||
}
|
||||
if (url.includes("/api/mcp/events")) {
|
||||
sse = createPushableSseStream();
|
||||
return sse.response;
|
||||
}
|
||||
if (url.endsWith("/api/mcp/send")) {
|
||||
seenInits.push(init);
|
||||
return new Response(JSON.stringify({ ok: true }), { status: 200 });
|
||||
}
|
||||
return new Response("not found", { status: 404 });
|
||||
});
|
||||
|
||||
const transport = new RemoteClientTransport({ baseUrl, fetchFn }, config);
|
||||
await transport.start();
|
||||
|
||||
const controller = new AbortController();
|
||||
await transport.send(
|
||||
{ jsonrpc: "2.0", method: "notifications/initialized" },
|
||||
{ requestSignal: controller.signal },
|
||||
);
|
||||
expect(seenInits[0]?.signal).toBe(controller.signal);
|
||||
|
||||
// No signal supplied (stdio/SSE, where the SDK never creates one) must
|
||||
// leave the fetch unaborted rather than passing `undefined` through as a
|
||||
// deliberate value.
|
||||
await transport.send({
|
||||
jsonrpc: "2.0",
|
||||
method: "notifications/roots/list_changed",
|
||||
});
|
||||
expect(seenInits[1]).not.toHaveProperty("signal");
|
||||
|
||||
await transport.close();
|
||||
});
|
||||
|
||||
it("aborting the requestSignal rejects the in-flight send", async () => {
|
||||
let sse = createPushableSseStream();
|
||||
const fetchFn = vi
|
||||
.fn<typeof fetch>()
|
||||
.mockImplementation(async (input, init) => {
|
||||
const url = String(input);
|
||||
if (url.endsWith("/api/mcp/connect")) {
|
||||
return new Response(JSON.stringify({ sessionId: "abc" }), {
|
||||
status: 200,
|
||||
});
|
||||
}
|
||||
if (url.includes("/api/mcp/events")) {
|
||||
sse = createPushableSseStream();
|
||||
return sse.response;
|
||||
}
|
||||
if (url.endsWith("/api/mcp/send")) {
|
||||
// The backend holds this route open for the whole call, so model it
|
||||
// as a fetch that only settles when the caller aborts.
|
||||
return new Promise<Response>((_resolve, reject) => {
|
||||
init?.signal?.addEventListener("abort", () =>
|
||||
reject(new DOMException("Aborted", "AbortError")),
|
||||
);
|
||||
});
|
||||
}
|
||||
return new Response("not found", { status: 404 });
|
||||
});
|
||||
|
||||
const transport = new RemoteClientTransport({ baseUrl, fetchFn }, config);
|
||||
await transport.start();
|
||||
|
||||
const controller = new AbortController();
|
||||
const sent = transport.send(
|
||||
{ jsonrpc: "2.0", id: 7, method: "tools/call" },
|
||||
{ requestSignal: controller.signal },
|
||||
);
|
||||
|
||||
controller.abort();
|
||||
// The backend never answers an aborted send, so the rejection has to come
|
||||
// from the fetch itself. `postSend` already cancels the SSE response wait
|
||||
// it registered for this id on that path (its catch does, unchanged by
|
||||
// #2140) — so the send surfaces the abort rather than the wait's timeout.
|
||||
await expect(sent).rejects.toThrow(/Aborted/);
|
||||
|
||||
await transport.close();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -20,6 +20,7 @@ import {
|
|||
createMrtrEdgeCaseTool,
|
||||
loadConfig,
|
||||
resolveConfig,
|
||||
type ToolDefinition,
|
||||
} from "@modelcontextprotocol/inspector-test-server";
|
||||
import type { ServerConfig } from "@modelcontextprotocol/inspector-test-server";
|
||||
import type {
|
||||
|
|
@ -437,6 +438,146 @@ describe("modern-era negotiation (2026-07-28)", () => {
|
|||
expect(result.result!.isError).toBeFalsy();
|
||||
});
|
||||
|
||||
// #2140: on the 2026-07-28 era, closing the request's own response stream IS
|
||||
// the cancellation signal for Streamable HTTP; `notifications/cancelled` is
|
||||
// the stdio mechanism and the spec says it is neither required nor expected
|
||||
// here. The SDK implements that fork off `transport.hasPerRequestStream` —
|
||||
// but every Inspector connection is wrapped in `MessageTrackingTransport`,
|
||||
// which was not forwarding it, so all three clients POSTed the notification
|
||||
// and a spec-compliant server acknowledged it `202` and kept running.
|
||||
//
|
||||
// This asserts at the far end, on the server's own request signal: nothing on
|
||||
// the client side distinguishes a cancel that reached the server from one
|
||||
// that was dropped, which is exactly how this shipped.
|
||||
it("cancels by aborting the request's stream, which the server observes", async () => {
|
||||
let sawAbort!: (value: boolean) => void;
|
||||
const aborted = new Promise<boolean>((resolve) => {
|
||||
sawAbort = resolve;
|
||||
});
|
||||
let started!: () => void;
|
||||
const running = new Promise<void>((resolve) => {
|
||||
started = resolve;
|
||||
});
|
||||
|
||||
const slowServer = createTestServerHttp({
|
||||
serverInfo: createTestServerInfo("cancel-era-test", "1.0.0"),
|
||||
tools: [
|
||||
{
|
||||
name: "slow_task",
|
||||
description: "Runs until the client cancels it",
|
||||
handler: async (_params, _context, extra) => {
|
||||
extra?.signal?.addEventListener("abort", () => sawAbort(true), {
|
||||
once: true,
|
||||
});
|
||||
started();
|
||||
// Never settles on its own, so a regression is a timeout rather
|
||||
// than a pass.
|
||||
return new Promise(() => {});
|
||||
},
|
||||
},
|
||||
],
|
||||
modern: {},
|
||||
});
|
||||
await slowServer.start();
|
||||
server = slowServer;
|
||||
|
||||
const connected = await connectWithEra(slowServer.url, "modern");
|
||||
const { tools } = await connected.listTools();
|
||||
const tool = tools.find((t) => t.name === "slow_task");
|
||||
|
||||
// Hold the rejection immediately so it is never seen as unhandled while we
|
||||
// wait on the server side.
|
||||
const settled = connected.callTool(tool!, {}).catch((err: unknown) => err);
|
||||
await running;
|
||||
expect(connected.cancelToolCall()).toBe(true);
|
||||
|
||||
await expect(settled).resolves.toBeInstanceOf(ToolCallCancelledError);
|
||||
await expect(aborted).resolves.toBe(true);
|
||||
}, 30_000);
|
||||
|
||||
// The test above builds its own never-returning tool, which is the sharpest
|
||||
// way to assert on the server's abort signal but says nothing about the
|
||||
// artifacts a human actually uses. This one drives the documented showcase
|
||||
// through the SAME path they would — the checked-in config, resolved through
|
||||
// the preset registry — so a misspelt preset name, a config naming a dead
|
||||
// preset, or a regression in the `slow_task` handler's own abort loop fails
|
||||
// here rather than only when someone runs the repro by hand.
|
||||
//
|
||||
// It observes the handler's own return value, not the client's view. Counting
|
||||
// progress notifications would be a false negative: the SDK drops a cancelled
|
||||
// request's progress handler locally, so the client stops seeing ticks the
|
||||
// moment it cancels whether or not the server ever stopped working — the very
|
||||
// silence this bug hid behind.
|
||||
it("stops the showcase tool's work on cancel (slow_task, via the showcase config)", async () => {
|
||||
const config = loadConfig(
|
||||
join(repoRoot, "test-servers/configs/cancellation-modern-http.json"),
|
||||
);
|
||||
expect(config.tools).toContainEqual({ preset: "slow_task" });
|
||||
|
||||
const resolved = resolveConfig(config);
|
||||
expect(resolved.tools?.map((tool) => tool.name)).toEqual([
|
||||
"slow_task",
|
||||
"echo",
|
||||
]);
|
||||
|
||||
// Wrap the resolved preset rather than replacing it, so what runs is the
|
||||
// real registered handler and only its outcome is observed. `tools` is a
|
||||
// union with the task-tool shape, whose handler has a different signature,
|
||||
// so narrow to the plain one this preset actually is.
|
||||
const preset = resolved.tools!.find(
|
||||
(tool): tool is ToolDefinition => tool.name === "slow_task",
|
||||
)!;
|
||||
let reportOutcome!: (text: string) => void;
|
||||
const outcome = new Promise<string>((resolve) => {
|
||||
reportOutcome = resolve;
|
||||
});
|
||||
let firstTick!: () => void;
|
||||
const running = new Promise<void>((resolve) => {
|
||||
firstTick = resolve;
|
||||
});
|
||||
|
||||
const started = createTestServerHttp({
|
||||
...resolved,
|
||||
tools: resolved.tools!.map((tool) =>
|
||||
tool.name === "slow_task"
|
||||
? {
|
||||
...preset,
|
||||
handler: async (params, context, extra) => {
|
||||
extra?.signal?.addEventListener("abort", () => firstTick(), {
|
||||
once: true,
|
||||
});
|
||||
const result = await preset.handler(params, context, extra);
|
||||
reportOutcome(JSON.stringify(result));
|
||||
return result;
|
||||
},
|
||||
}
|
||||
: tool,
|
||||
),
|
||||
});
|
||||
await started.start();
|
||||
server = started;
|
||||
const connected = await connectWithEra(started.url, "modern");
|
||||
|
||||
const { tools } = await connected.listTools();
|
||||
const tool = tools.find((t) => t.name === "slow_task");
|
||||
expect(tool).toBeDefined();
|
||||
|
||||
// Hold the rejection immediately so it is never seen as unhandled.
|
||||
const settled = connected
|
||||
.callTool(tool!, { seconds: 30 })
|
||||
.catch((err: unknown) => err);
|
||||
|
||||
// Let it get past its first tick, then cancel.
|
||||
await new Promise((resolve) => setTimeout(resolve, 1500));
|
||||
expect(connected.cancelToolCall()).toBe(true);
|
||||
await expect(settled).resolves.toBeInstanceOf(ToolCallCancelledError);
|
||||
await running;
|
||||
|
||||
// The handler must return promptly, saying it was cancelled — not run on to
|
||||
// its 30th second, which is what it did before the fix.
|
||||
await expect(outcome).resolves.toContain("cancelled after");
|
||||
}, 30_000);
|
||||
|
||||
it("cancels an in-flight MRTR call while its embedded request is pending", async () => {
|
||||
const started = await startMrtrServer(createMrtrTool());
|
||||
const connected = await connectWithEra(started.url, "modern");
|
||||
|
|
|
|||
|
|
@ -0,0 +1,199 @@
|
|||
/**
|
||||
* #2140 — the Cancel button must send the transport's cancellation signal.
|
||||
*
|
||||
* On a 2026-07-28 Streamable HTTP connection the spec makes *closing the
|
||||
* request's SSE response stream* the cancellation signal, and says a
|
||||
* `notifications/cancelled` is neither required nor expected. The SDK
|
||||
* implements that fork itself: `Protocol.request` aborts a per-request
|
||||
* `AbortController` — handed to the transport as
|
||||
* `TransportSendOptions.requestSignal` — when the connection is modern **and**
|
||||
* the transport advertises `hasPerRequestStream`; otherwise it POSTs the
|
||||
* notification.
|
||||
*
|
||||
* The web client lost it at the proxy boundary. The real upstream transport
|
||||
* lives on the Node backend, and the browser's `RemoteClientTransport` neither
|
||||
* advertised the flag nor applied a `requestSignal` to its
|
||||
* `POST /api/mcp/send` — so every web cancel took the stdio branch, the server
|
||||
* answered the notification `202 Accepted` and dropped it, and the tool kept
|
||||
* running to completion. Only a manual disconnect actually cancelled anything.
|
||||
*
|
||||
* This drives the whole chain the browser drives — InspectorClient ->
|
||||
* RemoteClientTransport -> the Hono backend -> a real upstream Streamable HTTP
|
||||
* transport -> a modern test server — and asserts at the far end, on the
|
||||
* server's own request abort signal. Nothing shallower reaches it: each of the
|
||||
* three hops can drop the signal on its own, and a unit test of any one of them
|
||||
* passes while the chain stays broken.
|
||||
*/
|
||||
|
||||
import { describe, it, expect, afterEach } from "vitest";
|
||||
import { serve } from "@hono/node-server";
|
||||
import type { ServerType } from "@hono/node-server";
|
||||
import { InspectorClient } from "@inspector/core/mcp/inspectorClient.js";
|
||||
import { createRemoteTransport } from "@inspector/core/mcp/remote/createRemoteTransport.js";
|
||||
import { createRemoteApp } from "@inspector/core/mcp/remote/node/server.js";
|
||||
import { ToolCallCancelledError } from "@inspector/core/mcp/toolCallCancelledError.js";
|
||||
import { eraToVersionNegotiation } from "@inspector/core/mcp/types.js";
|
||||
import {
|
||||
createTestServerHttp,
|
||||
createTestServerInfo,
|
||||
type TestServerHttp,
|
||||
type ToolDefinition,
|
||||
} from "@modelcontextprotocol/inspector-test-server";
|
||||
|
||||
function deferred<T>(): { promise: Promise<T>; resolve: (value: T) => void } {
|
||||
let resolve!: (value: T) => void;
|
||||
const promise = new Promise<T>((r) => {
|
||||
resolve = r;
|
||||
});
|
||||
return { promise, resolve };
|
||||
}
|
||||
|
||||
/**
|
||||
* A tool that never returns on its own. It reports when it started, and whether
|
||||
* the server-side request signal — which the SDK aborts when the client closes
|
||||
* this request's response stream — ever fired.
|
||||
*/
|
||||
function createSlowTool(): {
|
||||
tool: ToolDefinition;
|
||||
started: Promise<void>;
|
||||
aborted: Promise<boolean>;
|
||||
} {
|
||||
const started = deferred<void>();
|
||||
const aborted = deferred<boolean>();
|
||||
const tool: ToolDefinition = {
|
||||
name: "slow_task",
|
||||
description: "Runs until the client cancels it",
|
||||
handler: async (_params, _context, extra) => {
|
||||
extra?.signal?.addEventListener("abort", () => aborted.resolve(true), {
|
||||
once: true,
|
||||
});
|
||||
started.resolve();
|
||||
// Never settles: this call ends by cancellation or not at all, so a
|
||||
// regression shows up as the test's own timeout rather than as a pass.
|
||||
return new Promise(() => {});
|
||||
},
|
||||
};
|
||||
return { tool, started: started.promise, aborted: aborted.promise };
|
||||
}
|
||||
|
||||
async function startRemoteBackend(): Promise<{
|
||||
baseUrl: string;
|
||||
server: ServerType;
|
||||
authToken: string;
|
||||
}> {
|
||||
const { app, authToken } = createRemoteApp({
|
||||
initialConfig: { defaultEnvironment: {} },
|
||||
});
|
||||
return new Promise((resolve, reject) => {
|
||||
const server = serve(
|
||||
{ fetch: app.fetch, port: 0, hostname: "127.0.0.1" },
|
||||
(info) => {
|
||||
const port =
|
||||
info && typeof info === "object" && "port" in info
|
||||
? (info as { port: number }).port
|
||||
: 0;
|
||||
resolve({ baseUrl: `http://127.0.0.1:${port}`, server, authToken });
|
||||
},
|
||||
);
|
||||
server.on("error", reject);
|
||||
});
|
||||
}
|
||||
|
||||
describe("web-client cancellation over a per-request stream (#2140)", () => {
|
||||
let client: InspectorClient | null = null;
|
||||
let backend: ServerType | null = null;
|
||||
let upstream: TestServerHttp | null = null;
|
||||
|
||||
afterEach(async () => {
|
||||
if (client) {
|
||||
try {
|
||||
await client.disconnect();
|
||||
} catch {
|
||||
// Ignore disconnect errors.
|
||||
}
|
||||
client = null;
|
||||
}
|
||||
if (backend) {
|
||||
const server = backend;
|
||||
backend = null;
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
server.close((err) => (err ? reject(err) : resolve()));
|
||||
});
|
||||
}
|
||||
if (upstream) {
|
||||
try {
|
||||
await upstream.stop();
|
||||
} catch {
|
||||
// Ignore stop errors.
|
||||
}
|
||||
upstream = null;
|
||||
}
|
||||
});
|
||||
|
||||
it("aborts the upstream request stream, which the server observes as cancellation", async () => {
|
||||
const slow = createSlowTool();
|
||||
upstream = createTestServerHttp({
|
||||
serverInfo: createTestServerInfo("cancel-test", "1.0.0"),
|
||||
tools: [slow.tool],
|
||||
modern: {},
|
||||
});
|
||||
await upstream.start();
|
||||
|
||||
const { baseUrl, server, authToken } = await startRemoteBackend();
|
||||
backend = server;
|
||||
|
||||
const connected = new InspectorClient(
|
||||
{ type: "streamable-http", url: upstream.url },
|
||||
{
|
||||
environment: {
|
||||
transport: createRemoteTransport({ baseUrl, authToken }),
|
||||
},
|
||||
versionNegotiation: eraToVersionNegotiation("modern"),
|
||||
},
|
||||
);
|
||||
await connected.connect();
|
||||
client = connected;
|
||||
expect(connected.getProtocolEra()).toBe("modern");
|
||||
|
||||
const { tools } = await connected.listTools();
|
||||
const tool = tools.find((t) => t.name === "slow_task");
|
||||
expect(tool).toBeDefined();
|
||||
|
||||
// Hold the rejection from the first turn so the runner never sees it as
|
||||
// unhandled while we wait on the server side.
|
||||
const settled = connected.callTool(tool!, {}).catch((err: unknown) => err);
|
||||
|
||||
await slow.started;
|
||||
expect(connected.cancelToolCall()).toBe(true);
|
||||
|
||||
// The user-facing contract is unchanged: a deliberate cancel still surfaces
|
||||
// as ToolCallCancelledError, whichever wire signal carried it.
|
||||
await expect(settled).resolves.toBeInstanceOf(ToolCallCancelledError);
|
||||
|
||||
// The assertion that matters. Before the fix the server saw only a
|
||||
// `notifications/cancelled` it was free to ignore (and did), its request
|
||||
// signal never fired, and this promise never resolved.
|
||||
await expect(slow.aborted).resolves.toBe(true);
|
||||
}, 30_000);
|
||||
|
||||
it("advertises the per-request stream only for streamable-http", () => {
|
||||
// The flag is what the SDK forks on, and the factory is where a server's
|
||||
// configured type reaches the transport. stdio and SSE multiplex every
|
||||
// request over one shared channel: there is no per-request stream to close,
|
||||
// and aborting anything would take the whole session down — so they must
|
||||
// keep the `notifications/cancelled` mechanism.
|
||||
const create = createRemoteTransport({ baseUrl: "http://unused.example" });
|
||||
expect(
|
||||
create({ type: "stdio", command: "echo", args: [] }, {}).transport
|
||||
.hasPerRequestStream,
|
||||
).toBe(false);
|
||||
expect(
|
||||
create({ type: "sse", url: "http://unused.example/sse" }, {}).transport
|
||||
.hasPerRequestStream,
|
||||
).toBe(false);
|
||||
expect(
|
||||
create({ type: "streamable-http", url: "http://unused.example/mcp" }, {})
|
||||
.transport.hasPerRequestStream,
|
||||
).toBe(true);
|
||||
});
|
||||
});
|
||||
|
|
@ -649,9 +649,12 @@ export class InspectorClient extends InspectorClientEventTarget {
|
|||
>();
|
||||
private rawWireRequestCounter = 0;
|
||||
// Abort controller for the in-flight ordinary (non-task) tool call. Aborting
|
||||
// it makes the SDK send a `notifications/cancelled` for that request (the MCP
|
||||
// cancellation flow) and reject the pending call, which `callTool` surfaces as
|
||||
// a `ToolCallCancelledError`. Undefined when no ordinary call is in flight.
|
||||
// it hands the SDK the MCP cancellation flow for that request and rejects the
|
||||
// pending call, which `callTool` surfaces as a `ToolCallCancelledError`. Which
|
||||
// signal reaches the server is the transport's business, not ours: a
|
||||
// per-request-stream transport on a 2026-era connection has that request's
|
||||
// stream aborted, and everything else gets `notifications/cancelled` (#2140).
|
||||
// Undefined when no ordinary call is in flight.
|
||||
// Task-augmented calls have a server-side task and are cancelled via
|
||||
// `cancelRequestorTask` instead, so they don't use this (#1458).
|
||||
private activeToolCallAbortController?: AbortController;
|
||||
|
|
@ -1171,8 +1174,10 @@ export class InspectorClient extends InspectorClientEventTarget {
|
|||
if (this.requestTimeout !== undefined) {
|
||||
opts.timeout = this.requestTimeout;
|
||||
}
|
||||
// When provided, aborting this signal makes the SDK send a
|
||||
// `notifications/cancelled` for the request and reject it (#1458).
|
||||
// When provided, aborting this signal cancels the request and rejects it
|
||||
// (#1458). The SDK picks the wire signal from the transport: an aborted
|
||||
// per-request SSE stream on a 2026-era Streamable HTTP connection, and
|
||||
// `notifications/cancelled` everywhere else (#2140).
|
||||
if (signal) {
|
||||
opts.signal = signal;
|
||||
}
|
||||
|
|
@ -2978,10 +2983,13 @@ export class InspectorClient extends InspectorClientEventTarget {
|
|||
|
||||
/**
|
||||
* Cancel the in-flight ordinary (non-task) tool call started by
|
||||
* {@link callTool}. Aborting its request makes the SDK send a
|
||||
* `notifications/cancelled` to the server (the MCP cancellation flow) and
|
||||
* reject the pending call, which `callTool` surfaces as a
|
||||
* {@link ToolCallCancelledError}.
|
||||
* {@link callTool}. Aborting its request runs the MCP cancellation flow and
|
||||
* rejects the pending call, which `callTool` surfaces as a
|
||||
* {@link ToolCallCancelledError}. The SDK chooses the wire signal from the
|
||||
* transport: on a 2026-era Streamable HTTP connection it aborts that
|
||||
* request's own SSE response stream — the spec's cancellation signal —
|
||||
* rather than sending `notifications/cancelled`, which is the stdio
|
||||
* mechanism and remains in use there (#2140).
|
||||
*
|
||||
* Task-augmented calls have a server-side task and are cancelled via
|
||||
* {@link cancelRequestorTask} instead — this is a no-op for them (and whenever
|
||||
|
|
|
|||
|
|
@ -203,6 +203,30 @@ export class MessageTrackingTransport implements Transport {
|
|||
return this.baseTransport.sessionId;
|
||||
}
|
||||
|
||||
/**
|
||||
* Forward the base transport's per-request-stream capability (#2140).
|
||||
*
|
||||
* The SDK's `Protocol.request` reads this off the transport it was handed —
|
||||
* which is always this wrapper — to decide how a cancelled request is
|
||||
* signalled: abort that request's own stream (the 2026-07-28 signal for
|
||||
* Streamable HTTP) or POST a `notifications/cancelled` (the stdio one).
|
||||
*
|
||||
* Not forwarding it made the wrapper *answer* the question, `undefined`, for
|
||||
* every client. So the Cancel button POSTed a notification even on a modern
|
||||
* Streamable HTTP connection, where a spec-compliant server acknowledges it
|
||||
* `202` and drops it — the tool kept running and only a disconnect really
|
||||
* cancelled anything. That hit the CLI and TUI as much as the web client:
|
||||
* their real `StreamableHTTPClientTransport` advertises the flag correctly
|
||||
* and this wrapper hid it.
|
||||
*
|
||||
* A base transport that says nothing stays `undefined` rather than becoming
|
||||
* `false`, since the SDK's check is `=== true` either way and inventing a
|
||||
* value here would misreport what the transport claimed.
|
||||
*/
|
||||
get hasPerRequestStream(): boolean | undefined {
|
||||
return this.baseTransport.hasPerRequestStream;
|
||||
}
|
||||
|
||||
// Implemented as a concrete method (rather than delegating the base
|
||||
// transport's optional `setProtocolVersion`) so the SDK Client always
|
||||
// invokes it after the initialize handshake — including for stdio, whose
|
||||
|
|
|
|||
|
|
@ -1022,6 +1022,36 @@ export function createRemoteApp(
|
|||
// Auth errors may reject the wait via onerror while send() also throws.
|
||||
void responseWait.catch(() => {});
|
||||
}
|
||||
|
||||
// #2140: this route is held open for the whole call, so the browser
|
||||
// aborting its `POST /api/mcp/send` mid-flight *is* the client's
|
||||
// cancellation signal on a per-request-stream upstream. Forward it as
|
||||
// `requestSignal` so the upstream StreamableHTTP transport closes that
|
||||
// request's SSE response stream, which is what the 2026-07-28 spec makes
|
||||
// the cancellation signal. A transport with no per-request stream (stdio,
|
||||
// SSE) ignores the option, and a *cancel* never reaches it as an abort
|
||||
// anyway: `RemoteClientTransport` withholds `hasPerRequestStream` there, so
|
||||
// the SDK keeps sending `notifications/cancelled`. Forwarding the
|
||||
// disconnect unconditionally is still right — a browser that went away
|
||||
// mid-call is not waiting for the answer whatever the transport is.
|
||||
const upstreamAbort = new AbortController();
|
||||
const clientSignal = c.req.raw.signal;
|
||||
const onClientDisconnect = () => {
|
||||
upstreamAbort.abort(clientSignal.reason);
|
||||
// The response will never arrive now; release the wait so this handler
|
||||
// unwinds instead of sitting on a promise nothing can settle.
|
||||
if (requestId !== undefined) {
|
||||
session.cancelRequestWait(requestId);
|
||||
}
|
||||
};
|
||||
if (clientSignal.aborted) {
|
||||
onClientDisconnect();
|
||||
} else {
|
||||
clientSignal.addEventListener("abort", onClientDisconnect, {
|
||||
once: true,
|
||||
});
|
||||
}
|
||||
|
||||
try {
|
||||
// SEP-2243: apply the client's mirrored headers to the upstream request,
|
||||
// restricted to the `Mcp-Param-` prefix so a client can't inject other
|
||||
|
|
@ -1031,6 +1061,7 @@ export function createRemoteApp(
|
|||
await session.transport.send(message, {
|
||||
relatedRequestId: relatedRequestId as string | number | undefined,
|
||||
...(paramHeaders && { headers: paramHeaders }),
|
||||
requestSignal: upstreamAbort.signal,
|
||||
});
|
||||
if (responseWait) {
|
||||
await responseWait;
|
||||
|
|
@ -1051,6 +1082,7 @@ export function createRemoteApp(
|
|||
const msg = err instanceof Error ? err.message : String(err);
|
||||
return c.json({ ok: false, kind: "transport_error", error: msg });
|
||||
} finally {
|
||||
clientSignal.removeEventListener("abort", onClientDisconnect);
|
||||
session.endSend();
|
||||
}
|
||||
});
|
||||
|
|
|
|||
|
|
@ -261,6 +261,25 @@ export class RemoteClientTransport implements Transport {
|
|||
private readonly options: RemoteTransportOptions;
|
||||
private readonly config: import("../types.js").MCPServerConfig;
|
||||
|
||||
/**
|
||||
* Whether the upstream connection gives each request its own response stream
|
||||
* (#2140). The SDK's `Protocol.request` reads this: on a 2026-era connection
|
||||
* a transport advertising it has its per-request stream **aborted** as the
|
||||
* spec's cancellation signal, and no `notifications/cancelled` is sent; a
|
||||
* transport that does not gets the stdio mechanism instead.
|
||||
*
|
||||
* The real upstream transport lives on the backend, so this transport has to
|
||||
* answer for it. Streamable HTTP opens one POST — and one SSE response
|
||||
* stream — per request, so it qualifies; stdio and SSE multiplex every
|
||||
* request over one shared channel and must keep sending the notification.
|
||||
*
|
||||
* Advertising it is only half the fix: `requestSend` applies the SDK's
|
||||
* `requestSignal` to the browser-to-backend fetch, and `/api/mcp/send`
|
||||
* forwards that disconnect to the upstream `transport.send`, which is what
|
||||
* actually closes the stream the server is watching.
|
||||
*/
|
||||
readonly hasPerRequestStream: boolean;
|
||||
|
||||
/**
|
||||
* Intentionally returns undefined. The MCP Client checks transport.sessionId to detect
|
||||
* reconnects and skip initialize. Our _sessionId is the remote server's session ID, not
|
||||
|
|
@ -312,6 +331,7 @@ export class RemoteClientTransport implements Transport {
|
|||
) {
|
||||
this.options = options;
|
||||
this.config = config;
|
||||
this.hasPerRequestStream = config.type === "streamable-http";
|
||||
}
|
||||
|
||||
setAuthRecovery(handlers: AuthRecoveryHandlers | undefined): void {
|
||||
|
|
@ -757,10 +777,17 @@ export class RemoteClientTransport implements Transport {
|
|||
}),
|
||||
};
|
||||
|
||||
// #2140: aborting this fetch is the cancellation signal for a
|
||||
// per-request-stream upstream. The backend holds `/api/mcp/send` open for
|
||||
// the whole call (it awaits the JSON-RPC response), so the disconnect
|
||||
// reaches it mid-flight and it aborts the upstream request's SSE stream.
|
||||
// For stdio/SSE `hasPerRequestStream` is false, so the SDK never supplies
|
||||
// a `requestSignal` and this is undefined.
|
||||
const res = await this.fetchFn(`${this.baseUrl}/api/mcp/send`, {
|
||||
method: "POST",
|
||||
headers: this.headers,
|
||||
body: JSON.stringify(body),
|
||||
...(options?.requestSignal && { signal: options.requestSignal }),
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
|
|
|
|||
|
|
@ -86,20 +86,71 @@ test("passes when the child paints the marker and keeps running", async () => {
|
|||
// visibly contains the marker. A slow-but-fine TUI would fail, intermittently,
|
||||
// with a diagnostic pointing at the wrong thing entirely.
|
||||
test("a paint landing just under the deadline is not failed by the render timer", async () => {
|
||||
const paintAt = 250;
|
||||
// The two clocks start at different moments: the child's `setTimeout` runs
|
||||
// from its own first JS tick, while `renderTimer` is armed in the parent at
|
||||
// spawn. A delay written as a plain `setTimeout(paint, N)` therefore lands at
|
||||
// `N + startup`, so the deadline has to carry a startup allowance — and the
|
||||
// case fails, as "did not render", whenever the machine is loaded enough to
|
||||
// exceed it. It failed three `local:gate` runs in a row that way while
|
||||
// passing every time standalone (#2177), and widening the allowance only
|
||||
// moved the threshold (#2180).
|
||||
//
|
||||
// So the paint is anchored to the PARENT's clock instead: the child is told
|
||||
// the wall-clock instant to paint at, and sleeps for whatever is left of it
|
||||
// when it gets there. Startup is then *absorbed* by the sleep rather than
|
||||
// added to it, and the paint lands at ~`PAINT_AT` measured from spawn no
|
||||
// matter how slow the boot was.
|
||||
const spawnedAt = Date.now();
|
||||
const PAINT_AT = 300;
|
||||
const timeoutMs = 600;
|
||||
const surviveMs = 900;
|
||||
|
||||
const r = await runRenderSmoke({
|
||||
...opts,
|
||||
...stub(
|
||||
`setTimeout(() => { console.log(${JSON.stringify(MARKER)}); ` +
|
||||
`setInterval(() => {}, 1000); }, ${paintAt});`,
|
||||
`const at = ${spawnedAt + PAINT_AT};` +
|
||||
`setTimeout(() => { console.log(${JSON.stringify(MARKER)}); ` +
|
||||
`setInterval(() => {}, 1000); }, Math.max(0, at - Date.now()));`,
|
||||
),
|
||||
// Deliberately tight: the render deadline expires while the survival window
|
||||
// is still open, which is the whole shape of the bug.
|
||||
timeoutMs: paintAt + 150,
|
||||
surviveMs: 400,
|
||||
// The shape being reproduced is an overlap, and only an overlap:
|
||||
//
|
||||
// actual paint < timeoutMs < actual paint + surviveMs
|
||||
//
|
||||
// i.e. the render deadline expires while the survival window is still
|
||||
// open. `done` is first-caller-wins, so an armed render timer firing in
|
||||
// that gap settles the run as "did not render <marker>" while quoting an
|
||||
// output tail that visibly contains the marker.
|
||||
//
|
||||
// `timeoutMs < surviveMs` is what makes the right-hand side hold for ANY
|
||||
// paint time, including 0 — so a boot slow enough to swallow the anchor
|
||||
// still reproduces the overlap rather than degrading into a different
|
||||
// case. The only startup assumption left is that boot finishes before the
|
||||
// deadline at all, and 600ms is an order of magnitude over the 46-59ms
|
||||
// measured here idle.
|
||||
//
|
||||
// Do not tighten these to make the test faster, and do not reorder them so
|
||||
// that `surviveMs <= timeoutMs`: the overlap is the point.
|
||||
timeoutMs,
|
||||
surviveMs,
|
||||
});
|
||||
assert.equal(r.code, 0, `expected pass, got: ${r.message}`);
|
||||
assert.doesNotMatch(r.message, /did not render/);
|
||||
|
||||
// Passing is not on its own evidence that the overlap was reproduced — a
|
||||
// paint late enough to land past `timeoutMs` fails, but one landing so early
|
||||
// that the deadline falls outside the survival window would pass while
|
||||
// testing nothing. Read the paint time back out of the verdict and assert
|
||||
// the shape actually held, so the case cannot go vacuous.
|
||||
const paintedAt = Number(/ at (\d+)ms/.exec(r.message)?.[1]);
|
||||
assert.ok(
|
||||
Number.isFinite(paintedAt),
|
||||
`no first-paint time in verdict: ${r.message}`,
|
||||
);
|
||||
assert.ok(
|
||||
paintedAt < timeoutMs && timeoutMs < paintedAt + surviveMs,
|
||||
`deadline did not fall inside the survival window: painted at ${paintedAt}ms, ` +
|
||||
`timeoutMs ${timeoutMs}, surviveMs ${surviveMs}`,
|
||||
);
|
||||
});
|
||||
|
||||
test("fails when the child exits before painting", async () => {
|
||||
|
|
|
|||
|
|
@ -390,7 +390,11 @@ When a tool execution triggers sampling or elicitation requests, they appear inl
|
|||
- Step description if provided
|
||||
- Elapsed time display
|
||||
- Execute button with loading state
|
||||
- **Cancel button** sends `notifications/cancelled`
|
||||
- **Cancel button** sends the transport-appropriate cancellation signal (#2140):
|
||||
on a 2026-07-28 Streamable HTTP connection it aborts the request's own SSE
|
||||
response stream, which the spec makes the cancellation signal; on stdio (and
|
||||
on any pre-2026 connection, where the per-stream mechanism does not exist) it
|
||||
sends `notifications/cancelled`
|
||||
- **Inline Client Request Queue** - When tool triggers sampling/elicitation:
|
||||
- Pending requests shown inline (not as separate modal)
|
||||
- Queue counter shows total pending requests
|
||||
|
|
|
|||
|
|
@ -110,7 +110,11 @@ When a tool execution triggers sampling or elicitation requests, they appear inl
|
|||
- Step description if provided
|
||||
- Elapsed time display
|
||||
- Execute button with loading state
|
||||
- **Cancel button** sends `notifications/cancelled`
|
||||
- **Cancel button** sends the transport-appropriate cancellation signal (#2140):
|
||||
on a 2026-07-28 Streamable HTTP connection it aborts the request's own SSE
|
||||
response stream, which the spec makes the cancellation signal; on stdio (and
|
||||
on any pre-2026 connection, where the per-stream mechanism does not exist) it
|
||||
sends `notifications/cancelled`
|
||||
- **Inline Client Request Queue** - When tool triggers sampling/elicitation:
|
||||
- Pending requests shown inline (not as separate modal)
|
||||
- Queue counter shows total pending requests
|
||||
|
|
|
|||
12
test-servers/configs/cancellation-modern-http.json
Normal file
12
test-servers/configs/cancellation-modern-http.json
Normal file
|
|
@ -0,0 +1,12 @@
|
|||
{
|
||||
"serverInfo": {
|
||||
"name": "cancellation-showcase",
|
||||
"version": "1.0.0"
|
||||
},
|
||||
"tools": [{ "preset": "slow_task" }, { "preset": "echo" }],
|
||||
"transport": {
|
||||
"type": "streamable-http",
|
||||
"port": 6606,
|
||||
"modern": true
|
||||
}
|
||||
}
|
||||
|
|
@ -33,6 +33,7 @@ import {
|
|||
createCollectUrlElicitationTool,
|
||||
createUrlElicitationFormTool,
|
||||
createSendNotificationTool,
|
||||
createSlowTaskTool,
|
||||
createGetAnnotatedMessageTool,
|
||||
createGetTempTool,
|
||||
createGetTempExtraTool,
|
||||
|
|
@ -165,6 +166,8 @@ function resolveToolPreset(
|
|||
return createUrlElicitationFormTool();
|
||||
case "send_notification":
|
||||
return createSendNotificationTool();
|
||||
case "slow_task":
|
||||
return createSlowTaskTool();
|
||||
case "get_annotated_message":
|
||||
return createGetAnnotatedMessageTool();
|
||||
case "get_temp":
|
||||
|
|
|
|||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Reference in a new issue