Merge branch 'v2/main' into v2/feat/2144-oauth-revocation

This commit is contained in:
Cliff Hall 2026-08-28 08:28:57 -04:00 • committed by GitHub
commit 5ba2fadd3b
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
22 changed files with 3326 additions and 1516 deletions

View file

@ -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

View file

@ -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. */

View file

@ -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

View file

@ -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 },
},
};

View file

@ -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).

View 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;
}

View file

@ -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);
});
});

View file

@ -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();
});
});
});

View file

@ -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");

View file

@ -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);
});
});

View file

@ -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

View file

@ -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

View file

@ -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();
}
});

View file

@ -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) {

View file

@ -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 () => {

View file

@ -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

View file

@ -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

View 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
}
}

View file

@ -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