Merge branch 'v2/main' into v2/fix/2172-oidc-discovery-compat
This commit is contained in:
commit
94a6620c90
22 changed files with 3326 additions and 1516 deletions
45
README.md
45
README.md
|
|
@ -164,6 +164,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-{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) |
|
| `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) |
|
| `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
|
#### MCP Apps
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -450,122 +450,67 @@ vi.mock("./lib/publishAppDocument", () => ({
|
||||||
// that invoke the App's connect / call-tool / get-prompt / read-resource /
|
// that invoke the App's connect / call-tool / get-prompt / read-resource /
|
||||||
// set-log-level handlers.
|
// set-log-level handlers.
|
||||||
vi.mock("./components/views/InspectorView/InspectorView", () => ({
|
vi.mock("./components/views/InspectorView/InspectorView", () => ({
|
||||||
InspectorView: (props: {
|
InspectorView: (props: InspectorViewProps) => (
|
||||||
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;
|
|
||||||
};
|
|
||||||
}) => (
|
|
||||||
<div>
|
<div>
|
||||||
<span data-testid="tool-status">
|
<span data-testid="tool-status">
|
||||||
{props.toolCallState?.status ?? "none"}
|
{props.tools.toolCallState?.status ?? "none"}
|
||||||
</span>
|
</span>
|
||||||
<span data-testid="task-progress-keys">
|
<span data-testid="task-progress-keys">
|
||||||
{Object.keys(props.progressByTaskId ?? {}).join(",") || "none"}
|
{Object.keys(props.tasks.progressByTaskId ?? {}).join(",") || "none"}
|
||||||
</span>
|
</span>
|
||||||
<span data-testid="selected-tool">
|
<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>
|
||||||
<span data-testid="tool-search">{props.toolsUi?.search || "none"}</span>
|
|
||||||
<span data-testid="selected-prompt">
|
<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>
|
||||||
<span data-testid="log-filter">{props.logsUi?.filterText || "none"}</span>
|
|
||||||
<span data-testid="prompt-status">
|
<span data-testid="prompt-status">
|
||||||
{props.getPromptState?.status ?? "none"}
|
{props.prompts.getPromptState?.status ?? "none"}
|
||||||
</span>
|
</span>
|
||||||
<span data-testid="resource-status">
|
<span data-testid="resource-status">
|
||||||
{props.readResourceState?.status ?? "none"}
|
{props.resources.readResourceState?.status ?? "none"}
|
||||||
</span>
|
</span>
|
||||||
<span data-testid="log-level">{props.currentLogLevel}</span>
|
<span data-testid="log-level">{props.logs.currentLogLevel}</span>
|
||||||
<span data-testid="active-tab">{props.activeTab ?? "none"}</span>
|
<span data-testid="active-tab">{props.shell.activeTab ?? "none"}</span>
|
||||||
<span data-testid="init-result">
|
<span data-testid="init-result">
|
||||||
{props.initializeResult
|
{props.connection.initializeResult
|
||||||
? `name:${props.initializeResult.serverInfo.name || "(empty)"}`
|
? `name:${props.connection.initializeResult.serverInfo.name || "(empty)"}`
|
||||||
: "none"}
|
: "none"}
|
||||||
</span>
|
</span>
|
||||||
<span data-testid="errored-server">
|
<span data-testid="errored-server">
|
||||||
{props.erroredServerId ?? "none"}
|
{props.connection.erroredServerId ?? "none"}
|
||||||
</span>
|
</span>
|
||||||
<span data-testid="active-server">{props.activeServer ?? "none"}</span>
|
<span data-testid="active-server">
|
||||||
<button onClick={() => props.onActiveTabChange("Servers")}>
|
{props.connection.activeServer ?? "none"}
|
||||||
|
</span>
|
||||||
|
<button onClick={() => props.shell.onActiveTabChange("Servers")}>
|
||||||
switch-servers-tab
|
switch-servers-tab
|
||||||
</button>
|
</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). */}
|
{/* 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.connection.onToggleConnection("B")}>
|
||||||
<button onClick={() => props.onConnectionInfo()}>
|
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
|
open-connection-info
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
onClick={() =>
|
onClick={() =>
|
||||||
props.onToolsUiChange({
|
props.tools.onToolsUiChange({
|
||||||
formValues: {},
|
...props.tools.toolsUi,
|
||||||
search: "",
|
|
||||||
...props.toolsUi,
|
|
||||||
selectedToolKey: "0:get_acts",
|
selectedToolKey: "0:get_acts",
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
@ -574,10 +519,8 @@ vi.mock("./components/views/InspectorView/InspectorView", () => ({
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
onClick={() =>
|
onClick={() =>
|
||||||
props.onToolsUiChange({
|
props.tools.onToolsUiChange({
|
||||||
formValues: {},
|
...props.tools.toolsUi,
|
||||||
search: "",
|
|
||||||
...props.toolsUi,
|
|
||||||
selectedToolKey: "1:other_tool",
|
selectedToolKey: "1:other_tool",
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
@ -586,9 +529,8 @@ vi.mock("./components/views/InspectorView/InspectorView", () => ({
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
onClick={() =>
|
onClick={() =>
|
||||||
props.onToolsUiChange({
|
props.tools.onToolsUiChange({
|
||||||
formValues: {},
|
...props.tools.toolsUi,
|
||||||
...props.toolsUi,
|
|
||||||
search: "act",
|
search: "act",
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
@ -597,10 +539,8 @@ vi.mock("./components/views/InspectorView/InspectorView", () => ({
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
onClick={() =>
|
onClick={() =>
|
||||||
props.onPromptsUiChange({
|
props.prompts.onPromptsUiChange({
|
||||||
argumentValues: {},
|
...props.prompts.promptsUi,
|
||||||
search: "",
|
|
||||||
...props.promptsUi,
|
|
||||||
selectedPromptName: "greet",
|
selectedPromptName: "greet",
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
@ -609,70 +549,99 @@ vi.mock("./components/views/InspectorView/InspectorView", () => ({
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
onClick={() =>
|
onClick={() =>
|
||||||
props.onLogsUiChange({
|
props.logs.onLogsUiChange({
|
||||||
visibleLevels: {},
|
...props.logs.logsUi,
|
||||||
...props.logsUi,
|
|
||||||
filterText: "err",
|
filterText: "err",
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
set-log-filter
|
set-log-filter
|
||||||
</button>
|
</button>
|
||||||
<button onClick={() => props.onCallTool("get_acts", {})}>call</button>
|
<button onClick={() => props.tools.onCallTool("get_acts", {})}>
|
||||||
<button onClick={() => props.onCallTool("get_acts", {}, true)}>
|
call
|
||||||
|
</button>
|
||||||
|
<button onClick={() => props.tools.onCallTool("get_acts", {}, true)}>
|
||||||
call-as-task
|
call-as-task
|
||||||
</button>
|
</button>
|
||||||
<button onClick={() => props.onCancelTask("task-1")}>cancel-task</button>
|
<button onClick={() => props.tasks.onCancelTask("task-1")}>
|
||||||
<button onClick={() => props.onCancelToolCall()}>cancel-tool-call</button>
|
cancel-task
|
||||||
<button onClick={() => props.onClearCompletedTasks()}>
|
</button>
|
||||||
|
<button onClick={() => props.tools.onCancelToolCall?.()}>
|
||||||
|
cancel-tool-call
|
||||||
|
</button>
|
||||||
|
<button onClick={() => props.tasks.onClearCompletedTasks()}>
|
||||||
clear-completed
|
clear-completed
|
||||||
</button>
|
</button>
|
||||||
<button onClick={() => props.onRefreshTasks()}>refresh-tasks</button>
|
<button onClick={() => props.tasks.onRefreshTasks()}>
|
||||||
<button onClick={() => props.onGetPrompt("greet", {})}>get-prompt</button>
|
refresh-tasks
|
||||||
<button onClick={() => props.onReadResource("res://x")}>
|
</button>
|
||||||
|
<button onClick={() => props.prompts.onGetPrompt("greet", {})}>
|
||||||
|
get-prompt
|
||||||
|
</button>
|
||||||
|
<button onClick={() => props.resources.onReadResource("res://x")}>
|
||||||
read-resource
|
read-resource
|
||||||
</button>
|
</button>
|
||||||
<button onClick={() => props.onSetLogLevel("debug")}>set-level</button>
|
<button onClick={() => props.logs.onSetLogLevel("debug")}>
|
||||||
<button onClick={() => props.onServerSettings("A")}>open-settings</button>
|
set-level
|
||||||
|
</button>
|
||||||
|
<button onClick={() => props.servers.onServerSettings("A")}>
|
||||||
|
open-settings
|
||||||
|
</button>
|
||||||
{/* The real server grid (and its Add / Edit controls) lives inside this
|
{/* The real server grid (and its Add / Edit controls) lives inside this
|
||||||
mocked view, so the config modal is only reachable through these
|
mocked view, so the config modal is only reachable through these
|
||||||
callbacks — and the highlight batch only observable through this prop. */}
|
callbacks — and the highlight batch only observable through this prop. */}
|
||||||
<button onClick={() => props.onServerEdit("A")}>edit-server</button>
|
<button onClick={() => props.servers.onServerEdit("A")}>
|
||||||
<button onClick={() => props.onServerAdd()}>add-server</button>
|
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">
|
<span data-testid="highlighted-servers">
|
||||||
{(props.highlightedServerIds ?? []).join(",") || "none"}
|
{(props.servers.highlightedServerIds ?? []).join(",") || "none"}
|
||||||
</span>
|
</span>
|
||||||
<span data-testid="pinned-history">
|
<span data-testid="pinned-history">
|
||||||
{Array.from(props.pinnedProtocolIds ?? []).join(",")}
|
{Array.from(props.protocol.pinnedProtocolIds ?? []).join(",")}
|
||||||
</span>
|
</span>
|
||||||
<button onClick={() => props.onTogglePinProtocol("hist-1")}>
|
<button onClick={() => props.protocol.onTogglePinProtocol("hist-1")}>
|
||||||
toggle-pin
|
toggle-pin
|
||||||
</button>
|
</button>
|
||||||
<button onClick={() => props.onReplayProtocol("hist-1")}>
|
<button onClick={() => props.protocol.onReplayProtocol("hist-1")}>
|
||||||
replay-history
|
replay-history
|
||||||
</button>
|
</button>
|
||||||
<button onClick={() => props.onClearProtocol()}>clear-history</button>
|
<button onClick={() => props.protocol.onClearProtocol()}>
|
||||||
|
clear-history
|
||||||
|
</button>
|
||||||
<span data-testid="tools-paginated">
|
<span data-testid="tools-paginated">
|
||||||
{String(props.toolsPagination.paginated)}
|
{String(props.tools.toolsPagination.paginated)}
|
||||||
</span>
|
</span>
|
||||||
<span data-testid="tools-loaded-pages">
|
<span data-testid="tools-loaded-pages">
|
||||||
{props.toolsPagination.loadedPages}
|
{props.tools.toolsPagination.loadedPages}
|
||||||
</span>
|
</span>
|
||||||
<button onClick={() => props.toolsPagination.onPaginatedChange(true)}>
|
<button
|
||||||
|
onClick={() => props.tools.toolsPagination.onPaginatedChange(true)}
|
||||||
|
>
|
||||||
paginated-on
|
paginated-on
|
||||||
</button>
|
</button>
|
||||||
<button onClick={() => props.toolsPagination.onPaginatedChange(false)}>
|
<button
|
||||||
|
onClick={() => props.tools.toolsPagination.onPaginatedChange(false)}
|
||||||
|
>
|
||||||
paginated-off
|
paginated-off
|
||||||
</button>
|
</button>
|
||||||
<button onClick={() => props.toolsPagination.onLoadMore()}>
|
<button onClick={() => props.tools.toolsPagination.onLoadMore()}>
|
||||||
load-more-tools
|
load-more-tools
|
||||||
</button>
|
</button>
|
||||||
<button onClick={() => props.onRefreshTools()}>refresh-tools</button>
|
<button onClick={() => props.tools.onRefreshTools()}>
|
||||||
|
refresh-tools
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
),
|
),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
import App from "./App";
|
import App from "./App";
|
||||||
|
import type { InspectorViewProps } from "./components/views/InspectorView/InspectorView";
|
||||||
import { SERVER_INFO_NOT_REPORTED_LABEL } from "./components/groups/ConnectionInfoContent/ConnectionInfoContent";
|
import { SERVER_INFO_NOT_REPORTED_LABEL } from "./components/groups/ConnectionInfoContent/ConnectionInfoContent";
|
||||||
import { OAUTH_CALLBACK_PATH } from "./utils/oauthFlow.js";
|
import { OAUTH_CALLBACK_PATH } from "./utils/oauthFlow.js";
|
||||||
import { INSPECTOR_SERVERS_TAB } from "./utils/inspectorTabs.js";
|
import { INSPECTOR_SERVERS_TAB } from "./utils/inspectorTabs.js";
|
||||||
|
|
@ -3842,6 +3811,30 @@ describe("App background command rejections (#2049)", () => {
|
||||||
return user;
|
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 () => {
|
it("does not leak an unhandled rejection when an all-pages Refresh fails", async () => {
|
||||||
vi.mocked(useManagedTools).mockReturnValue({
|
vi.mocked(useManagedTools).mockReturnValue({
|
||||||
tools: [],
|
tools: [],
|
||||||
|
|
@ -3919,6 +3912,156 @@ describe("App background command rejections (#2049)", () => {
|
||||||
rejections.stop();
|
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. */
|
/** A complete `useManagedResources` return, so the mock stays type-checked against the hook's contract. */
|
||||||
|
|
|
||||||
|
|
@ -64,6 +64,20 @@ import { useInitialConfig } from "@inspector/core/react/useInitialConfig.js";
|
||||||
import { refreshingPersist } from "./lib/refreshingPersist";
|
import { refreshingPersist } from "./lib/refreshingPersist";
|
||||||
import { usePendingClientRequests } from "@inspector/core/react/usePendingClientRequests.js";
|
import { usePendingClientRequests } from "@inspector/core/react/usePendingClientRequests.js";
|
||||||
import { InspectorView } from "./components/views/InspectorView/InspectorView";
|
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 type { ReadResourceState } from "./components/screens/ResourcesScreen/ResourcesScreen";
|
||||||
import { AppElicitationHost } from "./components/elements/AppElicitation/AppElicitationHost";
|
import { AppElicitationHost } from "./components/elements/AppElicitation/AppElicitationHost";
|
||||||
import type { LogEntryData } from "./components/elements/LogEntry/LogEntry";
|
import type { LogEntryData } from "./components/elements/LogEntry/LogEntry";
|
||||||
|
|
@ -108,6 +122,21 @@ import { FetchBodyDroppedToastMessage } from "./components/elements/Toasts/Fetch
|
||||||
import { OutputValidationToastMessage } from "./components/elements/Toasts/OutputValidationToastMessage";
|
import { OutputValidationToastMessage } from "./components/elements/Toasts/OutputValidationToastMessage";
|
||||||
import { ReAuthBannerBar } from "./components/groups/ReAuthBanner/ReAuthBannerBar";
|
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() {
|
function App() {
|
||||||
const { onToggleTheme } = useThemeToggle();
|
const { onToggleTheme } = useThemeToggle();
|
||||||
|
|
||||||
|
|
@ -1469,6 +1498,304 @@ function App() {
|
||||||
[pendingElicitations, inspectorClient],
|
[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 (
|
return (
|
||||||
<>
|
<>
|
||||||
<Box>
|
<Box>
|
||||||
|
|
@ -1484,171 +1811,18 @@ function App() {
|
||||||
</ReAuthBannerBar>
|
</ReAuthBannerBar>
|
||||||
) : null}
|
) : null}
|
||||||
<InspectorView
|
<InspectorView
|
||||||
deepLink={deepLink}
|
shell={shellProps}
|
||||||
deepLinkStatus={deepLinkStatus}
|
connection={connectionProps}
|
||||||
servers={servers}
|
servers={serverListProps}
|
||||||
serverListWritable={serverListWritable}
|
tools={toolsPanelProps}
|
||||||
activeServer={activeServerId}
|
prompts={promptsPanelProps}
|
||||||
erroredServerId={failedServerId}
|
resources={resourcesPanelProps}
|
||||||
connectedServerId={connectedServerId}
|
apps={appsPanelProps}
|
||||||
version={inspectorVersion}
|
tasks={tasksPanelProps}
|
||||||
connectionStatus={connectionStatus}
|
logs={logsPanelProps}
|
||||||
connectErrorMessage={connectErrorMessage}
|
protocol={protocolPanelProps}
|
||||||
initializeResult={initializeResult}
|
network={networkPanelProps}
|
||||||
latencyMs={latencyMs}
|
console={consolePanelProps}
|
||||||
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}
|
|
||||||
/>
|
/>
|
||||||
</Box>
|
</Box>
|
||||||
<AppElicitationHost
|
<AppElicitationHost
|
||||||
|
|
|
||||||
|
|
@ -18,6 +18,20 @@ import type { Meta, StoryObj } from "@storybook/react-vite";
|
||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
import { expect, fn } from "storybook/test";
|
import { expect, fn } from "storybook/test";
|
||||||
import { InspectorView } from "./InspectorView";
|
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 { noopPagination } from "../../../test/fixtures/pagination";
|
||||||
import {
|
import {
|
||||||
EMPTY_TOOLS_UI,
|
EMPTY_TOOLS_UI,
|
||||||
|
|
@ -324,116 +338,164 @@ const demoInitializeResult: InitializeResult = {
|
||||||
serverInfo: { name: "Local Dev Server", version: "1.2.0" },
|
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> = {
|
const meta: Meta<typeof InspectorView> = {
|
||||||
title: "Views/InspectorView",
|
title: "Views/InspectorView",
|
||||||
component: InspectorView,
|
component: InspectorView,
|
||||||
parameters: { layout: "fullscreen" },
|
parameters: { layout: "fullscreen" },
|
||||||
args: {
|
args: {
|
||||||
// Data
|
shell: shellArgs,
|
||||||
servers: demoServers,
|
connection: connectionArgs,
|
||||||
tools: demoTools,
|
servers: serversArgs,
|
||||||
prompts: demoPrompts,
|
tools: toolsArgs,
|
||||||
resources: demoResources,
|
prompts: promptsArgs,
|
||||||
resourceTemplates: demoResourceTemplates,
|
resources: resourcesArgs,
|
||||||
subscriptions: demoSubscriptions,
|
apps: appsArgs,
|
||||||
logs: demoLogs,
|
tasks: tasksArgs,
|
||||||
tasks: demoTasks,
|
logs: logsArgs,
|
||||||
progressByTaskId: demoProgressByTaskId,
|
protocol: protocolArgs,
|
||||||
protocol: demoHistory,
|
network: networkArgs,
|
||||||
network: demoNetwork,
|
console: consoleArgs,
|
||||||
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(),
|
|
||||||
},
|
},
|
||||||
render: (args) => {
|
render: (args) => {
|
||||||
const [activeTab, setActiveTab] = useState(args.activeTab ?? "Servers");
|
const [activeTab, setActiveTab] = useState(args.shell.activeTab);
|
||||||
return (
|
return (
|
||||||
<InspectorView
|
<InspectorView
|
||||||
{...args}
|
{...args}
|
||||||
activeTab={activeTab}
|
shell={{ ...args.shell, activeTab, onActiveTabChange: setActiveTab }}
|
||||||
onActiveTabChange={setActiveTab}
|
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
|
|
@ -463,7 +525,7 @@ export const Default: Story = {
|
||||||
|
|
||||||
export const NoServers: Story = {
|
export const NoServers: Story = {
|
||||||
args: {
|
args: {
|
||||||
servers: [],
|
servers: { ...serversArgs, servers: [] },
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
@ -480,7 +542,7 @@ const manyServers: ServerEntry[] = Array.from({ length: 24 }, (_, i) => ({
|
||||||
|
|
||||||
export const ManyServers: Story = {
|
export const ManyServers: Story = {
|
||||||
args: {
|
args: {
|
||||||
servers: manyServers,
|
servers: { ...serversArgs, servers: manyServers },
|
||||||
},
|
},
|
||||||
play: async ({ canvasElement }) => {
|
play: async ({ canvasElement }) => {
|
||||||
// Even under enough content to overflow, the shell stays viewport-clamped
|
// Even under enough content to overflow, the shell stays viewport-clamped
|
||||||
|
|
@ -509,10 +571,13 @@ export const ManyServers: Story = {
|
||||||
// regression / storybook play function coverage.
|
// regression / storybook play function coverage.
|
||||||
export const Connected: Story = {
|
export const Connected: Story = {
|
||||||
args: {
|
args: {
|
||||||
activeServer: demoServers[0]!.id,
|
connection: {
|
||||||
connectionStatus: "connected",
|
...connectionArgs,
|
||||||
initializeResult: demoInitializeResult,
|
activeServer: demoServers[0]!.id,
|
||||||
latencyMs: 142,
|
connectionStatus: "connected",
|
||||||
|
initializeResult: demoInitializeResult,
|
||||||
|
latencyMs: 142,
|
||||||
|
},
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
@ -524,10 +589,13 @@ export const ConnectionError: Story = {
|
||||||
// connect-attempt-failure signal that gates the failure column; `network: []`
|
// connect-attempt-failure signal that gates the failure column; `network: []`
|
||||||
// (and empty Protocol) keep it a pure stdio failure so only Console is offered.
|
// (and empty Protocol) keep it a pure stdio failure so only Console is offered.
|
||||||
args: {
|
args: {
|
||||||
activeServer: demoServers[0]!.id,
|
connection: {
|
||||||
erroredServerId: demoServers[0]!.id,
|
...connectionArgs,
|
||||||
connectionStatus: "error",
|
activeServer: demoServers[0]!.id,
|
||||||
network: [],
|
erroredServerId: demoServers[0]!.id,
|
||||||
stderrLogs: demoStderr,
|
connectionStatus: "error",
|
||||||
|
},
|
||||||
|
network: { ...networkArgs, network: [] },
|
||||||
|
console: { ...consoleArgs, stderrLogs: demoStderr },
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
|
||||||
File diff suppressed because it is too large
Load diff
|
|
@ -1,14 +1,4 @@
|
||||||
import {
|
import { useEffect, useMemo, useRef, useState, type ReactNode } from "react";
|
||||||
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 {
|
import {
|
||||||
AppShell,
|
AppShell,
|
||||||
Flex,
|
Flex,
|
||||||
|
|
@ -18,81 +8,38 @@ import {
|
||||||
type MantineTransition,
|
type MantineTransition,
|
||||||
} from "@mantine/core";
|
} from "@mantine/core";
|
||||||
import { useLocalStorage } from "@mantine/hooks";
|
import { useLocalStorage } from "@mantine/hooks";
|
||||||
import type {
|
import type { Implementation, Tool } from "@modelcontextprotocol/client";
|
||||||
Implementation,
|
import type { ServerEntry } from "@inspector/core/mcp/types.js";
|
||||||
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 { isTerminalStatus } from "@inspector/core/mcp/types.js";
|
import { isTerminalStatus } from "@inspector/core/mcp/types.js";
|
||||||
import { isAppTool } from "@inspector/core/mcp/apps.js";
|
import { isAppTool } from "@inspector/core/mcp/apps.js";
|
||||||
import { TASKS_EXTENSION_KEY } from "@inspector/core/mcp/modernTaskSchemas.js";
|
import { TASKS_EXTENSION_KEY } from "@inspector/core/mcp/modernTaskSchemas.js";
|
||||||
import { ViewHeader } from "../../groups/ViewHeader/ViewHeader";
|
import { ViewHeader } from "../../groups/ViewHeader/ViewHeader";
|
||||||
import { VersionBadge } from "../../elements/VersionBadge/VersionBadge";
|
import { VersionBadge } from "../../elements/VersionBadge/VersionBadge";
|
||||||
import { CopyrightBadge } from "../../elements/CopyrightBadge/CopyrightBadge";
|
import { CopyrightBadge } from "../../elements/CopyrightBadge/CopyrightBadge";
|
||||||
import type { ListPaginationControlsProps } from "../../elements/ListPaginationControls/ListPaginationControls";
|
|
||||||
import { ServerListScreen } from "../../screens/ServerListScreen/ServerListScreen";
|
import { ServerListScreen } from "../../screens/ServerListScreen/ServerListScreen";
|
||||||
import {
|
import { ToolsScreen } from "../../screens/ToolsScreen/ToolsScreen";
|
||||||
ToolsScreen,
|
import { AppsScreen } from "../../screens/AppsScreen/AppsScreen";
|
||||||
type ToolCallState,
|
import { PromptsScreen } from "../../screens/PromptsScreen/PromptsScreen";
|
||||||
type ToolsUiState,
|
import { ResourcesScreen } from "../../screens/ResourcesScreen/ResourcesScreen";
|
||||||
} from "../../screens/ToolsScreen/ToolsScreen";
|
import { LoggingScreen } from "../../screens/LoggingScreen/LoggingScreen";
|
||||||
import {
|
import { TasksScreen } from "../../screens/TasksScreen/TasksScreen";
|
||||||
AppsScreen,
|
import { ProtocolScreen } from "../../screens/ProtocolScreen/ProtocolScreen";
|
||||||
type AppsUiState,
|
import { NetworkScreen } from "../../screens/NetworkScreen/NetworkScreen";
|
||||||
} from "../../screens/AppsScreen/AppsScreen";
|
import { ConsoleScreen } from "../../screens/ConsoleScreen/ConsoleScreen";
|
||||||
import type {
|
import type {
|
||||||
AppRendererHandle,
|
AppsPanelProps,
|
||||||
BridgeFactory,
|
ConnectionProps,
|
||||||
} from "../../elements/AppRenderer/AppRenderer";
|
ConsolePanelProps,
|
||||||
import {
|
LogsPanelProps,
|
||||||
PromptsScreen,
|
NetworkPanelProps,
|
||||||
type GetPromptState,
|
PromptsPanelProps,
|
||||||
type PromptsUiState,
|
ProtocolPanelProps,
|
||||||
} from "../../screens/PromptsScreen/PromptsScreen";
|
ResourcesPanelProps,
|
||||||
import {
|
ServerListProps,
|
||||||
ResourcesScreen,
|
ShellProps,
|
||||||
type ReadResourceState,
|
TasksPanelProps,
|
||||||
type ResourcesUiState,
|
ToolsPanelProps,
|
||||||
} from "../../screens/ResourcesScreen/ResourcesScreen";
|
} from "./types";
|
||||||
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";
|
|
||||||
import type { SortDirection } from "../../elements/SortToggle/SortToggle";
|
import type { SortDirection } from "../../elements/SortToggle/SortToggle";
|
||||||
import { ScreenStage } from "../../elements/ScreenStage/ScreenStage";
|
import { ScreenStage } from "../../elements/ScreenStage/ScreenStage";
|
||||||
import { MonitoringScreen } from "../../groups/MonitoringScreen/MonitoringScreen";
|
import { MonitoringScreen } from "../../groups/MonitoringScreen/MonitoringScreen";
|
||||||
|
|
@ -360,402 +307,193 @@ const MonitorColumnGroup = Group.withProps({
|
||||||
flex: "0 0 auto",
|
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 {
|
export interface InspectorViewProps {
|
||||||
/**
|
/** App chrome: active tab, theme/settings entry points, footer, deep link. */
|
||||||
* Validated deep-link parameters from the page URL. When present and
|
shell: ShellProps;
|
||||||
* `openApp` is set, the parent switches to the Apps tab and pre-selects that
|
/** Live connection state plus the capability facts several screens read. */
|
||||||
* app (with `appArgs` as the form values) once the connection is up and the
|
connection: ConnectionProps;
|
||||||
* app list contains it. The connect itself is driven by the parent.
|
/** The server catalog and every action on it. */
|
||||||
*/
|
servers: ServerListProps;
|
||||||
deepLink?: DeepLink;
|
tools: ToolsPanelProps;
|
||||||
/**
|
prompts: PromptsPanelProps;
|
||||||
* Outcome of parsing the initial-URL deep link, surfaced as `data-deeplink`
|
resources: ResourcesPanelProps;
|
||||||
* on the `connection-status` testid. Distinguishes "no deep link" from
|
apps: AppsPanelProps;
|
||||||
* "rejected" (token mismatch / bad serverUrl) — both leave `data-status`
|
tasks: TasksPanelProps;
|
||||||
* idle, so an automated driver otherwise cannot tell them apart.
|
logs: LogsPanelProps;
|
||||||
*/
|
protocol: ProtocolPanelProps;
|
||||||
deepLinkStatus?: DeepLinkParseStatus;
|
network: NetworkPanelProps;
|
||||||
|
console: ConsolePanelProps;
|
||||||
// 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;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function InspectorView({
|
export function InspectorView({
|
||||||
deepLink,
|
shell,
|
||||||
deepLinkStatus,
|
connection,
|
||||||
servers: serversInput,
|
servers: serverList,
|
||||||
serverListWritable = true,
|
tools: toolsPanel,
|
||||||
activeServer,
|
prompts: promptsPanel,
|
||||||
erroredServerId,
|
resources: resourcesPanel,
|
||||||
connectedServerId,
|
apps: appsPanel,
|
||||||
version,
|
tasks: tasksPanel,
|
||||||
connectionStatus,
|
logs: logsPanel,
|
||||||
connectErrorMessage,
|
protocol: protocolPanel,
|
||||||
initializeResult,
|
network: networkPanel,
|
||||||
latencyMs,
|
console: consolePanel,
|
||||||
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,
|
|
||||||
}: InspectorViewProps) {
|
}: 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
|
// UI-only state. Connection state, primitive lists, and all action
|
||||||
// dispatching live in the parent; this component only owns view-local
|
// dispatching live in the parent; this component only owns view-local
|
||||||
// toggles (sort direction, list compact). Tab selection is lifted (#1417).
|
// toggles (sort direction, list compact). Tab selection is lifted (#1417).
|
||||||
|
|
|
||||||
379
clients/web/src/components/views/InspectorView/types.ts
Normal file
379
clients/web/src/components/views/InspectorView/types.ts
Normal file
|
|
@ -0,0 +1,379 @@
|
||||||
|
// Domain prop bundles for `InspectorView` (#2130).
|
||||||
|
//
|
||||||
|
// The view used to take ~130 flat props, which made App.tsx's JSX a prop wall
|
||||||
|
// rather than a component tree. Each bundle below groups one domain's data,
|
||||||
|
// UI state, and actions into a single object, so the call site reads as the
|
||||||
|
// tree it is and a hook can hand over exactly one bundle.
|
||||||
|
//
|
||||||
|
// Two conventions keep the change reviewable and keep it from rippling
|
||||||
|
// downward:
|
||||||
|
//
|
||||||
|
// - **Field names are unchanged.** Every field carries the exact name it had
|
||||||
|
// as a flat prop, so the bundles are a regrouping, not a rename.
|
||||||
|
// - **The bundles stop here.** `InspectorView` destructures them back into
|
||||||
|
// the same locals and passes them down to the screens as before, so
|
||||||
|
// nothing below the view knows they exist.
|
||||||
|
import type { Ref } from "react";
|
||||||
|
import type {
|
||||||
|
InitializeResult,
|
||||||
|
LoggingLevel,
|
||||||
|
Prompt,
|
||||||
|
ProtocolEra,
|
||||||
|
ReadResourceResult,
|
||||||
|
Resource,
|
||||||
|
ResourceTemplateType as ResourceTemplate,
|
||||||
|
Task,
|
||||||
|
Tool,
|
||||||
|
} from "@modelcontextprotocol/client";
|
||||||
|
import type { MalformedListItem } from "@inspector/core/mcp";
|
||||||
|
import type {
|
||||||
|
ConnectionStatus,
|
||||||
|
ExcludedTool,
|
||||||
|
FetchRequestEntry,
|
||||||
|
InspectorResourceSubscription,
|
||||||
|
MessageEntry,
|
||||||
|
ResourceSubscriptionStreamState,
|
||||||
|
ServerEntry,
|
||||||
|
StderrLogEntry,
|
||||||
|
} from "@inspector/core/mcp/types.js";
|
||||||
|
import type { ReplayParamsOverride } from "../../../lib/protocolReplay";
|
||||||
|
import type { DeepLink, DeepLinkParseStatus } from "../../../utils/deepLink";
|
||||||
|
import type { ListPaginationControlsProps } from "../../elements/ListPaginationControls/ListPaginationControls";
|
||||||
|
import type {
|
||||||
|
AppRendererHandle,
|
||||||
|
BridgeFactory,
|
||||||
|
} from "../../elements/AppRenderer/AppRenderer";
|
||||||
|
import type { LogEntryData } from "../../elements/LogEntry/LogEntry";
|
||||||
|
import type { TaskProgress } from "../../groups/TaskCard/TaskCard";
|
||||||
|
import type {
|
||||||
|
ToolCallState,
|
||||||
|
ToolsUiState,
|
||||||
|
} from "../../screens/ToolsScreen/ToolsScreen";
|
||||||
|
import type { AppsUiState } from "../../screens/AppsScreen/AppsScreen";
|
||||||
|
import type {
|
||||||
|
GetPromptState,
|
||||||
|
PromptsUiState,
|
||||||
|
} from "../../screens/PromptsScreen/PromptsScreen";
|
||||||
|
import type {
|
||||||
|
ReadResourceState,
|
||||||
|
ResourcesUiState,
|
||||||
|
} from "../../screens/ResourcesScreen/ResourcesScreen";
|
||||||
|
import type { LogsUiState } from "../../screens/LoggingScreen/LoggingScreen";
|
||||||
|
import type { TasksUiState } from "../../screens/TasksScreen/TasksScreen";
|
||||||
|
import type { ProtocolUiState } from "../../screens/ProtocolScreen/ProtocolScreen";
|
||||||
|
import type { NetworkUiState } from "../../screens/NetworkScreen/NetworkScreen";
|
||||||
|
import type { ConsoleUiState } from "../../screens/ConsoleScreen/ConsoleScreen";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* App chrome and the few things that belong to no single screen: the active
|
||||||
|
* tab, the theme/client-settings entry points, the footer version, the
|
||||||
|
* deep-link parameters, and the malformed-list-item log that three separate
|
||||||
|
* screens each filter for their own entries.
|
||||||
|
*/
|
||||||
|
export interface ShellProps {
|
||||||
|
/**
|
||||||
|
* Validated deep-link parameters from the page URL. When present and
|
||||||
|
* `openApp` is set, the parent switches to the Apps tab and pre-selects that
|
||||||
|
* app (with `appArgs` as the form values) once the connection is up and the
|
||||||
|
* app list contains it. The connect itself is driven by the parent.
|
||||||
|
*/
|
||||||
|
deepLink?: DeepLink;
|
||||||
|
/**
|
||||||
|
* Outcome of parsing the initial-URL deep link, surfaced as `data-deeplink`
|
||||||
|
* on the `connection-status` testid. Distinguishes "no deep link" from
|
||||||
|
* "rejected" (token mismatch / bad serverUrl) — both leave `data-status`
|
||||||
|
* idle, so an automated driver otherwise cannot tell them apart.
|
||||||
|
*/
|
||||||
|
deepLinkStatus?: DeepLinkParseStatus;
|
||||||
|
/**
|
||||||
|
* The Inspector build version (root `package.json`), shown at the left of the
|
||||||
|
* footer row (#1682). Absent on a legacy backend that omits it — the version
|
||||||
|
* label then renders nothing.
|
||||||
|
*/
|
||||||
|
version?: string;
|
||||||
|
/**
|
||||||
|
* Entries dropped from a list result as malformed, across every list method.
|
||||||
|
* Each list panel filters for its own and warns about them (#1909). It spans
|
||||||
|
* three screens, so it has no single domain owner.
|
||||||
|
*/
|
||||||
|
malformedListItems?: MalformedListItem[];
|
||||||
|
/** Active inspector tab (lifted to App for OAuth resume). */
|
||||||
|
activeTab: string;
|
||||||
|
onActiveTabChange: (tab: string) => void;
|
||||||
|
/**
|
||||||
|
* Theme toggle (lives in the parent so the color scheme can also flow into
|
||||||
|
* other top-level UI later).
|
||||||
|
*/
|
||||||
|
onToggleTheme: () => void;
|
||||||
|
/** Open install-level client settings (client.json / EMA IdP). */
|
||||||
|
onOpenClientSettings: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Live connection state and the capability facts more than one screen reads.
|
||||||
|
* Driven by the parent via `useInspectorClient`.
|
||||||
|
*/
|
||||||
|
export interface ConnectionProps {
|
||||||
|
activeServer?: string;
|
||||||
|
/**
|
||||||
|
* Id of the server whose last connection attempt failed (#1621). Its card in
|
||||||
|
* the Servers screen draws a red border until another server is connected or
|
||||||
|
* a new connection is attempted. Independent of `activeServer`, which the
|
||||||
|
* parent clears on the failure's `disconnect` event.
|
||||||
|
*
|
||||||
|
* It is also the sole signal that opens the monitoring sidebar onto the
|
||||||
|
* failure's diagnostics (#2108). That used to be gated on the `"error"`
|
||||||
|
* connection status, which an OAuth failure never reaches — the parent drives
|
||||||
|
* that leg and tears the client down itself, settling the session at
|
||||||
|
* `"disconnected"`. So the parent must set this for *every* connect-attempt
|
||||||
|
* failure, auth legs included, and clear it as each new attempt starts.
|
||||||
|
*/
|
||||||
|
erroredServerId?: string;
|
||||||
|
/**
|
||||||
|
* Id of the server that just connected successfully (#1682). Its card draws
|
||||||
|
* the green highlight border and scrolls into view once the monitoring
|
||||||
|
* sidebar has opened — the success mirror of `erroredServerId`.
|
||||||
|
*/
|
||||||
|
connectedServerId?: string;
|
||||||
|
connectionStatus: ConnectionStatus;
|
||||||
|
/**
|
||||||
|
* Last connection-level error message (handshake failure, OAuth start
|
||||||
|
* failure, deep-link automation failure). Surfaced as `data-error-message`
|
||||||
|
* on the header's `connection-status` testid so an automated driver can read
|
||||||
|
* *why* a connect failed without scraping a transient toast.
|
||||||
|
*/
|
||||||
|
connectErrorMessage?: string;
|
||||||
|
initializeResult?: InitializeResult;
|
||||||
|
latencyMs?: number;
|
||||||
|
/** Negotiated protocol era (SEP §7.8), for the Protocol view's era badge. */
|
||||||
|
protocolEra?: ProtocolEra;
|
||||||
|
/**
|
||||||
|
* Argument completion, shared by the Prompts and Resources screens — hence
|
||||||
|
* here rather than in either one.
|
||||||
|
*/
|
||||||
|
onCompleteArgument?: (
|
||||||
|
ref:
|
||||||
|
| { type: "ref/resource"; uri: string }
|
||||||
|
| { type: "ref/prompt"; name: string },
|
||||||
|
argumentName: string,
|
||||||
|
argumentValue: string,
|
||||||
|
context: Record<string, string>,
|
||||||
|
) => Promise<string[]>;
|
||||||
|
completionsSupported?: boolean;
|
||||||
|
// Connection lifecycle (dispatched to `useInspectorClient.connect/disconnect`).
|
||||||
|
onToggleConnection: (id: string) => void;
|
||||||
|
onDisconnect: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The server catalog and every action on it. Static config only — the runtime
|
||||||
|
* connection state comes from {@link ConnectionProps} and is merged into each
|
||||||
|
* card by the view.
|
||||||
|
*/
|
||||||
|
export interface ServerListProps {
|
||||||
|
servers: ServerEntry[];
|
||||||
|
/**
|
||||||
|
* Whether the server list is writable (catalog) or read-only (a `--config`
|
||||||
|
* session file / ad-hoc launch). When false, the Servers screen hides all
|
||||||
|
* catalog mutation controls. Defaults to true.
|
||||||
|
*/
|
||||||
|
serverListWritable?: boolean;
|
||||||
|
/** Ids of freshly-added servers to highlight on the list (first is scrolled to). */
|
||||||
|
highlightedServerIds?: string[];
|
||||||
|
/** Clears the freshly-added highlight for a server (on click of its card). */
|
||||||
|
onClearHighlight?: (id: string) => void;
|
||||||
|
onServerAdd: () => void;
|
||||||
|
onServerImportConfig: () => void;
|
||||||
|
onServerImportJson: () => void;
|
||||||
|
/** Download the current server list as a canonical `mcp.json` file. */
|
||||||
|
onServerExport: () => void;
|
||||||
|
onConnectionInfo: (id: string) => void;
|
||||||
|
onServerSettings: (id: string) => void;
|
||||||
|
onServerEdit: (id: string) => void;
|
||||||
|
onServerClone: (id: string) => void;
|
||||||
|
onServerRemove: (id: string) => void;
|
||||||
|
/** Persist a new server ordering (drag-and-drop / keyboard reorder). */
|
||||||
|
onServerReorder: (orderedIds: string[]) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The Tools screen: its list, lifted UI state, and actions. */
|
||||||
|
export interface ToolsPanelProps {
|
||||||
|
tools: Tool[];
|
||||||
|
/**
|
||||||
|
* Tools excluded from `tools/list` for invalid `x-mcp-header` annotations
|
||||||
|
* (SEP-2243); shown in the Tools sidebar with the reason (#1632).
|
||||||
|
*/
|
||||||
|
excludedTools?: ExcludedTool[];
|
||||||
|
/** "List changed since last refresh", from the managed-state layer (#1402). */
|
||||||
|
toolsListChanged: boolean;
|
||||||
|
/**
|
||||||
|
* Last list-load failure, rendered above the sidebar list so a failed load
|
||||||
|
* (including the connect-time one) can't read as "this server has none"
|
||||||
|
* (#1953).
|
||||||
|
*/
|
||||||
|
toolsLoadError?: Error | null;
|
||||||
|
/**
|
||||||
|
* Lifted selection / search / filter state. Owned by the parent so it
|
||||||
|
* persists across tab navigation within a live session — the screens unmount
|
||||||
|
* on tab switch, so screen-local state would be lost (#1417).
|
||||||
|
*/
|
||||||
|
toolsUi: ToolsUiState;
|
||||||
|
/** Panel-level "operation in flight" state. */
|
||||||
|
toolCallState?: ToolCallState;
|
||||||
|
/** Pagination controls for the Tools list (#1721). */
|
||||||
|
toolsPagination: ListPaginationControlsProps;
|
||||||
|
/** Whether the connected server advertises task-augmented tool calls. */
|
||||||
|
serverSupportsTaskToolCalls: boolean;
|
||||||
|
onToolsUiChange: (next: ToolsUiState) => void;
|
||||||
|
onCallTool: (
|
||||||
|
name: string,
|
||||||
|
args: Record<string, unknown>,
|
||||||
|
runAsTask?: boolean,
|
||||||
|
) => void;
|
||||||
|
onCancelToolCall?: () => void;
|
||||||
|
onClearToolResult?: () => void;
|
||||||
|
onRefreshTools: () => void;
|
||||||
|
/**
|
||||||
|
* Read-on-demand handler for `resource_link` blocks in a tool result.
|
||||||
|
* Returns the linked resource's contents so the result panel can inline them.
|
||||||
|
*/
|
||||||
|
onReadResourceContents?: (uri: string) => Promise<ReadResourceResult>;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The Prompts screen: its list, lifted UI state, and actions. */
|
||||||
|
export interface PromptsPanelProps {
|
||||||
|
prompts: Prompt[];
|
||||||
|
promptsListChanged: boolean;
|
||||||
|
promptsLoadError?: Error | null;
|
||||||
|
promptsUi: PromptsUiState;
|
||||||
|
getPromptState?: GetPromptState;
|
||||||
|
/** Pagination controls for the Prompts list (#1721). */
|
||||||
|
promptsPagination: ListPaginationControlsProps;
|
||||||
|
onPromptsUiChange: (next: PromptsUiState) => void;
|
||||||
|
onGetPrompt: (name: string, args: Record<string, string>) => void;
|
||||||
|
onCopyPromptMessages?: () => void;
|
||||||
|
onRefreshPrompts: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The Resources screen: resources, templates, subscriptions, and actions. */
|
||||||
|
export interface ResourcesPanelProps {
|
||||||
|
resources: Resource[];
|
||||||
|
resourceTemplates: ResourceTemplate[];
|
||||||
|
subscriptions: InspectorResourceSubscription[];
|
||||||
|
/**
|
||||||
|
* Modern-era `subscriptions/listen` stream state (#1630). Drives the
|
||||||
|
* Resources screen's stream badge/dot; `active: false` (or omitted) on the
|
||||||
|
* legacy era.
|
||||||
|
*/
|
||||||
|
subscriptionStreamState?: ResourceSubscriptionStreamState;
|
||||||
|
/**
|
||||||
|
* Whether the connected server advertises the `resources.subscribe`
|
||||||
|
* capability. When false, the Resources screen hides the Subscribe/
|
||||||
|
* Unsubscribe button and the Subscriptions accordion section.
|
||||||
|
*/
|
||||||
|
subscriptionsSupported?: boolean;
|
||||||
|
resourcesListChanged: boolean;
|
||||||
|
/**
|
||||||
|
* The Resources sidebar lists resources AND templates behind a single
|
||||||
|
* Refresh, so App passes whichever of the two loads failed.
|
||||||
|
*/
|
||||||
|
resourcesLoadError?: Error | null;
|
||||||
|
resourcesUi: ResourcesUiState;
|
||||||
|
readResourceState?: ReadResourceState;
|
||||||
|
/** Pagination controls for the Resources list (#1721). */
|
||||||
|
resourcesPagination: ListPaginationControlsProps;
|
||||||
|
onResourcesUiChange: (next: ResourcesUiState) => void;
|
||||||
|
onReadResource: (uri: string) => void;
|
||||||
|
onSubscribeResource: (uri: string) => void;
|
||||||
|
onUnsubscribeResource: (uri: string) => void;
|
||||||
|
onRefreshResources: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The Apps screen and its sandbox wiring. The parent's web environment
|
||||||
|
* provides the sandbox iframe URL (undefined when the sandbox controller is
|
||||||
|
* unavailable), the per-app bridge factory, and the renderer handle the parent
|
||||||
|
* uses to push tool input/result into the running app and tear it down.
|
||||||
|
*/
|
||||||
|
export interface AppsPanelProps {
|
||||||
|
appsUi: AppsUiState;
|
||||||
|
sandboxPath?: string;
|
||||||
|
bridgeFactory: BridgeFactory;
|
||||||
|
appRendererRef: Ref<AppRendererHandle>;
|
||||||
|
onAppsUiChange: (next: AppsUiState) => void;
|
||||||
|
onSelectApp: (name: string) => void;
|
||||||
|
onOpenApp: (name: string, args: Record<string, unknown>) => void;
|
||||||
|
onCloseApp: () => void;
|
||||||
|
onAppError: (err: Error) => void;
|
||||||
|
onRefreshApps: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The Tasks monitor: the task list, its progress map, and actions. */
|
||||||
|
export interface TasksPanelProps {
|
||||||
|
tasks: Task[];
|
||||||
|
progressByTaskId?: Record<string, TaskProgress>;
|
||||||
|
tasksUi: TasksUiState;
|
||||||
|
onTasksUiChange: (next: TasksUiState) => void;
|
||||||
|
onCancelTask: (taskId: string) => void;
|
||||||
|
onClearCompletedTasks: () => void;
|
||||||
|
onRefreshTasks: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The Logs monitor, including both eras' log-level controls. */
|
||||||
|
export interface LogsPanelProps {
|
||||||
|
logs: LogEntryData[];
|
||||||
|
logsUi: LogsUiState;
|
||||||
|
/**
|
||||||
|
* The MCP `logging/setLevel` request has no echo notification, so the parent
|
||||||
|
* keeps the optimistic current value.
|
||||||
|
*/
|
||||||
|
currentLogLevel: LoggingLevel;
|
||||||
|
/**
|
||||||
|
* Modern-era per-request log level currently stamped, or `null` when opted
|
||||||
|
* out (#1629). On modern connections the Logs sidebar shows a per-request
|
||||||
|
* opt-in control instead of the legacy `logging/setLevel` selector.
|
||||||
|
*/
|
||||||
|
modernLogLevel?: LoggingLevel | null;
|
||||||
|
onSetLogLevel: (level: LoggingLevel) => void;
|
||||||
|
/** Set (or clear, with `null`) the modern per-request log level. */
|
||||||
|
onSetModernLogLevel?: (level: LoggingLevel | null) => void;
|
||||||
|
onLogsUiChange: (next: LogsUiState) => void;
|
||||||
|
onClearLogs: () => void;
|
||||||
|
onExportLogs: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The Protocol monitor: the message log, pinning, replay, and exports. */
|
||||||
|
export interface ProtocolPanelProps {
|
||||||
|
protocol: MessageEntry[];
|
||||||
|
protocolUi: ProtocolUiState;
|
||||||
|
/**
|
||||||
|
* Pinned message ids. Optional because pin state isn't persisted yet (#1244
|
||||||
|
* is single-PR; persistence is a separate concern).
|
||||||
|
*/
|
||||||
|
pinnedProtocolIds?: Set<string>;
|
||||||
|
onProtocolUiChange: (next: ProtocolUiState) => void;
|
||||||
|
onClearProtocol: () => void;
|
||||||
|
onExportProtocol: () => void;
|
||||||
|
onClearProtocolSection: (section: "pinned" | "history") => void;
|
||||||
|
onExportProtocolSection: (section: "pinned" | "history") => void;
|
||||||
|
onReplayProtocol: (id: string, overrideParams?: ReplayParamsOverride) => void;
|
||||||
|
onTogglePinProtocol: (id: string) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The Network monitor: captured HTTP traffic and its actions. */
|
||||||
|
export interface NetworkPanelProps {
|
||||||
|
network: FetchRequestEntry[];
|
||||||
|
networkUi: NetworkUiState;
|
||||||
|
onNetworkUiChange: (next: NetworkUiState) => void;
|
||||||
|
onClearNetwork: () => void;
|
||||||
|
onExportNetwork: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The Console monitor: captured stdio stderr. Empty for HTTP servers (#1621). */
|
||||||
|
export interface ConsolePanelProps {
|
||||||
|
stderrLogs: StderrLogEntry[];
|
||||||
|
consoleUi: ConsoleUiState;
|
||||||
|
onConsoleUiChange: (next: ConsoleUiState) => void;
|
||||||
|
onClearConsole: () => void;
|
||||||
|
onExportConsole: () => void;
|
||||||
|
}
|
||||||
|
|
@ -12,6 +12,8 @@ class FakeTransport implements Transport {
|
||||||
sessionId?: string;
|
sessionId?: string;
|
||||||
// Optional on the SDK Transport interface; stdio omits it, HTTP defines it.
|
// Optional on the SDK Transport interface; stdio omits it, HTTP defines it.
|
||||||
setProtocolVersion?: (version: string) => void;
|
setProtocolVersion?: (version: string) => void;
|
||||||
|
// Optional too: Streamable HTTP advertises it, stdio and SSE say nothing.
|
||||||
|
hasPerRequestStream?: boolean;
|
||||||
async start(): Promise<void> {}
|
async start(): Promise<void> {}
|
||||||
async send(message: JSONRPCMessage): Promise<void> {
|
async send(message: JSONRPCMessage): Promise<void> {
|
||||||
this.sent.push(message);
|
this.sent.push(message);
|
||||||
|
|
@ -272,4 +274,19 @@ describe("MessageTrackingTransport.setProtocolVersion", () => {
|
||||||
expect(() => tracked.setProtocolVersion("2025-06-18")).not.toThrow();
|
expect(() => tracked.setProtocolVersion("2025-06-18")).not.toThrow();
|
||||||
expect(tracked.protocolVersion).toBe("2025-06-18");
|
expect(tracked.protocolVersion).toBe("2025-06-18");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// #2140: the SDK reads `hasPerRequestStream` off the transport it was handed
|
||||||
|
// — always this wrapper — to choose between aborting a request's own stream
|
||||||
|
// and POSTing `notifications/cancelled`. A wrapper that answers for the base
|
||||||
|
// transport instead of forwarding its answer breaks Cancel on every client.
|
||||||
|
it("forwards the base transport's per-request-stream capability", () => {
|
||||||
|
const { tracked, base } = makeTracked();
|
||||||
|
expect(tracked.hasPerRequestStream).toBeUndefined();
|
||||||
|
|
||||||
|
base.hasPerRequestStream = true;
|
||||||
|
expect(tracked.hasPerRequestStream).toBe(true);
|
||||||
|
|
||||||
|
base.hasPerRequestStream = false;
|
||||||
|
expect(tracked.hasPerRequestStream).toBe(false);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -569,4 +569,121 @@ describe("RemoteClientTransport", () => {
|
||||||
|
|
||||||
await transport.close();
|
await transport.close();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// #2140: the Cancel button's wire signal is chosen by the SDK from the
|
||||||
|
// transport's `hasPerRequestStream`. The real upstream transport lives on the
|
||||||
|
// backend, so this transport answers for it — and must answer per server
|
||||||
|
// type, since only Streamable HTTP gives a request a stream of its own to
|
||||||
|
// close.
|
||||||
|
describe("per-request-stream cancellation (#2140)", () => {
|
||||||
|
it("advertises hasPerRequestStream only for streamable-http", () => {
|
||||||
|
const forType = (c: MCPServerConfig) =>
|
||||||
|
new RemoteClientTransport(
|
||||||
|
{ baseUrl, fetchFn: vi.fn<typeof fetch>() },
|
||||||
|
c,
|
||||||
|
).hasPerRequestStream;
|
||||||
|
|
||||||
|
expect(
|
||||||
|
forType({ type: "streamable-http", url: "http://server.example/mcp" }),
|
||||||
|
).toBe(true);
|
||||||
|
// stdio and SSE multiplex every request over one shared channel, so
|
||||||
|
// aborting anything would cancel the whole session rather than the call.
|
||||||
|
// The SDK must keep sending `notifications/cancelled` for them.
|
||||||
|
expect(forType(config)).toBe(false);
|
||||||
|
expect(forType({ type: "sse", url: "http://server.example/sse" })).toBe(
|
||||||
|
false,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("applies the SDK's requestSignal to the browser-to-backend send fetch", async () => {
|
||||||
|
const seenInits: (RequestInit | undefined)[] = [];
|
||||||
|
let sse = createPushableSseStream();
|
||||||
|
const fetchFn = vi
|
||||||
|
.fn<typeof fetch>()
|
||||||
|
.mockImplementation(async (input, init) => {
|
||||||
|
const url = String(input);
|
||||||
|
if (url.endsWith("/api/mcp/connect")) {
|
||||||
|
return new Response(JSON.stringify({ sessionId: "abc" }), {
|
||||||
|
status: 200,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (url.includes("/api/mcp/events")) {
|
||||||
|
sse = createPushableSseStream();
|
||||||
|
return sse.response;
|
||||||
|
}
|
||||||
|
if (url.endsWith("/api/mcp/send")) {
|
||||||
|
seenInits.push(init);
|
||||||
|
return new Response(JSON.stringify({ ok: true }), { status: 200 });
|
||||||
|
}
|
||||||
|
return new Response("not found", { status: 404 });
|
||||||
|
});
|
||||||
|
|
||||||
|
const transport = new RemoteClientTransport({ baseUrl, fetchFn }, config);
|
||||||
|
await transport.start();
|
||||||
|
|
||||||
|
const controller = new AbortController();
|
||||||
|
await transport.send(
|
||||||
|
{ jsonrpc: "2.0", method: "notifications/initialized" },
|
||||||
|
{ requestSignal: controller.signal },
|
||||||
|
);
|
||||||
|
expect(seenInits[0]?.signal).toBe(controller.signal);
|
||||||
|
|
||||||
|
// No signal supplied (stdio/SSE, where the SDK never creates one) must
|
||||||
|
// leave the fetch unaborted rather than passing `undefined` through as a
|
||||||
|
// deliberate value.
|
||||||
|
await transport.send({
|
||||||
|
jsonrpc: "2.0",
|
||||||
|
method: "notifications/roots/list_changed",
|
||||||
|
});
|
||||||
|
expect(seenInits[1]).not.toHaveProperty("signal");
|
||||||
|
|
||||||
|
await transport.close();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("aborting the requestSignal rejects the in-flight send", async () => {
|
||||||
|
let sse = createPushableSseStream();
|
||||||
|
const fetchFn = vi
|
||||||
|
.fn<typeof fetch>()
|
||||||
|
.mockImplementation(async (input, init) => {
|
||||||
|
const url = String(input);
|
||||||
|
if (url.endsWith("/api/mcp/connect")) {
|
||||||
|
return new Response(JSON.stringify({ sessionId: "abc" }), {
|
||||||
|
status: 200,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (url.includes("/api/mcp/events")) {
|
||||||
|
sse = createPushableSseStream();
|
||||||
|
return sse.response;
|
||||||
|
}
|
||||||
|
if (url.endsWith("/api/mcp/send")) {
|
||||||
|
// The backend holds this route open for the whole call, so model it
|
||||||
|
// as a fetch that only settles when the caller aborts.
|
||||||
|
return new Promise<Response>((_resolve, reject) => {
|
||||||
|
init?.signal?.addEventListener("abort", () =>
|
||||||
|
reject(new DOMException("Aborted", "AbortError")),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return new Response("not found", { status: 404 });
|
||||||
|
});
|
||||||
|
|
||||||
|
const transport = new RemoteClientTransport({ baseUrl, fetchFn }, config);
|
||||||
|
await transport.start();
|
||||||
|
|
||||||
|
const controller = new AbortController();
|
||||||
|
const sent = transport.send(
|
||||||
|
{ jsonrpc: "2.0", id: 7, method: "tools/call" },
|
||||||
|
{ requestSignal: controller.signal },
|
||||||
|
);
|
||||||
|
|
||||||
|
controller.abort();
|
||||||
|
// The backend never answers an aborted send, so the rejection has to come
|
||||||
|
// from the fetch itself. `postSend` already cancels the SSE response wait
|
||||||
|
// it registered for this id on that path (its catch does, unchanged by
|
||||||
|
// #2140) — so the send surfaces the abort rather than the wait's timeout.
|
||||||
|
await expect(sent).rejects.toThrow(/Aborted/);
|
||||||
|
|
||||||
|
await transport.close();
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -20,6 +20,7 @@ import {
|
||||||
createMrtrEdgeCaseTool,
|
createMrtrEdgeCaseTool,
|
||||||
loadConfig,
|
loadConfig,
|
||||||
resolveConfig,
|
resolveConfig,
|
||||||
|
type ToolDefinition,
|
||||||
} from "@modelcontextprotocol/inspector-test-server";
|
} from "@modelcontextprotocol/inspector-test-server";
|
||||||
import type { ServerConfig } from "@modelcontextprotocol/inspector-test-server";
|
import type { ServerConfig } from "@modelcontextprotocol/inspector-test-server";
|
||||||
import type {
|
import type {
|
||||||
|
|
@ -437,6 +438,146 @@ describe("modern-era negotiation (2026-07-28)", () => {
|
||||||
expect(result.result!.isError).toBeFalsy();
|
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 () => {
|
it("cancels an in-flight MRTR call while its embedded request is pending", async () => {
|
||||||
const started = await startMrtrServer(createMrtrTool());
|
const started = await startMrtrServer(createMrtrTool());
|
||||||
const connected = await connectWithEra(started.url, "modern");
|
const connected = await connectWithEra(started.url, "modern");
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,199 @@
|
||||||
|
/**
|
||||||
|
* #2140 — the Cancel button must send the transport's cancellation signal.
|
||||||
|
*
|
||||||
|
* On a 2026-07-28 Streamable HTTP connection the spec makes *closing the
|
||||||
|
* request's SSE response stream* the cancellation signal, and says a
|
||||||
|
* `notifications/cancelled` is neither required nor expected. The SDK
|
||||||
|
* implements that fork itself: `Protocol.request` aborts a per-request
|
||||||
|
* `AbortController` — handed to the transport as
|
||||||
|
* `TransportSendOptions.requestSignal` — when the connection is modern **and**
|
||||||
|
* the transport advertises `hasPerRequestStream`; otherwise it POSTs the
|
||||||
|
* notification.
|
||||||
|
*
|
||||||
|
* The web client lost it at the proxy boundary. The real upstream transport
|
||||||
|
* lives on the Node backend, and the browser's `RemoteClientTransport` neither
|
||||||
|
* advertised the flag nor applied a `requestSignal` to its
|
||||||
|
* `POST /api/mcp/send` — so every web cancel took the stdio branch, the server
|
||||||
|
* answered the notification `202 Accepted` and dropped it, and the tool kept
|
||||||
|
* running to completion. Only a manual disconnect actually cancelled anything.
|
||||||
|
*
|
||||||
|
* This drives the whole chain the browser drives — InspectorClient ->
|
||||||
|
* RemoteClientTransport -> the Hono backend -> a real upstream Streamable HTTP
|
||||||
|
* transport -> a modern test server — and asserts at the far end, on the
|
||||||
|
* server's own request abort signal. Nothing shallower reaches it: each of the
|
||||||
|
* three hops can drop the signal on its own, and a unit test of any one of them
|
||||||
|
* passes while the chain stays broken.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { describe, it, expect, afterEach } from "vitest";
|
||||||
|
import { serve } from "@hono/node-server";
|
||||||
|
import type { ServerType } from "@hono/node-server";
|
||||||
|
import { InspectorClient } from "@inspector/core/mcp/inspectorClient.js";
|
||||||
|
import { createRemoteTransport } from "@inspector/core/mcp/remote/createRemoteTransport.js";
|
||||||
|
import { createRemoteApp } from "@inspector/core/mcp/remote/node/server.js";
|
||||||
|
import { ToolCallCancelledError } from "@inspector/core/mcp/toolCallCancelledError.js";
|
||||||
|
import { eraToVersionNegotiation } from "@inspector/core/mcp/types.js";
|
||||||
|
import {
|
||||||
|
createTestServerHttp,
|
||||||
|
createTestServerInfo,
|
||||||
|
type TestServerHttp,
|
||||||
|
type ToolDefinition,
|
||||||
|
} from "@modelcontextprotocol/inspector-test-server";
|
||||||
|
|
||||||
|
function deferred<T>(): { promise: Promise<T>; resolve: (value: T) => void } {
|
||||||
|
let resolve!: (value: T) => void;
|
||||||
|
const promise = new Promise<T>((r) => {
|
||||||
|
resolve = r;
|
||||||
|
});
|
||||||
|
return { promise, resolve };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A tool that never returns on its own. It reports when it started, and whether
|
||||||
|
* the server-side request signal — which the SDK aborts when the client closes
|
||||||
|
* this request's response stream — ever fired.
|
||||||
|
*/
|
||||||
|
function createSlowTool(): {
|
||||||
|
tool: ToolDefinition;
|
||||||
|
started: Promise<void>;
|
||||||
|
aborted: Promise<boolean>;
|
||||||
|
} {
|
||||||
|
const started = deferred<void>();
|
||||||
|
const aborted = deferred<boolean>();
|
||||||
|
const tool: ToolDefinition = {
|
||||||
|
name: "slow_task",
|
||||||
|
description: "Runs until the client cancels it",
|
||||||
|
handler: async (_params, _context, extra) => {
|
||||||
|
extra?.signal?.addEventListener("abort", () => aborted.resolve(true), {
|
||||||
|
once: true,
|
||||||
|
});
|
||||||
|
started.resolve();
|
||||||
|
// Never settles: this call ends by cancellation or not at all, so a
|
||||||
|
// regression shows up as the test's own timeout rather than as a pass.
|
||||||
|
return new Promise(() => {});
|
||||||
|
},
|
||||||
|
};
|
||||||
|
return { tool, started: started.promise, aborted: aborted.promise };
|
||||||
|
}
|
||||||
|
|
||||||
|
async function startRemoteBackend(): Promise<{
|
||||||
|
baseUrl: string;
|
||||||
|
server: ServerType;
|
||||||
|
authToken: string;
|
||||||
|
}> {
|
||||||
|
const { app, authToken } = createRemoteApp({
|
||||||
|
initialConfig: { defaultEnvironment: {} },
|
||||||
|
});
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const server = serve(
|
||||||
|
{ fetch: app.fetch, port: 0, hostname: "127.0.0.1" },
|
||||||
|
(info) => {
|
||||||
|
const port =
|
||||||
|
info && typeof info === "object" && "port" in info
|
||||||
|
? (info as { port: number }).port
|
||||||
|
: 0;
|
||||||
|
resolve({ baseUrl: `http://127.0.0.1:${port}`, server, authToken });
|
||||||
|
},
|
||||||
|
);
|
||||||
|
server.on("error", reject);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("web-client cancellation over a per-request stream (#2140)", () => {
|
||||||
|
let client: InspectorClient | null = null;
|
||||||
|
let backend: ServerType | null = null;
|
||||||
|
let upstream: TestServerHttp | null = null;
|
||||||
|
|
||||||
|
afterEach(async () => {
|
||||||
|
if (client) {
|
||||||
|
try {
|
||||||
|
await client.disconnect();
|
||||||
|
} catch {
|
||||||
|
// Ignore disconnect errors.
|
||||||
|
}
|
||||||
|
client = null;
|
||||||
|
}
|
||||||
|
if (backend) {
|
||||||
|
const server = backend;
|
||||||
|
backend = null;
|
||||||
|
await new Promise<void>((resolve, reject) => {
|
||||||
|
server.close((err) => (err ? reject(err) : resolve()));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (upstream) {
|
||||||
|
try {
|
||||||
|
await upstream.stop();
|
||||||
|
} catch {
|
||||||
|
// Ignore stop errors.
|
||||||
|
}
|
||||||
|
upstream = null;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("aborts the upstream request stream, which the server observes as cancellation", async () => {
|
||||||
|
const slow = createSlowTool();
|
||||||
|
upstream = createTestServerHttp({
|
||||||
|
serverInfo: createTestServerInfo("cancel-test", "1.0.0"),
|
||||||
|
tools: [slow.tool],
|
||||||
|
modern: {},
|
||||||
|
});
|
||||||
|
await upstream.start();
|
||||||
|
|
||||||
|
const { baseUrl, server, authToken } = await startRemoteBackend();
|
||||||
|
backend = server;
|
||||||
|
|
||||||
|
const connected = new InspectorClient(
|
||||||
|
{ type: "streamable-http", url: upstream.url },
|
||||||
|
{
|
||||||
|
environment: {
|
||||||
|
transport: createRemoteTransport({ baseUrl, authToken }),
|
||||||
|
},
|
||||||
|
versionNegotiation: eraToVersionNegotiation("modern"),
|
||||||
|
},
|
||||||
|
);
|
||||||
|
await connected.connect();
|
||||||
|
client = connected;
|
||||||
|
expect(connected.getProtocolEra()).toBe("modern");
|
||||||
|
|
||||||
|
const { tools } = await connected.listTools();
|
||||||
|
const tool = tools.find((t) => t.name === "slow_task");
|
||||||
|
expect(tool).toBeDefined();
|
||||||
|
|
||||||
|
// Hold the rejection from the first turn so the runner never sees it as
|
||||||
|
// unhandled while we wait on the server side.
|
||||||
|
const settled = connected.callTool(tool!, {}).catch((err: unknown) => err);
|
||||||
|
|
||||||
|
await slow.started;
|
||||||
|
expect(connected.cancelToolCall()).toBe(true);
|
||||||
|
|
||||||
|
// The user-facing contract is unchanged: a deliberate cancel still surfaces
|
||||||
|
// as ToolCallCancelledError, whichever wire signal carried it.
|
||||||
|
await expect(settled).resolves.toBeInstanceOf(ToolCallCancelledError);
|
||||||
|
|
||||||
|
// The assertion that matters. Before the fix the server saw only a
|
||||||
|
// `notifications/cancelled` it was free to ignore (and did), its request
|
||||||
|
// signal never fired, and this promise never resolved.
|
||||||
|
await expect(slow.aborted).resolves.toBe(true);
|
||||||
|
}, 30_000);
|
||||||
|
|
||||||
|
it("advertises the per-request stream only for streamable-http", () => {
|
||||||
|
// The flag is what the SDK forks on, and the factory is where a server's
|
||||||
|
// configured type reaches the transport. stdio and SSE multiplex every
|
||||||
|
// request over one shared channel: there is no per-request stream to close,
|
||||||
|
// and aborting anything would take the whole session down — so they must
|
||||||
|
// keep the `notifications/cancelled` mechanism.
|
||||||
|
const create = createRemoteTransport({ baseUrl: "http://unused.example" });
|
||||||
|
expect(
|
||||||
|
create({ type: "stdio", command: "echo", args: [] }, {}).transport
|
||||||
|
.hasPerRequestStream,
|
||||||
|
).toBe(false);
|
||||||
|
expect(
|
||||||
|
create({ type: "sse", url: "http://unused.example/sse" }, {}).transport
|
||||||
|
.hasPerRequestStream,
|
||||||
|
).toBe(false);
|
||||||
|
expect(
|
||||||
|
create({ type: "streamable-http", url: "http://unused.example/mcp" }, {})
|
||||||
|
.transport.hasPerRequestStream,
|
||||||
|
).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -649,9 +649,12 @@ export class InspectorClient extends InspectorClientEventTarget {
|
||||||
>();
|
>();
|
||||||
private rawWireRequestCounter = 0;
|
private rawWireRequestCounter = 0;
|
||||||
// Abort controller for the in-flight ordinary (non-task) tool call. Aborting
|
// 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
|
// it hands the SDK the MCP cancellation flow for that request and rejects the
|
||||||
// cancellation flow) and reject the pending call, which `callTool` surfaces as
|
// pending call, which `callTool` surfaces as a `ToolCallCancelledError`. Which
|
||||||
// a `ToolCallCancelledError`. Undefined when no ordinary call is in flight.
|
// 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
|
// Task-augmented calls have a server-side task and are cancelled via
|
||||||
// `cancelRequestorTask` instead, so they don't use this (#1458).
|
// `cancelRequestorTask` instead, so they don't use this (#1458).
|
||||||
private activeToolCallAbortController?: AbortController;
|
private activeToolCallAbortController?: AbortController;
|
||||||
|
|
@ -1180,8 +1183,10 @@ export class InspectorClient extends InspectorClientEventTarget {
|
||||||
if (this.requestTimeout !== undefined) {
|
if (this.requestTimeout !== undefined) {
|
||||||
opts.timeout = this.requestTimeout;
|
opts.timeout = this.requestTimeout;
|
||||||
}
|
}
|
||||||
// When provided, aborting this signal makes the SDK send a
|
// When provided, aborting this signal cancels the request and rejects it
|
||||||
// `notifications/cancelled` for the request and reject it (#1458).
|
// (#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) {
|
if (signal) {
|
||||||
opts.signal = signal;
|
opts.signal = signal;
|
||||||
}
|
}
|
||||||
|
|
@ -2987,10 +2992,13 @@ export class InspectorClient extends InspectorClientEventTarget {
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Cancel the in-flight ordinary (non-task) tool call started by
|
* Cancel the in-flight ordinary (non-task) tool call started by
|
||||||
* {@link callTool}. Aborting its request makes the SDK send a
|
* {@link callTool}. Aborting its request runs the MCP cancellation flow and
|
||||||
* `notifications/cancelled` to the server (the MCP cancellation flow) and
|
* rejects the pending call, which `callTool` surfaces as a
|
||||||
* reject the pending call, which `callTool` surfaces as a
|
* {@link ToolCallCancelledError}. The SDK chooses the wire signal from the
|
||||||
* {@link ToolCallCancelledError}.
|
* 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
|
* Task-augmented calls have a server-side task and are cancelled via
|
||||||
* {@link cancelRequestorTask} instead — this is a no-op for them (and whenever
|
* {@link cancelRequestorTask} instead — this is a no-op for them (and whenever
|
||||||
|
|
|
||||||
|
|
@ -203,6 +203,30 @@ export class MessageTrackingTransport implements Transport {
|
||||||
return this.baseTransport.sessionId;
|
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
|
// Implemented as a concrete method (rather than delegating the base
|
||||||
// transport's optional `setProtocolVersion`) so the SDK Client always
|
// transport's optional `setProtocolVersion`) so the SDK Client always
|
||||||
// invokes it after the initialize handshake — including for stdio, whose
|
// invokes it after the initialize handshake — including for stdio, whose
|
||||||
|
|
|
||||||
|
|
@ -1022,6 +1022,36 @@ export function createRemoteApp(
|
||||||
// Auth errors may reject the wait via onerror while send() also throws.
|
// Auth errors may reject the wait via onerror while send() also throws.
|
||||||
void responseWait.catch(() => {});
|
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 {
|
try {
|
||||||
// SEP-2243: apply the client's mirrored headers to the upstream request,
|
// 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
|
// 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, {
|
await session.transport.send(message, {
|
||||||
relatedRequestId: relatedRequestId as string | number | undefined,
|
relatedRequestId: relatedRequestId as string | number | undefined,
|
||||||
...(paramHeaders && { headers: paramHeaders }),
|
...(paramHeaders && { headers: paramHeaders }),
|
||||||
|
requestSignal: upstreamAbort.signal,
|
||||||
});
|
});
|
||||||
if (responseWait) {
|
if (responseWait) {
|
||||||
await responseWait;
|
await responseWait;
|
||||||
|
|
@ -1051,6 +1082,7 @@ export function createRemoteApp(
|
||||||
const msg = err instanceof Error ? err.message : String(err);
|
const msg = err instanceof Error ? err.message : String(err);
|
||||||
return c.json({ ok: false, kind: "transport_error", error: msg });
|
return c.json({ ok: false, kind: "transport_error", error: msg });
|
||||||
} finally {
|
} finally {
|
||||||
|
clientSignal.removeEventListener("abort", onClientDisconnect);
|
||||||
session.endSend();
|
session.endSend();
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -261,6 +261,25 @@ export class RemoteClientTransport implements Transport {
|
||||||
private readonly options: RemoteTransportOptions;
|
private readonly options: RemoteTransportOptions;
|
||||||
private readonly config: import("../types.js").MCPServerConfig;
|
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
|
* 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
|
* 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.options = options;
|
||||||
this.config = config;
|
this.config = config;
|
||||||
|
this.hasPerRequestStream = config.type === "streamable-http";
|
||||||
}
|
}
|
||||||
|
|
||||||
setAuthRecovery(handlers: AuthRecoveryHandlers | undefined): void {
|
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`, {
|
const res = await this.fetchFn(`${this.baseUrl}/api/mcp/send`, {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
headers: this.headers,
|
headers: this.headers,
|
||||||
body: JSON.stringify(body),
|
body: JSON.stringify(body),
|
||||||
|
...(options?.requestSignal && { signal: options.requestSignal }),
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!res.ok) {
|
if (!res.ok) {
|
||||||
|
|
|
||||||
|
|
@ -86,20 +86,71 @@ test("passes when the child paints the marker and keeps running", async () => {
|
||||||
// visibly contains the marker. A slow-but-fine TUI would fail, intermittently,
|
// visibly contains the marker. A slow-but-fine TUI would fail, intermittently,
|
||||||
// with a diagnostic pointing at the wrong thing entirely.
|
// 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 () => {
|
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({
|
const r = await runRenderSmoke({
|
||||||
...opts,
|
...opts,
|
||||||
...stub(
|
...stub(
|
||||||
`setTimeout(() => { console.log(${JSON.stringify(MARKER)}); ` +
|
`const at = ${spawnedAt + PAINT_AT};` +
|
||||||
`setInterval(() => {}, 1000); }, ${paintAt});`,
|
`setTimeout(() => { console.log(${JSON.stringify(MARKER)}); ` +
|
||||||
|
`setInterval(() => {}, 1000); }, Math.max(0, at - Date.now()));`,
|
||||||
),
|
),
|
||||||
// Deliberately tight: the render deadline expires while the survival window
|
// The shape being reproduced is an overlap, and only an overlap:
|
||||||
// is still open, which is the whole shape of the bug.
|
//
|
||||||
timeoutMs: paintAt + 150,
|
// actual paint < timeoutMs < actual paint + surviveMs
|
||||||
surviveMs: 400,
|
//
|
||||||
|
// 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.equal(r.code, 0, `expected pass, got: ${r.message}`);
|
||||||
assert.doesNotMatch(r.message, /did not render/);
|
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 () => {
|
test("fails when the child exits before painting", async () => {
|
||||||
|
|
|
||||||
|
|
@ -390,7 +390,11 @@ When a tool execution triggers sampling or elicitation requests, they appear inl
|
||||||
- Step description if provided
|
- Step description if provided
|
||||||
- Elapsed time display
|
- Elapsed time display
|
||||||
- Execute button with loading state
|
- 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:
|
- **Inline Client Request Queue** - When tool triggers sampling/elicitation:
|
||||||
- Pending requests shown inline (not as separate modal)
|
- Pending requests shown inline (not as separate modal)
|
||||||
- Queue counter shows total pending requests
|
- Queue counter shows total pending requests
|
||||||
|
|
|
||||||
|
|
@ -110,7 +110,11 @@ When a tool execution triggers sampling or elicitation requests, they appear inl
|
||||||
- Step description if provided
|
- Step description if provided
|
||||||
- Elapsed time display
|
- Elapsed time display
|
||||||
- Execute button with loading state
|
- 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:
|
- **Inline Client Request Queue** - When tool triggers sampling/elicitation:
|
||||||
- Pending requests shown inline (not as separate modal)
|
- Pending requests shown inline (not as separate modal)
|
||||||
- Queue counter shows total pending requests
|
- Queue counter shows total pending requests
|
||||||
|
|
|
||||||
12
test-servers/configs/cancellation-modern-http.json
Normal file
12
test-servers/configs/cancellation-modern-http.json
Normal file
|
|
@ -0,0 +1,12 @@
|
||||||
|
{
|
||||||
|
"serverInfo": {
|
||||||
|
"name": "cancellation-showcase",
|
||||||
|
"version": "1.0.0"
|
||||||
|
},
|
||||||
|
"tools": [{ "preset": "slow_task" }, { "preset": "echo" }],
|
||||||
|
"transport": {
|
||||||
|
"type": "streamable-http",
|
||||||
|
"port": 6606,
|
||||||
|
"modern": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -33,6 +33,7 @@ import {
|
||||||
createCollectUrlElicitationTool,
|
createCollectUrlElicitationTool,
|
||||||
createUrlElicitationFormTool,
|
createUrlElicitationFormTool,
|
||||||
createSendNotificationTool,
|
createSendNotificationTool,
|
||||||
|
createSlowTaskTool,
|
||||||
createGetAnnotatedMessageTool,
|
createGetAnnotatedMessageTool,
|
||||||
createGetTempTool,
|
createGetTempTool,
|
||||||
createGetTempExtraTool,
|
createGetTempExtraTool,
|
||||||
|
|
@ -165,6 +166,8 @@ function resolveToolPreset(
|
||||||
return createUrlElicitationFormTool();
|
return createUrlElicitationFormTool();
|
||||||
case "send_notification":
|
case "send_notification":
|
||||||
return createSendNotificationTool();
|
return createSendNotificationTool();
|
||||||
|
case "slow_task":
|
||||||
|
return createSlowTaskTool();
|
||||||
case "get_annotated_message":
|
case "get_annotated_message":
|
||||||
return createGetAnnotatedMessageTool();
|
return createGetAnnotatedMessageTool();
|
||||||
case "get_temp":
|
case "get_temp":
|
||||||
|
|
|
||||||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Reference in a new issue