Merge remote-tracking branch 'origin/v2/main' into v2/fix/2172-oidc-discovery-compat
Signed-off-by: cliffhall <cliff@futurescale.com> # Conflicts: # README.md # core/mcp/inspectorClient.ts # test-servers/src/test-server-oauth.ts
This commit is contained in:
commit
2eeab05d00
33 changed files with 8524 additions and 1598 deletions
|
|
@ -136,7 +136,10 @@ vi.mock("@inspector/core/mcp/index.js", async (importOriginal) => {
|
|||
}
|
||||
return Promise.resolve(true);
|
||||
});
|
||||
clearOAuthTokens = vi.fn().mockResolvedValue(undefined);
|
||||
// #2144: the clear path reads the returned RFC 7009 outcome.
|
||||
clearOAuthTokens = vi
|
||||
.fn()
|
||||
.mockResolvedValue({ status: "skipped", reason: "no_endpoint" });
|
||||
}
|
||||
const instances: FakeInspectorClient[] = [];
|
||||
return {
|
||||
|
|
@ -450,122 +453,67 @@ vi.mock("./lib/publishAppDocument", () => ({
|
|||
// that invoke the App's connect / call-tool / get-prompt / read-resource /
|
||||
// set-log-level handlers.
|
||||
vi.mock("./components/views/InspectorView/InspectorView", () => ({
|
||||
InspectorView: (props: {
|
||||
toolCallState?: { status?: string };
|
||||
toolsUi?: {
|
||||
selectedToolKey?: string;
|
||||
formValues: Record<string, unknown>;
|
||||
search: string;
|
||||
};
|
||||
promptsUi?: {
|
||||
selectedPromptName?: string;
|
||||
argumentValues: Record<string, string>;
|
||||
submittedFor?: string;
|
||||
search: string;
|
||||
};
|
||||
logsUi?: { filterText: string; visibleLevels: Record<string, boolean> };
|
||||
getPromptState?: { status?: string };
|
||||
readResourceState?: { status?: string };
|
||||
currentLogLevel?: string;
|
||||
activeTab?: string;
|
||||
activeServer?: string;
|
||||
erroredServerId?: string;
|
||||
initializeResult?: { serverInfo: { name: string; version: string } };
|
||||
onActiveTabChange: (tab: string) => void;
|
||||
onConnectionInfo: () => void;
|
||||
onToggleConnection: (id: string) => void;
|
||||
onToolsUiChange: (next: {
|
||||
selectedToolKey?: string;
|
||||
formValues: Record<string, unknown>;
|
||||
search: string;
|
||||
}) => void;
|
||||
onPromptsUiChange: (next: {
|
||||
selectedPromptName?: string;
|
||||
argumentValues: Record<string, string>;
|
||||
submittedFor?: string;
|
||||
search: string;
|
||||
}) => void;
|
||||
onLogsUiChange: (next: {
|
||||
filterText: string;
|
||||
visibleLevels: Record<string, boolean>;
|
||||
}) => void;
|
||||
progressByTaskId?: Record<string, unknown>;
|
||||
onCallTool: (
|
||||
name: string,
|
||||
args: Record<string, unknown>,
|
||||
runAsTask?: boolean,
|
||||
) => void;
|
||||
onGetPrompt: (name: string, args: Record<string, string>) => void;
|
||||
onReadResource: (uri: string) => void;
|
||||
onSetLogLevel: (level: string) => void;
|
||||
onCancelTask: (taskId: string) => void;
|
||||
onCancelToolCall: () => void;
|
||||
onClearCompletedTasks: () => void;
|
||||
onRefreshTasks: () => void;
|
||||
onServerSettings: (id: string) => void;
|
||||
onServerEdit: (id: string) => void;
|
||||
onServerAdd: () => void;
|
||||
highlightedServerIds?: string[];
|
||||
onClearProtocol: () => void;
|
||||
onReplayProtocol: (id: string) => void;
|
||||
onTogglePinProtocol: (id: string) => void;
|
||||
pinnedProtocolIds?: Set<string>;
|
||||
onRefreshTools: () => void;
|
||||
toolsPagination: {
|
||||
paginated: boolean;
|
||||
canLoadMore: boolean;
|
||||
loadedPages: number;
|
||||
onPaginatedChange: (v: boolean) => void;
|
||||
onLoadMore: () => void;
|
||||
};
|
||||
}) => (
|
||||
InspectorView: (props: InspectorViewProps) => (
|
||||
<div>
|
||||
<span data-testid="tool-status">
|
||||
{props.toolCallState?.status ?? "none"}
|
||||
{props.tools.toolCallState?.status ?? "none"}
|
||||
</span>
|
||||
<span data-testid="task-progress-keys">
|
||||
{Object.keys(props.progressByTaskId ?? {}).join(",") || "none"}
|
||||
{Object.keys(props.tasks.progressByTaskId ?? {}).join(",") || "none"}
|
||||
</span>
|
||||
<span data-testid="selected-tool">
|
||||
{props.toolsUi?.selectedToolKey ?? "none"}
|
||||
{props.tools.toolsUi?.selectedToolKey ?? "none"}
|
||||
</span>
|
||||
<span data-testid="tool-search">
|
||||
{props.tools.toolsUi?.search || "none"}
|
||||
</span>
|
||||
<span data-testid="tool-search">{props.toolsUi?.search || "none"}</span>
|
||||
<span data-testid="selected-prompt">
|
||||
{props.promptsUi?.selectedPromptName ?? "none"}
|
||||
{props.prompts.promptsUi?.selectedPromptName ?? "none"}
|
||||
</span>
|
||||
<span data-testid="log-filter">
|
||||
{props.logs.logsUi?.filterText || "none"}
|
||||
</span>
|
||||
<span data-testid="log-filter">{props.logsUi?.filterText || "none"}</span>
|
||||
<span data-testid="prompt-status">
|
||||
{props.getPromptState?.status ?? "none"}
|
||||
{props.prompts.getPromptState?.status ?? "none"}
|
||||
</span>
|
||||
<span data-testid="resource-status">
|
||||
{props.readResourceState?.status ?? "none"}
|
||||
{props.resources.readResourceState?.status ?? "none"}
|
||||
</span>
|
||||
<span data-testid="log-level">{props.currentLogLevel}</span>
|
||||
<span data-testid="active-tab">{props.activeTab ?? "none"}</span>
|
||||
<span data-testid="log-level">{props.logs.currentLogLevel}</span>
|
||||
<span data-testid="active-tab">{props.shell.activeTab ?? "none"}</span>
|
||||
<span data-testid="init-result">
|
||||
{props.initializeResult
|
||||
? `name:${props.initializeResult.serverInfo.name || "(empty)"}`
|
||||
{props.connection.initializeResult
|
||||
? `name:${props.connection.initializeResult.serverInfo.name || "(empty)"}`
|
||||
: "none"}
|
||||
</span>
|
||||
<span data-testid="errored-server">
|
||||
{props.erroredServerId ?? "none"}
|
||||
{props.connection.erroredServerId ?? "none"}
|
||||
</span>
|
||||
<span data-testid="active-server">{props.activeServer ?? "none"}</span>
|
||||
<button onClick={() => props.onActiveTabChange("Servers")}>
|
||||
<span data-testid="active-server">
|
||||
{props.connection.activeServer ?? "none"}
|
||||
</span>
|
||||
<button onClick={() => props.shell.onActiveTabChange("Servers")}>
|
||||
switch-servers-tab
|
||||
</button>
|
||||
<button onClick={() => props.onToggleConnection("A")}>connect</button>
|
||||
<button onClick={() => props.connection.onToggleConnection("A")}>
|
||||
connect
|
||||
</button>
|
||||
{/* A second target, so a test can drive an A -> B -> A switch (#2095). */}
|
||||
<button onClick={() => props.onToggleConnection("B")}>connect-b</button>
|
||||
<button onClick={() => props.onConnectionInfo()}>
|
||||
<button onClick={() => props.connection.onToggleConnection("B")}>
|
||||
connect-b
|
||||
</button>
|
||||
{/* The header's explicit Disconnect, which routes to the standalone
|
||||
`onDisconnect` rather than through the toggle. */}
|
||||
<button onClick={() => props.connection.onDisconnect()}>
|
||||
disconnect
|
||||
</button>
|
||||
<button onClick={() => props.servers.onConnectionInfo("A")}>
|
||||
open-connection-info
|
||||
</button>
|
||||
<button
|
||||
onClick={() =>
|
||||
props.onToolsUiChange({
|
||||
formValues: {},
|
||||
search: "",
|
||||
...props.toolsUi,
|
||||
props.tools.onToolsUiChange({
|
||||
...props.tools.toolsUi,
|
||||
selectedToolKey: "0:get_acts",
|
||||
})
|
||||
}
|
||||
|
|
@ -574,10 +522,8 @@ vi.mock("./components/views/InspectorView/InspectorView", () => ({
|
|||
</button>
|
||||
<button
|
||||
onClick={() =>
|
||||
props.onToolsUiChange({
|
||||
formValues: {},
|
||||
search: "",
|
||||
...props.toolsUi,
|
||||
props.tools.onToolsUiChange({
|
||||
...props.tools.toolsUi,
|
||||
selectedToolKey: "1:other_tool",
|
||||
})
|
||||
}
|
||||
|
|
@ -586,9 +532,8 @@ vi.mock("./components/views/InspectorView/InspectorView", () => ({
|
|||
</button>
|
||||
<button
|
||||
onClick={() =>
|
||||
props.onToolsUiChange({
|
||||
formValues: {},
|
||||
...props.toolsUi,
|
||||
props.tools.onToolsUiChange({
|
||||
...props.tools.toolsUi,
|
||||
search: "act",
|
||||
})
|
||||
}
|
||||
|
|
@ -597,10 +542,8 @@ vi.mock("./components/views/InspectorView/InspectorView", () => ({
|
|||
</button>
|
||||
<button
|
||||
onClick={() =>
|
||||
props.onPromptsUiChange({
|
||||
argumentValues: {},
|
||||
search: "",
|
||||
...props.promptsUi,
|
||||
props.prompts.onPromptsUiChange({
|
||||
...props.prompts.promptsUi,
|
||||
selectedPromptName: "greet",
|
||||
})
|
||||
}
|
||||
|
|
@ -609,70 +552,99 @@ vi.mock("./components/views/InspectorView/InspectorView", () => ({
|
|||
</button>
|
||||
<button
|
||||
onClick={() =>
|
||||
props.onLogsUiChange({
|
||||
visibleLevels: {},
|
||||
...props.logsUi,
|
||||
props.logs.onLogsUiChange({
|
||||
...props.logs.logsUi,
|
||||
filterText: "err",
|
||||
})
|
||||
}
|
||||
>
|
||||
set-log-filter
|
||||
</button>
|
||||
<button onClick={() => props.onCallTool("get_acts", {})}>call</button>
|
||||
<button onClick={() => props.onCallTool("get_acts", {}, true)}>
|
||||
<button onClick={() => props.tools.onCallTool("get_acts", {})}>
|
||||
call
|
||||
</button>
|
||||
<button onClick={() => props.tools.onCallTool("get_acts", {}, true)}>
|
||||
call-as-task
|
||||
</button>
|
||||
<button onClick={() => props.onCancelTask("task-1")}>cancel-task</button>
|
||||
<button onClick={() => props.onCancelToolCall()}>cancel-tool-call</button>
|
||||
<button onClick={() => props.onClearCompletedTasks()}>
|
||||
<button onClick={() => props.tasks.onCancelTask("task-1")}>
|
||||
cancel-task
|
||||
</button>
|
||||
<button onClick={() => props.tools.onCancelToolCall?.()}>
|
||||
cancel-tool-call
|
||||
</button>
|
||||
<button onClick={() => props.tasks.onClearCompletedTasks()}>
|
||||
clear-completed
|
||||
</button>
|
||||
<button onClick={() => props.onRefreshTasks()}>refresh-tasks</button>
|
||||
<button onClick={() => props.onGetPrompt("greet", {})}>get-prompt</button>
|
||||
<button onClick={() => props.onReadResource("res://x")}>
|
||||
<button onClick={() => props.tasks.onRefreshTasks()}>
|
||||
refresh-tasks
|
||||
</button>
|
||||
<button onClick={() => props.prompts.onGetPrompt("greet", {})}>
|
||||
get-prompt
|
||||
</button>
|
||||
<button onClick={() => props.resources.onReadResource("res://x")}>
|
||||
read-resource
|
||||
</button>
|
||||
<button onClick={() => props.onSetLogLevel("debug")}>set-level</button>
|
||||
<button onClick={() => props.onServerSettings("A")}>open-settings</button>
|
||||
<button onClick={() => props.logs.onSetLogLevel("debug")}>
|
||||
set-level
|
||||
</button>
|
||||
<button onClick={() => props.servers.onServerSettings("A")}>
|
||||
open-settings
|
||||
</button>
|
||||
{/* The real server grid (and its Add / Edit controls) lives inside this
|
||||
mocked view, so the config modal is only reachable through these
|
||||
callbacks — and the highlight batch only observable through this prop. */}
|
||||
<button onClick={() => props.onServerEdit("A")}>edit-server</button>
|
||||
<button onClick={() => props.onServerAdd()}>add-server</button>
|
||||
<button onClick={() => props.servers.onServerEdit("A")}>
|
||||
edit-server
|
||||
</button>
|
||||
<button onClick={() => props.servers.onServerAdd()}>add-server</button>
|
||||
{/* The real drag-and-drop reorder lives inside this mocked view, so the
|
||||
callback is only reachable through a control like this one. */}
|
||||
<button onClick={() => props.servers.onServerReorder(["B", "A"])}>
|
||||
reorder-servers
|
||||
</button>
|
||||
<span data-testid="highlighted-servers">
|
||||
{(props.highlightedServerIds ?? []).join(",") || "none"}
|
||||
{(props.servers.highlightedServerIds ?? []).join(",") || "none"}
|
||||
</span>
|
||||
<span data-testid="pinned-history">
|
||||
{Array.from(props.pinnedProtocolIds ?? []).join(",")}
|
||||
{Array.from(props.protocol.pinnedProtocolIds ?? []).join(",")}
|
||||
</span>
|
||||
<button onClick={() => props.onTogglePinProtocol("hist-1")}>
|
||||
<button onClick={() => props.protocol.onTogglePinProtocol("hist-1")}>
|
||||
toggle-pin
|
||||
</button>
|
||||
<button onClick={() => props.onReplayProtocol("hist-1")}>
|
||||
<button onClick={() => props.protocol.onReplayProtocol("hist-1")}>
|
||||
replay-history
|
||||
</button>
|
||||
<button onClick={() => props.onClearProtocol()}>clear-history</button>
|
||||
<button onClick={() => props.protocol.onClearProtocol()}>
|
||||
clear-history
|
||||
</button>
|
||||
<span data-testid="tools-paginated">
|
||||
{String(props.toolsPagination.paginated)}
|
||||
{String(props.tools.toolsPagination.paginated)}
|
||||
</span>
|
||||
<span data-testid="tools-loaded-pages">
|
||||
{props.toolsPagination.loadedPages}
|
||||
{props.tools.toolsPagination.loadedPages}
|
||||
</span>
|
||||
<button onClick={() => props.toolsPagination.onPaginatedChange(true)}>
|
||||
<button
|
||||
onClick={() => props.tools.toolsPagination.onPaginatedChange(true)}
|
||||
>
|
||||
paginated-on
|
||||
</button>
|
||||
<button onClick={() => props.toolsPagination.onPaginatedChange(false)}>
|
||||
<button
|
||||
onClick={() => props.tools.toolsPagination.onPaginatedChange(false)}
|
||||
>
|
||||
paginated-off
|
||||
</button>
|
||||
<button onClick={() => props.toolsPagination.onLoadMore()}>
|
||||
<button onClick={() => props.tools.toolsPagination.onLoadMore()}>
|
||||
load-more-tools
|
||||
</button>
|
||||
<button onClick={() => props.onRefreshTools()}>refresh-tools</button>
|
||||
<button onClick={() => props.tools.onRefreshTools()}>
|
||||
refresh-tools
|
||||
</button>
|
||||
</div>
|
||||
),
|
||||
}));
|
||||
|
||||
import App from "./App";
|
||||
import type { InspectorViewProps } from "./components/views/InspectorView/InspectorView";
|
||||
import { SERVER_INFO_NOT_REPORTED_LABEL } from "./components/groups/ConnectionInfoContent/ConnectionInfoContent";
|
||||
import { OAUTH_CALLBACK_PATH } from "./utils/oauthFlow.js";
|
||||
import { INSPECTOR_SERVERS_TAB } from "./utils/inspectorTabs.js";
|
||||
|
|
@ -3842,6 +3814,30 @@ describe("App background command rejections (#2049)", () => {
|
|||
return user;
|
||||
};
|
||||
|
||||
// A complete `useServers` return with one injected mutator, so the mock stays
|
||||
// type-checked against the hook's contract rather than spread from a call.
|
||||
const serversWithReorder = (
|
||||
reorderServers: ReturnType<typeof useServers>["reorderServers"],
|
||||
): ReturnType<typeof useServers> => ({
|
||||
servers: [
|
||||
{
|
||||
id: "A",
|
||||
name: "PlotRocket",
|
||||
config: { type: "streamable-http", url: "https://api.example.com/mcp" },
|
||||
connection: { status: "disconnected" },
|
||||
},
|
||||
],
|
||||
loading: false,
|
||||
error: undefined,
|
||||
refresh: vi.fn().mockResolvedValue(undefined),
|
||||
addServer: vi.fn().mockResolvedValue(undefined),
|
||||
updateServer: vi.fn().mockResolvedValue(undefined),
|
||||
updateServerSettings: vi.fn().mockResolvedValue(undefined),
|
||||
removeServer: vi.fn().mockResolvedValue(undefined),
|
||||
reorderServers,
|
||||
importSource: vi.fn().mockResolvedValue({ servers: {} }),
|
||||
});
|
||||
|
||||
it("does not leak an unhandled rejection when an all-pages Refresh fails", async () => {
|
||||
vi.mocked(useManagedTools).mockReturnValue({
|
||||
tools: [],
|
||||
|
|
@ -3919,6 +3915,156 @@ describe("App background command rejections (#2049)", () => {
|
|||
rejections.stop();
|
||||
}
|
||||
});
|
||||
|
||||
// `onToggleConnection` and `onDisconnect` both close a live session with
|
||||
// `try { await disconnect() } finally { finalizeExplicitDisconnect() }` — a
|
||||
// `finally`, not a `catch` — so a transport that fails to close rejects out
|
||||
// of the handler. App discards neither: both are terminated with a `.catch`
|
||||
// that toasts, because a bare `void` would turn an ordinary click into a
|
||||
// global unhandled rejection with nothing shown to the user (#2130 review).
|
||||
const CLOSE_FAILURE = new Error("close boom");
|
||||
|
||||
it("toasts a failed transport close from the connection toggle instead of leaking it", async () => {
|
||||
const rejections = captureUnhandledRejections();
|
||||
try {
|
||||
const user = await connect();
|
||||
const client = clientInstances[0] as EventTarget & {
|
||||
disconnect: ReturnType<typeof vi.fn>;
|
||||
};
|
||||
client.disconnect.mockRejectedValueOnce(CLOSE_FAILURE);
|
||||
|
||||
// Second click on the same, now-connected server takes the disconnect
|
||||
// branch of the toggle.
|
||||
await user.click(screen.getByText("connect"));
|
||||
|
||||
await waitFor(() =>
|
||||
expect(notificationsMock.show).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
title: "Failed to change the connection",
|
||||
message: "close boom",
|
||||
color: "red",
|
||||
}),
|
||||
),
|
||||
);
|
||||
await settleRejections();
|
||||
expect(rejections.seen).toEqual([]);
|
||||
} finally {
|
||||
rejections.stop();
|
||||
}
|
||||
});
|
||||
|
||||
// The four command handlers await `handleCommandScopedAuthRecovery` from
|
||||
// *inside* their catch blocks, and a rejection thrown from a catch is not
|
||||
// caught by that same catch — so it escapes the handler entirely. That helper
|
||||
// awaits `checkAuthChallengeSatisfied`, which reaches the backend and can
|
||||
// reject. Before #2130's review this escaped a bare `void` as a global
|
||||
// unhandled rejection with nothing shown to the user.
|
||||
it("toasts an auth-recovery failure that escapes a tool call instead of leaking it", async () => {
|
||||
const rejections = captureUnhandledRejections();
|
||||
try {
|
||||
const user = await connect();
|
||||
const client = clientInstances[0] as EventTarget & {
|
||||
callTool: ReturnType<typeof vi.fn>;
|
||||
};
|
||||
client.callTool.mockRejectedValueOnce(
|
||||
new AuthRecoveryRequiredError(
|
||||
new URL("https://as.example.com/authorize"),
|
||||
{ reason: "unauthorized" },
|
||||
),
|
||||
);
|
||||
// The recovery helper's first await is the one that breaks.
|
||||
rejectNextChallengeCheck(new Error("challenge check failed"));
|
||||
|
||||
await user.click(screen.getByText("call"));
|
||||
|
||||
await waitFor(() =>
|
||||
expect(notificationsMock.show).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
title: "Tool call failed",
|
||||
message: "challenge check failed",
|
||||
color: "red",
|
||||
}),
|
||||
),
|
||||
);
|
||||
await settleRejections();
|
||||
expect(rejections.seen).toEqual([]);
|
||||
} finally {
|
||||
rejections.stop();
|
||||
}
|
||||
});
|
||||
|
||||
// The reorder handler was lifted out of the JSX into a named callback with a
|
||||
// `.catch` that toasts (#2130). `reorderServers` reverts its own optimistic
|
||||
// ordering and re-throws — on a 409 from a racing external edit, or a network
|
||||
// error — so without the toast the drag just silently bounces back.
|
||||
it("forwards a reorder to the server list", async () => {
|
||||
const reorderServers = vi.fn().mockResolvedValue(undefined);
|
||||
vi.mocked(useServers).mockReturnValue(serversWithReorder(reorderServers));
|
||||
const user = userEvent.setup();
|
||||
renderWithMantine(<App />);
|
||||
|
||||
await user.click(screen.getByText("reorder-servers"));
|
||||
|
||||
await waitFor(() =>
|
||||
expect(reorderServers).toHaveBeenCalledWith(["B", "A"]),
|
||||
);
|
||||
expect(notificationsMock.show).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("toasts a failed reorder instead of leaking it", async () => {
|
||||
const reorderServers = vi
|
||||
.fn()
|
||||
.mockRejectedValue(new Error("stale server list"));
|
||||
vi.mocked(useServers).mockReturnValue(serversWithReorder(reorderServers));
|
||||
const rejections = captureUnhandledRejections();
|
||||
try {
|
||||
const user = userEvent.setup();
|
||||
renderWithMantine(<App />);
|
||||
|
||||
await user.click(screen.getByText("reorder-servers"));
|
||||
|
||||
await waitFor(() =>
|
||||
expect(notificationsMock.show).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
title: "Failed to reorder servers",
|
||||
message: "stale server list",
|
||||
color: "red",
|
||||
}),
|
||||
),
|
||||
);
|
||||
await settleRejections();
|
||||
expect(rejections.seen).toEqual([]);
|
||||
} finally {
|
||||
rejections.stop();
|
||||
}
|
||||
});
|
||||
|
||||
it("toasts a failed transport close from the explicit Disconnect instead of leaking it", async () => {
|
||||
const rejections = captureUnhandledRejections();
|
||||
try {
|
||||
const user = await connect();
|
||||
const client = clientInstances[0] as EventTarget & {
|
||||
disconnect: ReturnType<typeof vi.fn>;
|
||||
};
|
||||
client.disconnect.mockRejectedValueOnce(CLOSE_FAILURE);
|
||||
|
||||
await user.click(screen.getByText("disconnect"));
|
||||
|
||||
await waitFor(() =>
|
||||
expect(notificationsMock.show).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
title: "Failed to disconnect",
|
||||
message: "close boom",
|
||||
color: "red",
|
||||
}),
|
||||
),
|
||||
);
|
||||
await settleRejections();
|
||||
expect(rejections.seen).toEqual([]);
|
||||
} finally {
|
||||
rejections.stop();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
/** A complete `useManagedResources` return, so the mock stays type-checked against the hook's contract. */
|
||||
|
|
|
|||
|
|
@ -64,6 +64,20 @@ import { useInitialConfig } from "@inspector/core/react/useInitialConfig.js";
|
|||
import { refreshingPersist } from "./lib/refreshingPersist";
|
||||
import { usePendingClientRequests } from "@inspector/core/react/usePendingClientRequests.js";
|
||||
import { InspectorView } from "./components/views/InspectorView/InspectorView";
|
||||
import type {
|
||||
AppsPanelProps,
|
||||
ConnectionProps,
|
||||
ConsolePanelProps,
|
||||
LogsPanelProps,
|
||||
NetworkPanelProps,
|
||||
PromptsPanelProps,
|
||||
ProtocolPanelProps,
|
||||
ResourcesPanelProps,
|
||||
ServerListProps,
|
||||
ShellProps,
|
||||
TasksPanelProps,
|
||||
ToolsPanelProps,
|
||||
} from "./components/views/InspectorView/types";
|
||||
import type { ReadResourceState } from "./components/screens/ResourcesScreen/ResourcesScreen";
|
||||
import { AppElicitationHost } from "./components/elements/AppElicitation/AppElicitationHost";
|
||||
import type { LogEntryData } from "./components/elements/LogEntry/LogEntry";
|
||||
|
|
@ -92,6 +106,7 @@ import {
|
|||
type PendingClientRequestContent,
|
||||
} from "./components/groups/PendingClientRequestModal/PendingClientRequestModal";
|
||||
import { downloadJsonFile } from "./lib/downloadFile";
|
||||
import { serverWithDraftSettings } from "./utils/serverWithDraftSettings";
|
||||
import { enrichProtocolEntries } from "./utils/correlateTransportErrors";
|
||||
import { visibleMalformedListItems } from "./utils/malformedListReport";
|
||||
import { parseDeepLink, deepLinkParseStatus } from "./utils/deepLink";
|
||||
|
|
@ -108,6 +123,21 @@ import { FetchBodyDroppedToastMessage } from "./components/elements/Toasts/Fetch
|
|||
import { OutputValidationToastMessage } from "./components/elements/Toasts/OutputValidationToastMessage";
|
||||
import { ReAuthBannerBar } from "./components/groups/ReAuthBanner/ReAuthBannerBar";
|
||||
|
||||
/**
|
||||
* Terminates a dispatched handler's promise by reporting the failure, for the
|
||||
* `InspectorView` action wrappers below. Module scope because it closes over
|
||||
* nothing — the title is the only thing that varies per action.
|
||||
*/
|
||||
function reportDispatchFailure(title: string) {
|
||||
return (err: unknown) => {
|
||||
notifications.show({
|
||||
title,
|
||||
message: err instanceof Error ? err.message : String(err),
|
||||
color: "red",
|
||||
});
|
||||
};
|
||||
}
|
||||
|
||||
function App() {
|
||||
const { onToggleTheme } = useThemeToggle();
|
||||
|
||||
|
|
@ -1319,15 +1349,63 @@ function App() {
|
|||
// target isn't resolvable.
|
||||
const settingsModalIsStdio = settingsModalServerType === "stdio";
|
||||
|
||||
/**
|
||||
* Servers whose clear is in flight (#2144). Keyed by id, not a single flag:
|
||||
* the callback explicitly supports clearing a server other than the active
|
||||
* one, so a global lock would silently drop B's click while A's revocation
|
||||
* was still out. See `runClear`.
|
||||
*/
|
||||
const clearOAuthInFlightRef = useRef<Set<string>>(new Set());
|
||||
|
||||
/**
|
||||
* Run a clear from a key/click handler, which cannot await.
|
||||
*
|
||||
* `clearServerOAuthAndDisconnect` does **not** own its failures — the store
|
||||
* write or the disconnect can reject — so a bare `void` would produce an
|
||||
* unhandled rejection and leave the user with a control that silently did
|
||||
* nothing. (An RFC 7009 failure is not this: those come back as an outcome
|
||||
* and are already reported in the success toast.)
|
||||
*/
|
||||
const runClear = useCallback(
|
||||
(server: Parameters<typeof clearServerOAuthAndDisconnect>[0]) => {
|
||||
// Shared by BOTH clear controls (Connection Info and Server Settings),
|
||||
// because for one server they drive the same client and the same store
|
||||
// entry. A ref rather than state: a double click delivers both events
|
||||
// before React re-renders, so a state-based guard would let both through
|
||||
// — and with revocation taking up to five seconds, that means concurrent
|
||||
// RFC 7009 requests, concurrent store writes, and two contradictory
|
||||
// toasts. Keyed by server so a *different* server's clear is unaffected.
|
||||
if (clearOAuthInFlightRef.current.has(server.id)) return;
|
||||
clearOAuthInFlightRef.current.add(server.id);
|
||||
clearServerOAuthAndDisconnect(server)
|
||||
.finally(() => {
|
||||
clearOAuthInFlightRef.current.delete(server.id);
|
||||
})
|
||||
.catch((err: unknown) => {
|
||||
notifications.show({
|
||||
title: "Could not clear the stored OAuth state",
|
||||
message: err instanceof Error ? err.message : String(err),
|
||||
color: "red",
|
||||
// The tokens may still be on disk and the session may still be up,
|
||||
// so this is not a notice to let time out.
|
||||
autoClose: false,
|
||||
});
|
||||
});
|
||||
},
|
||||
[clearServerOAuthAndDisconnect],
|
||||
);
|
||||
|
||||
const handleClearConnectionOAuth = useCallback(() => {
|
||||
if (!activeServer) return;
|
||||
void clearServerOAuthAndDisconnect(activeServer);
|
||||
}, [activeServer, clearServerOAuthAndDisconnect]);
|
||||
runClear(activeServer);
|
||||
}, [activeServer, runClear]);
|
||||
|
||||
const handleClearStoredOAuthFromSettings = useCallback(() => {
|
||||
if (!settingsModalTarget) return;
|
||||
void clearServerOAuthAndDisconnect(settingsModalTarget);
|
||||
}, [settingsModalTarget, clearServerOAuthAndDisconnect]);
|
||||
// Clear from *inside* the settings modal, so the draft is what the user is
|
||||
// looking at rather than what the debounced save has persisted (#2144).
|
||||
runClear(serverWithDraftSettings(settingsModalTarget, settingsDraft));
|
||||
}, [settingsModalTarget, settingsDraft, runClear]);
|
||||
|
||||
const onSettingsModalClose = useCallback(() => {
|
||||
flushSettingsDraft();
|
||||
|
|
@ -1469,6 +1547,304 @@ function App() {
|
|||
[pendingElicitations, inspectorClient],
|
||||
);
|
||||
|
||||
// --- InspectorView prop bundles (#2130) -----------------------------------
|
||||
//
|
||||
// The view takes one prop per domain, so the JSX below reads as a component
|
||||
// tree rather than a wall of ~130 props. Everything the bundles need is
|
||||
// assembled here: the handlers that used to be multi-line closures declared
|
||||
// inline in the JSX are named callbacks, and the `void`-discarding wrappers
|
||||
// carry their justification once rather than at a call site.
|
||||
|
||||
const onServerAdd = useCallback(() => {
|
||||
setHighlightedServerIds([]);
|
||||
setConfigModal({ mode: "add" });
|
||||
}, []);
|
||||
|
||||
const onServerImportConfig = useCallback(() => {
|
||||
setHighlightedServerIds([]);
|
||||
setImportConfigOpen(true);
|
||||
}, []);
|
||||
|
||||
const onServerImportJson = useCallback(() => {
|
||||
setHighlightedServerIds([]);
|
||||
setImportJsonOpen(true);
|
||||
}, []);
|
||||
|
||||
const onServerEdit = useCallback((id: string) => {
|
||||
setConfigModal({ mode: "edit", targetId: id });
|
||||
}, []);
|
||||
|
||||
const onServerClone = useCallback((id: string) => {
|
||||
setHighlightedServerIds([]);
|
||||
setConfigModal({ mode: "clone", targetId: id });
|
||||
}, []);
|
||||
|
||||
const onServerRemove = useCallback(
|
||||
(id: string) => {
|
||||
const target = servers.find((s) => s.id === id);
|
||||
if (target) setRemoveTarget(target);
|
||||
},
|
||||
[servers],
|
||||
);
|
||||
|
||||
const onServerReorderFromList = useCallback(
|
||||
(orderedIds: string[]) => {
|
||||
// reorderServers reverts the optimistic order via an internal refresh()
|
||||
// and re-throws on failure (409 from a racing external edit, or a network
|
||||
// error). Surface that to the user so the drag doesn't silently bounce
|
||||
// back — matching the toast pattern every other mutation here uses.
|
||||
reorderServers(orderedIds).catch((err: unknown) => {
|
||||
notifications.show({
|
||||
title: "Failed to reorder servers",
|
||||
message: err instanceof Error ? err.message : String(err),
|
||||
color: "red",
|
||||
});
|
||||
});
|
||||
},
|
||||
[reorderServers],
|
||||
);
|
||||
|
||||
const openClientSettings = useCallback(() => setClientSettingsOpen(true), []);
|
||||
const openConnectionInfo = useCallback(
|
||||
() => setConnectionInfoModalOpen(true),
|
||||
[],
|
||||
);
|
||||
const openServerSettings = useCallback(
|
||||
(id: string) => setSettingsModalTargetId(id),
|
||||
[],
|
||||
);
|
||||
|
||||
// Every `InspectorView` action prop is synchronous while the handler behind
|
||||
// it is async, so each wrapper below has to terminate its promise somehow.
|
||||
// They all do it the same way: a reporting `.catch`, never a bare `void`.
|
||||
//
|
||||
// The uniformity is the point. Deciding per handler whether it "owns its
|
||||
// failures" is a judgement this PR got wrong twice (#2130 review) — the
|
||||
// handlers really do catch their ordinary errors, but a rejection can still
|
||||
// escape by a route the reading misses:
|
||||
//
|
||||
// - `onToggleConnection` awaits `initialConfigSettledRef` and constructs
|
||||
// the client via `setupClientForServer` outside its own try/catch, and
|
||||
// its already-connected branch is a `try/finally` that lets a failed
|
||||
// transport close propagate. `onDisconnect` is that `try/finally` alone.
|
||||
// - `onCallTool` / `onGetPrompt` / `onReadResource` / `onOpenApp` await
|
||||
// `handleCommandScopedAuthRecovery` from *inside* their catch blocks, and
|
||||
// a rejection thrown from a catch is not caught by that same catch. That
|
||||
// helper awaits `checkAuthChallengeSatisfied` and `pushRemoteAuthState`,
|
||||
// both of which reach the backend and can reject.
|
||||
//
|
||||
// A handler that does surface its own failure resolves, so it never reaches
|
||||
// this `.catch` and there is no double-toast; what lands here is only the
|
||||
// escape, which would otherwise be a global unhandled rejection with nothing
|
||||
// shown to the user.
|
||||
const dispatchToggleConnection = useCallback(
|
||||
(id: string) => {
|
||||
onToggleConnection(id).catch(
|
||||
reportDispatchFailure("Failed to change the connection"),
|
||||
);
|
||||
},
|
||||
[onToggleConnection],
|
||||
);
|
||||
const dispatchDisconnect = useCallback(() => {
|
||||
onDisconnect().catch(reportDispatchFailure("Failed to disconnect"));
|
||||
}, [onDisconnect]);
|
||||
const dispatchCallTool = useCallback(
|
||||
(name: string, args: Record<string, unknown>, runAsTask?: boolean) => {
|
||||
onCallTool(name, args, runAsTask).catch(
|
||||
reportDispatchFailure("Tool call failed"),
|
||||
);
|
||||
},
|
||||
[onCallTool],
|
||||
);
|
||||
const dispatchGetPrompt = useCallback(
|
||||
(name: string, args: Record<string, string>) => {
|
||||
onGetPrompt(name, args).catch(
|
||||
reportDispatchFailure("Failed to get prompt"),
|
||||
);
|
||||
},
|
||||
[onGetPrompt],
|
||||
);
|
||||
const dispatchReadResource = useCallback(
|
||||
(uri: string) => {
|
||||
onReadResource(uri).catch(
|
||||
reportDispatchFailure("Failed to read resource"),
|
||||
);
|
||||
},
|
||||
[onReadResource],
|
||||
);
|
||||
const dispatchCancelTask = useCallback(
|
||||
(taskId: string) => {
|
||||
onCancelTask(taskId).catch(
|
||||
reportDispatchFailure("Failed to cancel task"),
|
||||
);
|
||||
},
|
||||
[onCancelTask],
|
||||
);
|
||||
const dispatchOpenApp = useCallback(
|
||||
(name: string, args: Record<string, unknown>) => {
|
||||
onOpenApp(name, args).catch(reportDispatchFailure("Failed to open app"));
|
||||
},
|
||||
[onOpenApp],
|
||||
);
|
||||
|
||||
const shellProps: ShellProps = {
|
||||
deepLink,
|
||||
deepLinkStatus,
|
||||
version: inspectorVersion,
|
||||
malformedListItems: shownMalformedListItems,
|
||||
activeTab,
|
||||
onActiveTabChange: setActiveTab,
|
||||
onToggleTheme,
|
||||
onOpenClientSettings: openClientSettings,
|
||||
};
|
||||
|
||||
const connectionProps: ConnectionProps = {
|
||||
activeServer: activeServerId,
|
||||
erroredServerId: failedServerId,
|
||||
connectedServerId,
|
||||
connectionStatus,
|
||||
connectErrorMessage,
|
||||
initializeResult,
|
||||
latencyMs,
|
||||
protocolEra,
|
||||
onCompleteArgument,
|
||||
completionsSupported: capabilities?.completions !== undefined,
|
||||
onToggleConnection: dispatchToggleConnection,
|
||||
onDisconnect: dispatchDisconnect,
|
||||
};
|
||||
|
||||
const serverListProps: ServerListProps = {
|
||||
servers,
|
||||
serverListWritable,
|
||||
highlightedServerIds,
|
||||
onClearHighlight: clearHighlight,
|
||||
onServerAdd,
|
||||
onServerImportConfig,
|
||||
onServerImportJson,
|
||||
onServerExport,
|
||||
onConnectionInfo: openConnectionInfo,
|
||||
onServerSettings: openServerSettings,
|
||||
onServerEdit,
|
||||
onServerClone,
|
||||
onServerRemove,
|
||||
onServerReorder: onServerReorderFromList,
|
||||
};
|
||||
|
||||
const toolsPanelProps: ToolsPanelProps = {
|
||||
tools,
|
||||
excludedTools,
|
||||
toolsListChanged,
|
||||
toolsLoadError: toolsPagination.error,
|
||||
toolsUi: ui.toolsUi,
|
||||
toolCallState,
|
||||
toolsPagination: toolsPaginationControls,
|
||||
serverSupportsTaskToolCalls:
|
||||
!!capabilities?.tasks?.requests?.tools?.call ||
|
||||
(inspectorClient?.isTasksExtensionNegotiated() ?? false),
|
||||
onToolsUiChange,
|
||||
onCallTool: dispatchCallTool,
|
||||
onCancelToolCall,
|
||||
onClearToolResult,
|
||||
onRefreshTools,
|
||||
onReadResourceContents,
|
||||
};
|
||||
|
||||
const promptsPanelProps: PromptsPanelProps = {
|
||||
prompts,
|
||||
promptsListChanged,
|
||||
promptsLoadError: promptsPagination.error,
|
||||
promptsUi: ui.promptsUi,
|
||||
getPromptState,
|
||||
promptsPagination: promptsPaginationControls,
|
||||
onPromptsUiChange: setUi.setPromptsUi,
|
||||
onGetPrompt: dispatchGetPrompt,
|
||||
onRefreshPrompts,
|
||||
};
|
||||
|
||||
const resourcesPanelProps: ResourcesPanelProps = {
|
||||
resources,
|
||||
resourceTemplates,
|
||||
subscriptions,
|
||||
subscriptionStreamState,
|
||||
subscriptionsSupported: capabilities?.resources?.subscribe === true,
|
||||
resourcesListChanged,
|
||||
resourcesLoadError: resourcesPagination.error ?? resourceTemplatesLoadError,
|
||||
resourcesUi: ui.resourcesUi,
|
||||
readResourceState: effectiveReadResourceState,
|
||||
resourcesPagination: resourcesPaginationControls,
|
||||
onResourcesUiChange: setUi.setResourcesUi,
|
||||
onReadResource: dispatchReadResource,
|
||||
onSubscribeResource,
|
||||
onUnsubscribeResource,
|
||||
onRefreshResources,
|
||||
};
|
||||
|
||||
const appsPanelProps: AppsPanelProps = {
|
||||
appsUi: ui.appsUi,
|
||||
sandboxPath: sandboxUrl,
|
||||
bridgeFactory: sandboxBridgeFactory,
|
||||
appRendererRef,
|
||||
onAppsUiChange: setUi.setAppsUi,
|
||||
onSelectApp,
|
||||
onOpenApp: dispatchOpenApp,
|
||||
onCloseApp,
|
||||
onAppError,
|
||||
// The Apps tab is a filtered view of the tools list, so refreshing it is
|
||||
// refreshing tools.
|
||||
onRefreshApps: onRefreshTools,
|
||||
};
|
||||
|
||||
const tasksPanelProps: TasksPanelProps = {
|
||||
tasks,
|
||||
progressByTaskId,
|
||||
tasksUi: ui.tasksUi,
|
||||
onTasksUiChange: setUi.setTasksUi,
|
||||
onCancelTask: dispatchCancelTask,
|
||||
onClearCompletedTasks,
|
||||
onRefreshTasks,
|
||||
};
|
||||
|
||||
const logsPanelProps: LogsPanelProps = {
|
||||
logs,
|
||||
logsUi: ui.logsUi,
|
||||
currentLogLevel,
|
||||
modernLogLevel,
|
||||
onSetLogLevel,
|
||||
onSetModernLogLevel,
|
||||
onLogsUiChange: setUi.setLogsUi,
|
||||
onClearLogs,
|
||||
onExportLogs,
|
||||
};
|
||||
|
||||
const protocolPanelProps: ProtocolPanelProps = {
|
||||
protocol: protocolEntries,
|
||||
protocolUi: ui.protocolUi,
|
||||
pinnedProtocolIds,
|
||||
onProtocolUiChange: setUi.setProtocolUi,
|
||||
onClearProtocol,
|
||||
onExportProtocol,
|
||||
onClearProtocolSection,
|
||||
onExportProtocolSection,
|
||||
onReplayProtocol,
|
||||
onTogglePinProtocol: togglePinProtocol,
|
||||
};
|
||||
|
||||
const networkPanelProps: NetworkPanelProps = {
|
||||
network: fetchRequests,
|
||||
networkUi: ui.networkUi,
|
||||
onNetworkUiChange: setUi.setNetworkUi,
|
||||
onClearNetwork,
|
||||
onExportNetwork,
|
||||
};
|
||||
|
||||
const consolePanelProps: ConsolePanelProps = {
|
||||
stderrLogs,
|
||||
consoleUi: ui.consoleUi,
|
||||
onConsoleUiChange: setUi.setConsoleUi,
|
||||
onClearConsole,
|
||||
onExportConsole,
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<Box>
|
||||
|
|
@ -1484,171 +1860,18 @@ function App() {
|
|||
</ReAuthBannerBar>
|
||||
) : null}
|
||||
<InspectorView
|
||||
deepLink={deepLink}
|
||||
deepLinkStatus={deepLinkStatus}
|
||||
servers={servers}
|
||||
serverListWritable={serverListWritable}
|
||||
activeServer={activeServerId}
|
||||
erroredServerId={failedServerId}
|
||||
connectedServerId={connectedServerId}
|
||||
version={inspectorVersion}
|
||||
connectionStatus={connectionStatus}
|
||||
connectErrorMessage={connectErrorMessage}
|
||||
initializeResult={initializeResult}
|
||||
latencyMs={latencyMs}
|
||||
tools={tools}
|
||||
excludedTools={excludedTools}
|
||||
malformedListItems={shownMalformedListItems}
|
||||
prompts={prompts}
|
||||
resources={resources}
|
||||
resourceTemplates={resourceTemplates}
|
||||
toolsListChanged={toolsListChanged}
|
||||
promptsListChanged={promptsListChanged}
|
||||
resourcesListChanged={resourcesListChanged}
|
||||
toolsLoadError={toolsPagination.error}
|
||||
promptsLoadError={promptsPagination.error}
|
||||
resourcesLoadError={
|
||||
resourcesPagination.error ?? resourceTemplatesLoadError
|
||||
}
|
||||
subscriptions={subscriptions}
|
||||
subscriptionStreamState={subscriptionStreamState}
|
||||
logs={logs}
|
||||
tasks={tasks}
|
||||
progressByTaskId={progressByTaskId}
|
||||
protocol={protocolEntries}
|
||||
protocolEra={protocolEra}
|
||||
network={fetchRequests}
|
||||
stderrLogs={stderrLogs}
|
||||
toolCallState={toolCallState}
|
||||
getPromptState={getPromptState}
|
||||
readResourceState={effectiveReadResourceState}
|
||||
toolsUi={ui.toolsUi}
|
||||
promptsUi={ui.promptsUi}
|
||||
resourcesUi={ui.resourcesUi}
|
||||
appsUi={ui.appsUi}
|
||||
tasksUi={ui.tasksUi}
|
||||
logsUi={ui.logsUi}
|
||||
protocolUi={ui.protocolUi}
|
||||
networkUi={ui.networkUi}
|
||||
consoleUi={ui.consoleUi}
|
||||
activeTab={activeTab}
|
||||
onActiveTabChange={setActiveTab}
|
||||
currentLogLevel={currentLogLevel}
|
||||
sandboxPath={sandboxUrl}
|
||||
bridgeFactory={sandboxBridgeFactory}
|
||||
appRendererRef={appRendererRef}
|
||||
onToggleTheme={onToggleTheme}
|
||||
onOpenClientSettings={() => setClientSettingsOpen(true)}
|
||||
onToggleConnection={(id) => {
|
||||
void onToggleConnection(id);
|
||||
}}
|
||||
onDisconnect={() => {
|
||||
void onDisconnect();
|
||||
}}
|
||||
onServerAdd={() => {
|
||||
setHighlightedServerIds([]);
|
||||
setConfigModal({ mode: "add" });
|
||||
}}
|
||||
onServerImportConfig={() => {
|
||||
setHighlightedServerIds([]);
|
||||
setImportConfigOpen(true);
|
||||
}}
|
||||
onServerImportJson={() => {
|
||||
setHighlightedServerIds([]);
|
||||
setImportJsonOpen(true);
|
||||
}}
|
||||
onServerExport={onServerExport}
|
||||
onConnectionInfo={() => setConnectionInfoModalOpen(true)}
|
||||
onServerSettings={(id) => setSettingsModalTargetId(id)}
|
||||
onServerEdit={(id) => setConfigModal({ mode: "edit", targetId: id })}
|
||||
onServerClone={(id) => {
|
||||
setHighlightedServerIds([]);
|
||||
setConfigModal({ mode: "clone", targetId: id });
|
||||
}}
|
||||
onServerRemove={(id) => {
|
||||
const target = servers.find((s) => s.id === id);
|
||||
if (target) setRemoveTarget(target);
|
||||
}}
|
||||
onServerReorder={(orderedIds) => {
|
||||
// reorderServers reverts the optimistic order via an internal
|
||||
// refresh() and re-throws on failure (409 from a racing external
|
||||
// edit, or a network error). Surface that to the user so the drag
|
||||
// doesn't silently bounce back — matching the toast pattern every
|
||||
// other mutation here uses.
|
||||
reorderServers(orderedIds).catch((err: unknown) => {
|
||||
notifications.show({
|
||||
title: "Failed to reorder servers",
|
||||
message: err instanceof Error ? err.message : String(err),
|
||||
color: "red",
|
||||
});
|
||||
});
|
||||
}}
|
||||
highlightedServerIds={highlightedServerIds}
|
||||
onClearHighlight={clearHighlight}
|
||||
serverSupportsTaskToolCalls={
|
||||
!!capabilities?.tasks?.requests?.tools?.call ||
|
||||
(inspectorClient?.isTasksExtensionNegotiated() ?? false)
|
||||
}
|
||||
onToolsUiChange={onToolsUiChange}
|
||||
onCallTool={(name, args, runAsTask) => {
|
||||
void onCallTool(name, args, runAsTask);
|
||||
}}
|
||||
onCancelToolCall={onCancelToolCall}
|
||||
onClearToolResult={onClearToolResult}
|
||||
onReadResourceContents={onReadResourceContents}
|
||||
onRefreshTools={onRefreshTools}
|
||||
toolsPagination={toolsPaginationControls}
|
||||
promptsPagination={promptsPaginationControls}
|
||||
resourcesPagination={resourcesPaginationControls}
|
||||
onPromptsUiChange={setUi.setPromptsUi}
|
||||
onGetPrompt={(name, args) => {
|
||||
void onGetPrompt(name, args);
|
||||
}}
|
||||
onRefreshPrompts={onRefreshPrompts}
|
||||
onResourcesUiChange={setUi.setResourcesUi}
|
||||
onReadResource={(uri) => {
|
||||
void onReadResource(uri);
|
||||
}}
|
||||
onSubscribeResource={onSubscribeResource}
|
||||
onUnsubscribeResource={onUnsubscribeResource}
|
||||
onRefreshResources={onRefreshResources}
|
||||
onCompleteArgument={onCompleteArgument}
|
||||
completionsSupported={capabilities?.completions !== undefined}
|
||||
subscriptionsSupported={capabilities?.resources?.subscribe === true}
|
||||
onTasksUiChange={setUi.setTasksUi}
|
||||
onCancelTask={(taskId) => {
|
||||
void onCancelTask(taskId);
|
||||
}}
|
||||
onClearCompletedTasks={onClearCompletedTasks}
|
||||
onRefreshTasks={onRefreshTasks}
|
||||
onSetLogLevel={onSetLogLevel}
|
||||
modernLogLevel={modernLogLevel}
|
||||
onSetModernLogLevel={onSetModernLogLevel}
|
||||
onLogsUiChange={setUi.setLogsUi}
|
||||
onClearLogs={onClearLogs}
|
||||
onExportLogs={onExportLogs}
|
||||
onProtocolUiChange={setUi.setProtocolUi}
|
||||
onClearProtocol={onClearProtocol}
|
||||
onExportProtocol={onExportProtocol}
|
||||
onClearProtocolSection={onClearProtocolSection}
|
||||
onExportProtocolSection={onExportProtocolSection}
|
||||
onReplayProtocol={onReplayProtocol}
|
||||
onTogglePinProtocol={togglePinProtocol}
|
||||
pinnedProtocolIds={pinnedProtocolIds}
|
||||
onNetworkUiChange={setUi.setNetworkUi}
|
||||
onClearNetwork={onClearNetwork}
|
||||
onExportNetwork={onExportNetwork}
|
||||
onConsoleUiChange={setUi.setConsoleUi}
|
||||
onClearConsole={onClearConsole}
|
||||
onExportConsole={onExportConsole}
|
||||
onAppsUiChange={setUi.setAppsUi}
|
||||
onSelectApp={onSelectApp}
|
||||
onOpenApp={(name, args) => {
|
||||
void onOpenApp(name, args);
|
||||
}}
|
||||
onCloseApp={onCloseApp}
|
||||
onAppError={onAppError}
|
||||
onRefreshApps={onRefreshTools}
|
||||
shell={shellProps}
|
||||
connection={connectionProps}
|
||||
servers={serverListProps}
|
||||
tools={toolsPanelProps}
|
||||
prompts={promptsPanelProps}
|
||||
resources={resourcesPanelProps}
|
||||
apps={appsPanelProps}
|
||||
tasks={tasksPanelProps}
|
||||
logs={logsPanelProps}
|
||||
protocol={protocolPanelProps}
|
||||
network={networkPanelProps}
|
||||
console={consolePanelProps}
|
||||
/>
|
||||
</Box>
|
||||
<AppElicitationHost
|
||||
|
|
|
|||
|
|
@ -707,6 +707,7 @@ describe("ServerSettingsForm", () => {
|
|||
tokenUrl: "",
|
||||
enterpriseManaged: false,
|
||||
requestRefreshToken: true,
|
||||
revokeOnClear: true,
|
||||
});
|
||||
});
|
||||
|
||||
|
|
@ -751,6 +752,47 @@ describe("ServerSettingsForm", () => {
|
|||
);
|
||||
});
|
||||
|
||||
// #2144 — the RFC 7009 opt-out. On by default: leaving it off silently is
|
||||
// what leaves the authorization server holding a live grant.
|
||||
it("renders Revoke tokens on clear checked by default", () => {
|
||||
renderWithMantine(
|
||||
<ServerSettingsForm
|
||||
{...baseHandlers}
|
||||
settings={emptySettings}
|
||||
expandedSections={["oauth"]}
|
||||
/>,
|
||||
);
|
||||
expect(screen.getByLabelText("Revoke tokens on clear")).toBeChecked();
|
||||
});
|
||||
|
||||
it("renders Revoke tokens on clear unchecked when the server opted out", () => {
|
||||
renderWithMantine(
|
||||
<ServerSettingsForm
|
||||
{...baseHandlers}
|
||||
settings={{ ...emptySettings, oauthRevokeOnClear: false }}
|
||||
expandedSections={["oauth"]}
|
||||
/>,
|
||||
);
|
||||
expect(screen.getByLabelText("Revoke tokens on clear")).not.toBeChecked();
|
||||
});
|
||||
|
||||
it("toggles Revoke tokens on clear through onOAuthChange", async () => {
|
||||
const user = userEvent.setup();
|
||||
const onOAuthChange = vi.fn();
|
||||
renderWithMantine(
|
||||
<ServerSettingsForm
|
||||
{...baseHandlers}
|
||||
onOAuthChange={onOAuthChange}
|
||||
settings={emptySettings}
|
||||
expandedSections={["oauth"]}
|
||||
/>,
|
||||
);
|
||||
await user.click(screen.getByLabelText("Revoke tokens on clear"));
|
||||
expect(onOAuthChange).toHaveBeenLastCalledWith(
|
||||
expect.objectContaining({ revokeOnClear: false }),
|
||||
);
|
||||
});
|
||||
|
||||
it("warns when opting out while offline_access is still in Scopes", () => {
|
||||
renderWithMantine(
|
||||
<ServerSettingsForm
|
||||
|
|
@ -1255,6 +1297,7 @@ describe("ServerSettingsForm", () => {
|
|||
tokenUrl: "",
|
||||
enterpriseManaged: true,
|
||||
requestRefreshToken: true,
|
||||
revokeOnClear: true,
|
||||
});
|
||||
});
|
||||
|
||||
|
|
@ -1363,6 +1406,7 @@ describe("ServerSettingsForm", () => {
|
|||
tokenUrl: "",
|
||||
enterpriseManaged: false,
|
||||
requestRefreshToken: true,
|
||||
revokeOnClear: true,
|
||||
});
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -521,6 +521,8 @@ export function ServerSettingsForm({
|
|||
onInsufficientScope: settings.oauthOnInsufficientScope,
|
||||
// Unset means the default, on — only an explicit opt-out is stored.
|
||||
requestRefreshToken: settings.oauthRequestRefreshToken ?? true,
|
||||
// Unset means the default, on — only an explicit opt-out is stored.
|
||||
revokeOnClear: settings.oauthRevokeOnClear ?? true,
|
||||
};
|
||||
}
|
||||
|
||||
|
|
@ -570,6 +572,15 @@ export function ServerSettingsForm({
|
|||
});
|
||||
}
|
||||
|
||||
function handleRevokeOnClearChange(
|
||||
event: ChangeEvent<HTMLInputElement>,
|
||||
): void {
|
||||
onOAuthChange({
|
||||
...currentOAuth(),
|
||||
revokeOnClear: event.currentTarget.checked,
|
||||
});
|
||||
}
|
||||
|
||||
function handleAddAuthorizationParam(): void {
|
||||
changeAuthorizationParams([...authorizationParams, { key: "", value: "" }]);
|
||||
}
|
||||
|
|
@ -917,6 +928,12 @@ export function ServerSettingsForm({
|
|||
checked={settings.oauthRequestRefreshToken ?? true}
|
||||
onChange={handleRequestRefreshTokenChange}
|
||||
/>
|
||||
<Checkbox
|
||||
label="Revoke tokens on clear"
|
||||
description="Calls the authorization server's RFC 7009 revocation endpoint when the stored OAuth state is cleared, so the grant ends when the session does instead of staying valid until it expires. Servers that advertise no revocation_endpoint are unaffected. Uncheck it to reproduce a client that disconnects still holding live tokens."
|
||||
checked={settings.oauthRevokeOnClear ?? true}
|
||||
onChange={handleRevokeOnClearChange}
|
||||
/>
|
||||
{refreshTokenOptedOut && scopesIncludeOfflineAccess ? (
|
||||
<Alert color="yellow" title="offline_access is still requested">
|
||||
The Scopes field above lists offline_access, and
|
||||
|
|
|
|||
|
|
@ -156,6 +156,42 @@ describe("ServerSettingsModal", () => {
|
|||
);
|
||||
});
|
||||
|
||||
// #2144 — same omit-the-default shape as the refresh-token pair above.
|
||||
it("maps the revoke-on-clear opt-out into settings, and back to unset", async () => {
|
||||
const user = userEvent.setup();
|
||||
const onSettingsChange = vi.fn();
|
||||
const { rerender } = renderWithMantine(
|
||||
<ServerSettingsModal
|
||||
opened
|
||||
settings={initialSettings}
|
||||
serverType="streamable-http"
|
||||
isStdio={false}
|
||||
onClose={vi.fn()}
|
||||
onSettingsChange={onSettingsChange}
|
||||
/>,
|
||||
);
|
||||
await user.click(screen.getByRole("button", { name: /OAuth Settings/i }));
|
||||
await user.click(screen.getByLabelText("Revoke tokens on clear"));
|
||||
expect(onSettingsChange).toHaveBeenLastCalledWith(
|
||||
expect.objectContaining({ oauthRevokeOnClear: false }),
|
||||
);
|
||||
|
||||
rerender(
|
||||
<ServerSettingsModal
|
||||
opened
|
||||
settings={{ ...initialSettings, oauthRevokeOnClear: false }}
|
||||
serverType="streamable-http"
|
||||
isStdio={false}
|
||||
onClose={vi.fn()}
|
||||
onSettingsChange={onSettingsChange}
|
||||
/>,
|
||||
);
|
||||
await user.click(screen.getByLabelText("Revoke tokens on clear"));
|
||||
expect(onSettingsChange).toHaveBeenLastCalledWith(
|
||||
expect.objectContaining({ oauthRevokeOnClear: undefined }),
|
||||
);
|
||||
});
|
||||
|
||||
it("maps the selected protocol era into settings (#1626)", async () => {
|
||||
const user = userEvent.setup();
|
||||
const onSettingsChange = vi.fn();
|
||||
|
|
|
|||
|
|
@ -191,6 +191,8 @@ export function ServerSettingsModal({
|
|||
// server that never touched the switch writes no field at all.
|
||||
oauthRequestRefreshToken:
|
||||
oauth.requestRefreshToken === false ? false : undefined,
|
||||
// #2144: same shape — `undefined` means on, so only the opt-out persists.
|
||||
oauthRevokeOnClear: oauth.revokeOnClear === false ? false : undefined,
|
||||
});
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -18,6 +18,20 @@ import type { Meta, StoryObj } from "@storybook/react-vite";
|
|||
import { useState } from "react";
|
||||
import { expect, fn } from "storybook/test";
|
||||
import { InspectorView } from "./InspectorView";
|
||||
import type {
|
||||
AppsPanelProps,
|
||||
ConnectionProps,
|
||||
ConsolePanelProps,
|
||||
LogsPanelProps,
|
||||
NetworkPanelProps,
|
||||
PromptsPanelProps,
|
||||
ProtocolPanelProps,
|
||||
ResourcesPanelProps,
|
||||
ServerListProps,
|
||||
ShellProps,
|
||||
TasksPanelProps,
|
||||
ToolsPanelProps,
|
||||
} from "./types";
|
||||
import { noopPagination } from "../../../test/fixtures/pagination";
|
||||
import {
|
||||
EMPTY_TOOLS_UI,
|
||||
|
|
@ -324,116 +338,164 @@ const demoInitializeResult: InitializeResult = {
|
|||
serverInfo: { name: "Local Dev Server", version: "1.2.0" },
|
||||
};
|
||||
|
||||
// One constant per domain bundle, so a story can override a single field
|
||||
// without replacing its whole bundle — Storybook merges args only at the top
|
||||
// level, and each of these IS one top-level arg now (#2130).
|
||||
const shellArgs: ShellProps = {
|
||||
activeTab: "Servers",
|
||||
onActiveTabChange: fn(),
|
||||
onToggleTheme: fn(),
|
||||
onOpenClientSettings: fn(),
|
||||
};
|
||||
|
||||
// Stories default to "disconnected"; per-story overrides drive the connected /
|
||||
// error narratives.
|
||||
const connectionArgs: ConnectionProps = {
|
||||
activeServer: undefined,
|
||||
connectionStatus: "disconnected",
|
||||
initializeResult: undefined,
|
||||
latencyMs: undefined,
|
||||
onToggleConnection: fn(),
|
||||
onDisconnect: fn(),
|
||||
};
|
||||
|
||||
// Callbacks are wired to storybook spies so play functions can assert on
|
||||
// dispatch. Real wiring routes these to InspectorClient methods (the app shell
|
||||
// at clients/web/src/App.tsx).
|
||||
const serversArgs: ServerListProps = {
|
||||
servers: demoServers,
|
||||
onServerAdd: fn(),
|
||||
onServerImportConfig: fn(),
|
||||
onServerImportJson: fn(),
|
||||
onServerExport: fn(),
|
||||
onConnectionInfo: fn(),
|
||||
onServerSettings: fn(),
|
||||
onServerEdit: fn(),
|
||||
onServerClone: fn(),
|
||||
onServerRemove: fn(),
|
||||
onServerReorder: fn(),
|
||||
};
|
||||
|
||||
const toolsArgs: ToolsPanelProps = {
|
||||
tools: demoTools,
|
||||
toolsListChanged: false,
|
||||
toolsUi: EMPTY_TOOLS_UI,
|
||||
toolsPagination: noopPagination,
|
||||
serverSupportsTaskToolCalls: false,
|
||||
onToolsUiChange: fn(),
|
||||
onCallTool: fn(),
|
||||
onRefreshTools: fn(),
|
||||
};
|
||||
|
||||
const promptsArgs: PromptsPanelProps = {
|
||||
prompts: demoPrompts,
|
||||
promptsListChanged: false,
|
||||
promptsUi: EMPTY_PROMPTS_UI,
|
||||
promptsPagination: noopPagination,
|
||||
onPromptsUiChange: fn(),
|
||||
onGetPrompt: fn(),
|
||||
onRefreshPrompts: fn(),
|
||||
};
|
||||
|
||||
const resourcesArgs: ResourcesPanelProps = {
|
||||
resources: demoResources,
|
||||
resourceTemplates: demoResourceTemplates,
|
||||
subscriptions: demoSubscriptions,
|
||||
resourcesListChanged: false,
|
||||
resourcesUi: EMPTY_RESOURCES_UI,
|
||||
resourcesPagination: noopPagination,
|
||||
onResourcesUiChange: fn(),
|
||||
onReadResource: fn(),
|
||||
onSubscribeResource: fn(),
|
||||
onUnsubscribeResource: fn(),
|
||||
onRefreshResources: fn(),
|
||||
};
|
||||
|
||||
const appsArgs: AppsPanelProps = {
|
||||
appsUi: EMPTY_APPS_UI,
|
||||
sandboxPath: "about:blank",
|
||||
bridgeFactory: noopBridgeFactory,
|
||||
appRendererRef: { current: null },
|
||||
onAppsUiChange: fn(),
|
||||
onSelectApp: fn(),
|
||||
onOpenApp: fn(),
|
||||
onCloseApp: fn(),
|
||||
onAppError: fn(),
|
||||
onRefreshApps: fn(),
|
||||
};
|
||||
|
||||
const tasksArgs: TasksPanelProps = {
|
||||
tasks: demoTasks,
|
||||
progressByTaskId: demoProgressByTaskId,
|
||||
tasksUi: EMPTY_TASKS_UI,
|
||||
onTasksUiChange: fn(),
|
||||
onCancelTask: fn(),
|
||||
onClearCompletedTasks: fn(),
|
||||
onRefreshTasks: fn(),
|
||||
};
|
||||
|
||||
const logsArgs: LogsPanelProps = {
|
||||
logs: demoLogs,
|
||||
logsUi: EMPTY_LOGS_UI,
|
||||
currentLogLevel: "info",
|
||||
onSetLogLevel: fn(),
|
||||
onLogsUiChange: fn(),
|
||||
onClearLogs: fn(),
|
||||
onExportLogs: fn(),
|
||||
};
|
||||
|
||||
const protocolArgs: ProtocolPanelProps = {
|
||||
protocol: demoHistory,
|
||||
protocolUi: EMPTY_PROTOCOL_UI,
|
||||
onProtocolUiChange: fn(),
|
||||
onClearProtocol: fn(),
|
||||
onExportProtocol: fn(),
|
||||
onClearProtocolSection: fn(),
|
||||
onExportProtocolSection: fn(),
|
||||
onReplayProtocol: fn(),
|
||||
onTogglePinProtocol: fn(),
|
||||
};
|
||||
|
||||
const networkArgs: NetworkPanelProps = {
|
||||
network: demoNetwork,
|
||||
networkUi: EMPTY_NETWORK_UI,
|
||||
onNetworkUiChange: fn(),
|
||||
onClearNetwork: fn(),
|
||||
onExportNetwork: fn(),
|
||||
};
|
||||
|
||||
const consoleArgs: ConsolePanelProps = {
|
||||
stderrLogs: demoStderr,
|
||||
consoleUi: EMPTY_CONSOLE_UI,
|
||||
onConsoleUiChange: fn(),
|
||||
onClearConsole: fn(),
|
||||
onExportConsole: fn(),
|
||||
};
|
||||
|
||||
const meta: Meta<typeof InspectorView> = {
|
||||
title: "Views/InspectorView",
|
||||
component: InspectorView,
|
||||
parameters: { layout: "fullscreen" },
|
||||
args: {
|
||||
// Data
|
||||
servers: demoServers,
|
||||
tools: demoTools,
|
||||
prompts: demoPrompts,
|
||||
resources: demoResources,
|
||||
resourceTemplates: demoResourceTemplates,
|
||||
subscriptions: demoSubscriptions,
|
||||
logs: demoLogs,
|
||||
tasks: demoTasks,
|
||||
progressByTaskId: demoProgressByTaskId,
|
||||
protocol: demoHistory,
|
||||
network: demoNetwork,
|
||||
stderrLogs: demoStderr,
|
||||
|
||||
// Connection state — stories default to "disconnected"; per-story
|
||||
// overrides drive the connected / error narratives.
|
||||
activeServer: undefined,
|
||||
connectionStatus: "disconnected",
|
||||
initializeResult: undefined,
|
||||
latencyMs: undefined,
|
||||
|
||||
// Misc state
|
||||
currentLogLevel: "info",
|
||||
sandboxPath: "about:blank",
|
||||
bridgeFactory: noopBridgeFactory,
|
||||
appRendererRef: { current: null },
|
||||
|
||||
// Per-screen UI state (search / filter / selection), one object per screen.
|
||||
toolsUi: EMPTY_TOOLS_UI,
|
||||
promptsUi: EMPTY_PROMPTS_UI,
|
||||
resourcesUi: EMPTY_RESOURCES_UI,
|
||||
appsUi: EMPTY_APPS_UI,
|
||||
tasksUi: EMPTY_TASKS_UI,
|
||||
logsUi: EMPTY_LOGS_UI,
|
||||
protocolUi: EMPTY_PROTOCOL_UI,
|
||||
networkUi: EMPTY_NETWORK_UI,
|
||||
consoleUi: EMPTY_CONSOLE_UI,
|
||||
|
||||
// Callbacks — all wired to storybook spies so play functions can assert
|
||||
// on dispatch. Real wiring routes these to InspectorClient methods (the
|
||||
// app shell at clients/web/src/App.tsx).
|
||||
onToggleTheme: fn(),
|
||||
onOpenClientSettings: fn(),
|
||||
onToggleConnection: fn(),
|
||||
onDisconnect: fn(),
|
||||
onServerAdd: fn(),
|
||||
onServerImportConfig: fn(),
|
||||
onServerImportJson: fn(),
|
||||
onServerExport: fn(),
|
||||
onConnectionInfo: fn(),
|
||||
onServerSettings: fn(),
|
||||
onServerEdit: fn(),
|
||||
onServerClone: fn(),
|
||||
onServerRemove: fn(),
|
||||
onServerReorder: fn(),
|
||||
serverSupportsTaskToolCalls: false,
|
||||
onToolsUiChange: fn(),
|
||||
onCallTool: fn(),
|
||||
onRefreshTools: fn(),
|
||||
toolsPagination: noopPagination,
|
||||
promptsPagination: noopPagination,
|
||||
resourcesPagination: noopPagination,
|
||||
onPromptsUiChange: fn(),
|
||||
onGetPrompt: fn(),
|
||||
onRefreshPrompts: fn(),
|
||||
onResourcesUiChange: fn(),
|
||||
onReadResource: fn(),
|
||||
onSubscribeResource: fn(),
|
||||
onUnsubscribeResource: fn(),
|
||||
onRefreshResources: fn(),
|
||||
onTasksUiChange: fn(),
|
||||
onCancelTask: fn(),
|
||||
onClearCompletedTasks: fn(),
|
||||
onRefreshTasks: fn(),
|
||||
onSetLogLevel: fn(),
|
||||
onLogsUiChange: fn(),
|
||||
onClearLogs: fn(),
|
||||
onExportLogs: fn(),
|
||||
onProtocolUiChange: fn(),
|
||||
onClearProtocol: fn(),
|
||||
onExportProtocol: fn(),
|
||||
onReplayProtocol: fn(),
|
||||
onTogglePinProtocol: fn(),
|
||||
onNetworkUiChange: fn(),
|
||||
onClearNetwork: fn(),
|
||||
onExportNetwork: fn(),
|
||||
onConsoleUiChange: fn(),
|
||||
onClearConsole: fn(),
|
||||
onExportConsole: fn(),
|
||||
onAppsUiChange: fn(),
|
||||
onSelectApp: fn(),
|
||||
onOpenApp: fn(),
|
||||
onCloseApp: fn(),
|
||||
onAppError: fn(),
|
||||
onRefreshApps: fn(),
|
||||
activeTab: "Servers",
|
||||
onActiveTabChange: fn(),
|
||||
shell: shellArgs,
|
||||
connection: connectionArgs,
|
||||
servers: serversArgs,
|
||||
tools: toolsArgs,
|
||||
prompts: promptsArgs,
|
||||
resources: resourcesArgs,
|
||||
apps: appsArgs,
|
||||
tasks: tasksArgs,
|
||||
logs: logsArgs,
|
||||
protocol: protocolArgs,
|
||||
network: networkArgs,
|
||||
console: consoleArgs,
|
||||
},
|
||||
render: (args) => {
|
||||
const [activeTab, setActiveTab] = useState(args.activeTab ?? "Servers");
|
||||
const [activeTab, setActiveTab] = useState(args.shell.activeTab);
|
||||
return (
|
||||
<InspectorView
|
||||
{...args}
|
||||
activeTab={activeTab}
|
||||
onActiveTabChange={setActiveTab}
|
||||
shell={{ ...args.shell, activeTab, onActiveTabChange: setActiveTab }}
|
||||
/>
|
||||
);
|
||||
},
|
||||
|
|
@ -463,7 +525,7 @@ export const Default: Story = {
|
|||
|
||||
export const NoServers: Story = {
|
||||
args: {
|
||||
servers: [],
|
||||
servers: { ...serversArgs, servers: [] },
|
||||
},
|
||||
};
|
||||
|
||||
|
|
@ -480,7 +542,7 @@ const manyServers: ServerEntry[] = Array.from({ length: 24 }, (_, i) => ({
|
|||
|
||||
export const ManyServers: Story = {
|
||||
args: {
|
||||
servers: manyServers,
|
||||
servers: { ...serversArgs, servers: manyServers },
|
||||
},
|
||||
play: async ({ canvasElement }) => {
|
||||
// Even under enough content to overflow, the shell stays viewport-clamped
|
||||
|
|
@ -509,10 +571,13 @@ export const ManyServers: Story = {
|
|||
// regression / storybook play function coverage.
|
||||
export const Connected: Story = {
|
||||
args: {
|
||||
activeServer: demoServers[0]!.id,
|
||||
connectionStatus: "connected",
|
||||
initializeResult: demoInitializeResult,
|
||||
latencyMs: 142,
|
||||
connection: {
|
||||
...connectionArgs,
|
||||
activeServer: demoServers[0]!.id,
|
||||
connectionStatus: "connected",
|
||||
initializeResult: demoInitializeResult,
|
||||
latencyMs: 142,
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
|
|
@ -524,10 +589,13 @@ export const ConnectionError: Story = {
|
|||
// connect-attempt-failure signal that gates the failure column; `network: []`
|
||||
// (and empty Protocol) keep it a pure stdio failure so only Console is offered.
|
||||
args: {
|
||||
activeServer: demoServers[0]!.id,
|
||||
erroredServerId: demoServers[0]!.id,
|
||||
connectionStatus: "error",
|
||||
network: [],
|
||||
stderrLogs: demoStderr,
|
||||
connection: {
|
||||
...connectionArgs,
|
||||
activeServer: demoServers[0]!.id,
|
||||
erroredServerId: demoServers[0]!.id,
|
||||
connectionStatus: "error",
|
||||
},
|
||||
network: { ...networkArgs, network: [] },
|
||||
console: { ...consoleArgs, stderrLogs: demoStderr },
|
||||
},
|
||||
};
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load diff
|
|
@ -1,14 +1,4 @@
|
|||
import {
|
||||
useEffect,
|
||||
useMemo,
|
||||
useRef,
|
||||
useState,
|
||||
type ReactNode,
|
||||
type Ref,
|
||||
} from "react";
|
||||
import type { MalformedListItem } from "@inspector/core/mcp";
|
||||
import type { ReplayParamsOverride } from "../../../lib/protocolReplay";
|
||||
import type { DeepLink, DeepLinkParseStatus } from "../../../utils/deepLink";
|
||||
import { useEffect, useMemo, useRef, useState, type ReactNode } from "react";
|
||||
import {
|
||||
AppShell,
|
||||
Flex,
|
||||
|
|
@ -18,81 +8,38 @@ import {
|
|||
type MantineTransition,
|
||||
} from "@mantine/core";
|
||||
import { useLocalStorage } from "@mantine/hooks";
|
||||
import type {
|
||||
Implementation,
|
||||
InitializeResult,
|
||||
LoggingLevel,
|
||||
Prompt,
|
||||
ProtocolEra,
|
||||
ReadResourceResult,
|
||||
Resource,
|
||||
ResourceTemplateType as ResourceTemplate,
|
||||
Task,
|
||||
Tool,
|
||||
} from "@modelcontextprotocol/client";
|
||||
import type {
|
||||
ConnectionStatus,
|
||||
ExcludedTool,
|
||||
FetchRequestEntry,
|
||||
InspectorResourceSubscription,
|
||||
MessageEntry,
|
||||
ResourceSubscriptionStreamState,
|
||||
ServerEntry,
|
||||
StderrLogEntry,
|
||||
} from "@inspector/core/mcp/types.js";
|
||||
import type { Implementation, Tool } from "@modelcontextprotocol/client";
|
||||
import type { ServerEntry } from "@inspector/core/mcp/types.js";
|
||||
import { isTerminalStatus } from "@inspector/core/mcp/types.js";
|
||||
import { isAppTool } from "@inspector/core/mcp/apps.js";
|
||||
import { TASKS_EXTENSION_KEY } from "@inspector/core/mcp/modernTaskSchemas.js";
|
||||
import { ViewHeader } from "../../groups/ViewHeader/ViewHeader";
|
||||
import { VersionBadge } from "../../elements/VersionBadge/VersionBadge";
|
||||
import { CopyrightBadge } from "../../elements/CopyrightBadge/CopyrightBadge";
|
||||
import type { ListPaginationControlsProps } from "../../elements/ListPaginationControls/ListPaginationControls";
|
||||
import { ServerListScreen } from "../../screens/ServerListScreen/ServerListScreen";
|
||||
import {
|
||||
ToolsScreen,
|
||||
type ToolCallState,
|
||||
type ToolsUiState,
|
||||
} from "../../screens/ToolsScreen/ToolsScreen";
|
||||
import {
|
||||
AppsScreen,
|
||||
type AppsUiState,
|
||||
} from "../../screens/AppsScreen/AppsScreen";
|
||||
import { ToolsScreen } from "../../screens/ToolsScreen/ToolsScreen";
|
||||
import { AppsScreen } from "../../screens/AppsScreen/AppsScreen";
|
||||
import { PromptsScreen } from "../../screens/PromptsScreen/PromptsScreen";
|
||||
import { ResourcesScreen } from "../../screens/ResourcesScreen/ResourcesScreen";
|
||||
import { LoggingScreen } from "../../screens/LoggingScreen/LoggingScreen";
|
||||
import { TasksScreen } from "../../screens/TasksScreen/TasksScreen";
|
||||
import { ProtocolScreen } from "../../screens/ProtocolScreen/ProtocolScreen";
|
||||
import { NetworkScreen } from "../../screens/NetworkScreen/NetworkScreen";
|
||||
import { ConsoleScreen } from "../../screens/ConsoleScreen/ConsoleScreen";
|
||||
import type {
|
||||
AppRendererHandle,
|
||||
BridgeFactory,
|
||||
} from "../../elements/AppRenderer/AppRenderer";
|
||||
import {
|
||||
PromptsScreen,
|
||||
type GetPromptState,
|
||||
type PromptsUiState,
|
||||
} from "../../screens/PromptsScreen/PromptsScreen";
|
||||
import {
|
||||
ResourcesScreen,
|
||||
type ReadResourceState,
|
||||
type ResourcesUiState,
|
||||
} from "../../screens/ResourcesScreen/ResourcesScreen";
|
||||
import {
|
||||
LoggingScreen,
|
||||
type LogsUiState,
|
||||
} from "../../screens/LoggingScreen/LoggingScreen";
|
||||
import type { LogEntryData } from "../../elements/LogEntry/LogEntry";
|
||||
import {
|
||||
TasksScreen,
|
||||
type TasksUiState,
|
||||
} from "../../screens/TasksScreen/TasksScreen";
|
||||
import type { TaskProgress } from "../../groups/TaskCard/TaskCard";
|
||||
import {
|
||||
ProtocolScreen,
|
||||
type ProtocolUiState,
|
||||
} from "../../screens/ProtocolScreen/ProtocolScreen";
|
||||
import {
|
||||
NetworkScreen,
|
||||
type NetworkUiState,
|
||||
} from "../../screens/NetworkScreen/NetworkScreen";
|
||||
import {
|
||||
ConsoleScreen,
|
||||
type ConsoleUiState,
|
||||
} from "../../screens/ConsoleScreen/ConsoleScreen";
|
||||
AppsPanelProps,
|
||||
ConnectionProps,
|
||||
ConsolePanelProps,
|
||||
LogsPanelProps,
|
||||
NetworkPanelProps,
|
||||
PromptsPanelProps,
|
||||
ProtocolPanelProps,
|
||||
ResourcesPanelProps,
|
||||
ServerListProps,
|
||||
ShellProps,
|
||||
TasksPanelProps,
|
||||
ToolsPanelProps,
|
||||
} from "./types";
|
||||
import type { SortDirection } from "../../elements/SortToggle/SortToggle";
|
||||
import { ScreenStage } from "../../elements/ScreenStage/ScreenStage";
|
||||
import { MonitoringScreen } from "../../groups/MonitoringScreen/MonitoringScreen";
|
||||
|
|
@ -360,402 +307,193 @@ const MonitorColumnGroup = Group.withProps({
|
|||
flex: "0 0 auto",
|
||||
});
|
||||
|
||||
/**
|
||||
* `InspectorView` takes one prop per domain rather than a wall of ~130 flat
|
||||
* ones (#2130). Each bundle's shape lives in `./types`; the fields inside keep
|
||||
* the names they had as flat props, so this is a regrouping rather than a
|
||||
* rename.
|
||||
*
|
||||
* The bundles stop here: the function below destructures each one back into
|
||||
* the same locals it already used, and the screens beneath receive the same
|
||||
* individual props they always did.
|
||||
*/
|
||||
export interface InspectorViewProps {
|
||||
/**
|
||||
* Validated deep-link parameters from the page URL. When present and
|
||||
* `openApp` is set, the parent switches to the Apps tab and pre-selects that
|
||||
* app (with `appArgs` as the form values) once the connection is up and the
|
||||
* app list contains it. The connect itself is driven by the parent.
|
||||
*/
|
||||
deepLink?: DeepLink;
|
||||
/**
|
||||
* Outcome of parsing the initial-URL deep link, surfaced as `data-deeplink`
|
||||
* on the `connection-status` testid. Distinguishes "no deep link" from
|
||||
* "rejected" (token mismatch / bad serverUrl) — both leave `data-status`
|
||||
* idle, so an automated driver otherwise cannot tell them apart.
|
||||
*/
|
||||
deepLinkStatus?: DeepLinkParseStatus;
|
||||
|
||||
// Server list (static config; runtime connection state comes from the
|
||||
// separate fields below and is merged into each card by this component).
|
||||
servers: ServerEntry[];
|
||||
/**
|
||||
* Whether the server list is writable (catalog) or read-only (a `--config`
|
||||
* session file / ad-hoc launch). When false, the Servers screen hides all
|
||||
* catalog mutation controls. Defaults to true.
|
||||
*/
|
||||
serverListWritable?: boolean;
|
||||
|
||||
// Connection state — driven by the parent via `useInspectorClient`.
|
||||
activeServer?: string;
|
||||
/**
|
||||
* Id of the server whose last connection attempt failed (#1621). Its card in
|
||||
* the Servers screen draws a red border until another server is connected or
|
||||
* a new connection is attempted. Independent of `activeServer`, which the
|
||||
* parent clears on the failure's `disconnect` event.
|
||||
*
|
||||
* It is also the sole signal that opens the monitoring sidebar onto the
|
||||
* failure's diagnostics (#2108). That used to be gated on the `"error"`
|
||||
* connection status, which an OAuth failure never reaches — the parent drives
|
||||
* that leg and tears the client down itself, settling the session at
|
||||
* `"disconnected"`. So the parent must set this for *every* connect-attempt
|
||||
* failure, auth legs included, and clear it as each new attempt starts.
|
||||
*/
|
||||
erroredServerId?: string;
|
||||
/**
|
||||
* Id of the server that just connected successfully (#1682). Its card draws
|
||||
* the green highlight border and scrolls into view once the monitoring
|
||||
* sidebar has opened — the success mirror of `erroredServerId`.
|
||||
*/
|
||||
connectedServerId?: string;
|
||||
/**
|
||||
* The Inspector build version (root `package.json`), shown at the left of the
|
||||
* footer row (#1682). Absent on a legacy backend that omits it — the version
|
||||
* label then renders nothing.
|
||||
*/
|
||||
version?: string;
|
||||
connectionStatus: ConnectionStatus;
|
||||
/**
|
||||
* Last connection-level error message (handshake failure, OAuth start
|
||||
* failure, deep-link automation failure). Surfaced as `data-error-message`
|
||||
* on the header's `connection-status` testid so an automated driver can read
|
||||
* *why* a connect failed without scraping a transient toast.
|
||||
*/
|
||||
connectErrorMessage?: string;
|
||||
initializeResult?: InitializeResult;
|
||||
latencyMs?: number;
|
||||
|
||||
// Primitive lists, log streams, task state — all sourced from the
|
||||
// per-primitive `useManaged*` / `useMessageLog` hooks in the parent.
|
||||
tools: Tool[];
|
||||
/** Tools excluded from `tools/list` for invalid `x-mcp-header` annotations
|
||||
* (SEP-2243); shown in the Tools sidebar with the reason (#1632). */
|
||||
excludedTools?: ExcludedTool[];
|
||||
/**
|
||||
* Entries dropped from a list result as malformed, across every list method.
|
||||
* Each list panel filters for its own and warns about them (#1909).
|
||||
*/
|
||||
malformedListItems?: MalformedListItem[];
|
||||
prompts: Prompt[];
|
||||
resources: Resource[];
|
||||
resourceTemplates: ResourceTemplate[];
|
||||
subscriptions: InspectorResourceSubscription[];
|
||||
/**
|
||||
* Modern-era `subscriptions/listen` stream state (#1630). Drives the
|
||||
* Resources screen's stream badge/dot; `active: false` (or omitted) on the
|
||||
* legacy era.
|
||||
*/
|
||||
subscriptionStreamState?: ResourceSubscriptionStreamState;
|
||||
|
||||
// "List changed since last refresh" flags, sourced from the managed-state
|
||||
// layer (#1402). They light the per-screen list-changed indicator. Apps is a
|
||||
// filtered view of tools, so it shares the tools flag.
|
||||
toolsListChanged: boolean;
|
||||
promptsListChanged: boolean;
|
||||
resourcesListChanged: boolean;
|
||||
|
||||
// Last list-load failure per screen, sourced from the same managed-state
|
||||
// layer. Rendered above the sidebar list so a failed load (including the
|
||||
// connect-time one) can't read as "this server has none" (#1953).
|
||||
toolsLoadError?: Error | null;
|
||||
promptsLoadError?: Error | null;
|
||||
/**
|
||||
* The Resources sidebar lists resources AND templates behind a single
|
||||
* Refresh, so App passes whichever of the two loads failed.
|
||||
*/
|
||||
resourcesLoadError?: Error | null;
|
||||
logs: LogEntryData[];
|
||||
tasks: Task[];
|
||||
progressByTaskId?: Record<string, TaskProgress>;
|
||||
protocol: MessageEntry[];
|
||||
/** Negotiated protocol era (SEP §7.8), for the Protocol view's era badge. */
|
||||
protocolEra?: ProtocolEra;
|
||||
network: FetchRequestEntry[];
|
||||
/** Captured stdio stderr (the Console screen). Empty for HTTP servers. (#1621) */
|
||||
stderrLogs: StderrLogEntry[];
|
||||
|
||||
// Per-screen "operation in flight" states (panel-level; optional because
|
||||
// the underlying screens accept them as optional).
|
||||
toolCallState?: ToolCallState;
|
||||
getPromptState?: GetPromptState;
|
||||
readResourceState?: ReadResourceState;
|
||||
|
||||
// Per-screen selection / search / filter state, one object per screen. Owned
|
||||
// by the parent (App) so it persists across tab navigation within a live
|
||||
// session — the screens unmount on tab switch, so screen-local state would be
|
||||
// lost (#1417). Each is paired with an `on{Screen}UiChange` setter below.
|
||||
toolsUi: ToolsUiState;
|
||||
promptsUi: PromptsUiState;
|
||||
resourcesUi: ResourcesUiState;
|
||||
appsUi: AppsUiState;
|
||||
tasksUi: TasksUiState;
|
||||
logsUi: LogsUiState;
|
||||
protocolUi: ProtocolUiState;
|
||||
networkUi: NetworkUiState;
|
||||
consoleUi: ConsoleUiState;
|
||||
|
||||
/** Active inspector tab (lifted to App for OAuth resume). */
|
||||
activeTab: string;
|
||||
onActiveTabChange: (tab: string) => void;
|
||||
|
||||
// Logging level. The MCP `logging/setLevel` request has no echo
|
||||
// notification, so the parent keeps the optimistic current value.
|
||||
currentLogLevel: LoggingLevel;
|
||||
|
||||
// MCP Apps sandbox. The parent's web environment provides the sandbox iframe
|
||||
// URL (undefined when the sandbox controller is unavailable), the per-app
|
||||
// bridge factory, and the renderer handle the parent uses to push tool
|
||||
// input/result into the running app and tear it down.
|
||||
sandboxPath?: string;
|
||||
bridgeFactory: BridgeFactory;
|
||||
appRendererRef: Ref<AppRendererHandle>;
|
||||
|
||||
// Protocol pinning. Optional because pin state isn't persisted yet (#1244
|
||||
// is single-PR; persistence is a separate concern).
|
||||
pinnedProtocolIds?: Set<string>;
|
||||
|
||||
// Theme toggle (lives in the parent so the color scheme can also flow
|
||||
// into other top-level UI later).
|
||||
onToggleTheme: () => void;
|
||||
/** Open install-level client settings (client.json / EMA IdP). */
|
||||
onOpenClientSettings: () => void;
|
||||
|
||||
// Connection lifecycle (dispatched to `useInspectorClient.connect/disconnect`).
|
||||
onToggleConnection: (id: string) => void;
|
||||
onDisconnect: () => void;
|
||||
|
||||
// Server list actions.
|
||||
onServerAdd: () => void;
|
||||
onServerImportConfig: () => void;
|
||||
onServerImportJson: () => void;
|
||||
/** Download the current server list as a canonical `mcp.json` file. */
|
||||
onServerExport: () => void;
|
||||
onConnectionInfo: (id: string) => void;
|
||||
onServerSettings: (id: string) => void;
|
||||
onServerEdit: (id: string) => void;
|
||||
onServerClone: (id: string) => void;
|
||||
onServerRemove: (id: string) => void;
|
||||
/** Persist a new server ordering (drag-and-drop / keyboard reorder). */
|
||||
onServerReorder: (orderedIds: string[]) => void;
|
||||
/** Ids of freshly-added servers to highlight on the list (first is scrolled to). */
|
||||
highlightedServerIds?: string[];
|
||||
/** Clears the freshly-added highlight for a server (on click of its card). */
|
||||
onClearHighlight?: (id: string) => void;
|
||||
|
||||
// Per-primitive actions (route to `inspectorClient` methods / hook refresh).
|
||||
// Each `on{Screen}UiChange` persists that screen's lifted UI state (#1417).
|
||||
/** Whether the connected server advertises task-augmented tool calls. */
|
||||
serverSupportsTaskToolCalls: boolean;
|
||||
onToolsUiChange: (next: ToolsUiState) => void;
|
||||
onCallTool: (
|
||||
name: string,
|
||||
args: Record<string, unknown>,
|
||||
runAsTask?: boolean,
|
||||
) => void;
|
||||
onCancelToolCall?: () => void;
|
||||
onClearToolResult?: () => void;
|
||||
onRefreshTools: () => void;
|
||||
/** Pagination controls for the Tools list (#1721). */
|
||||
toolsPagination: ListPaginationControlsProps;
|
||||
/** Pagination controls for the Prompts list (#1721). */
|
||||
promptsPagination: ListPaginationControlsProps;
|
||||
/** Pagination controls for the Resources list (#1721). */
|
||||
resourcesPagination: ListPaginationControlsProps;
|
||||
/**
|
||||
* Read-on-demand handler for `resource_link` blocks in a tool result.
|
||||
* Returns the linked resource's contents so the result panel can inline them.
|
||||
*/
|
||||
onReadResourceContents?: (uri: string) => Promise<ReadResourceResult>;
|
||||
|
||||
onPromptsUiChange: (next: PromptsUiState) => void;
|
||||
onGetPrompt: (name: string, args: Record<string, string>) => void;
|
||||
onCopyPromptMessages?: () => void;
|
||||
onRefreshPrompts: () => void;
|
||||
|
||||
onResourcesUiChange: (next: ResourcesUiState) => void;
|
||||
onReadResource: (uri: string) => void;
|
||||
onSubscribeResource: (uri: string) => void;
|
||||
onUnsubscribeResource: (uri: string) => void;
|
||||
onRefreshResources: () => void;
|
||||
onCompleteArgument?: (
|
||||
ref:
|
||||
| { type: "ref/resource"; uri: string }
|
||||
| { type: "ref/prompt"; name: string },
|
||||
argumentName: string,
|
||||
argumentValue: string,
|
||||
context: Record<string, string>,
|
||||
) => Promise<string[]>;
|
||||
completionsSupported?: boolean;
|
||||
/**
|
||||
* Whether the connected server advertises the `resources.subscribe`
|
||||
* capability. When false, the Resources screen hides the Subscribe/
|
||||
* Unsubscribe button and the Subscriptions accordion section.
|
||||
*/
|
||||
subscriptionsSupported?: boolean;
|
||||
|
||||
onTasksUiChange: (next: TasksUiState) => void;
|
||||
onCancelTask: (taskId: string) => void;
|
||||
onClearCompletedTasks: () => void;
|
||||
onRefreshTasks: () => void;
|
||||
|
||||
onSetLogLevel: (level: LoggingLevel) => void;
|
||||
/**
|
||||
* Modern-era per-request log level currently stamped, or `null` when opted
|
||||
* out (#1629). On modern connections the Logs sidebar shows a per-request
|
||||
* opt-in control instead of the legacy `logging/setLevel` selector.
|
||||
*/
|
||||
modernLogLevel?: LoggingLevel | null;
|
||||
/** Set (or clear, with `null`) the modern per-request log level. */
|
||||
onSetModernLogLevel?: (level: LoggingLevel | null) => void;
|
||||
onLogsUiChange: (next: LogsUiState) => void;
|
||||
onClearLogs: () => void;
|
||||
onExportLogs: () => void;
|
||||
|
||||
onProtocolUiChange: (next: ProtocolUiState) => void;
|
||||
onClearProtocol: () => void;
|
||||
onExportProtocol: () => void;
|
||||
onClearProtocolSection: (section: "pinned" | "history") => void;
|
||||
onExportProtocolSection: (section: "pinned" | "history") => void;
|
||||
onReplayProtocol: (id: string, overrideParams?: ReplayParamsOverride) => void;
|
||||
onTogglePinProtocol: (id: string) => void;
|
||||
|
||||
onNetworkUiChange: (next: NetworkUiState) => void;
|
||||
onClearNetwork: () => void;
|
||||
onExportNetwork: () => void;
|
||||
|
||||
onConsoleUiChange: (next: ConsoleUiState) => void;
|
||||
onClearConsole: () => void;
|
||||
onExportConsole: () => void;
|
||||
|
||||
onAppsUiChange: (next: AppsUiState) => void;
|
||||
onSelectApp: (name: string) => void;
|
||||
onOpenApp: (name: string, args: Record<string, unknown>) => void;
|
||||
onCloseApp: () => void;
|
||||
onAppError: (err: Error) => void;
|
||||
onRefreshApps: () => void;
|
||||
/** App chrome: active tab, theme/settings entry points, footer, deep link. */
|
||||
shell: ShellProps;
|
||||
/** Live connection state plus the capability facts several screens read. */
|
||||
connection: ConnectionProps;
|
||||
/** The server catalog and every action on it. */
|
||||
servers: ServerListProps;
|
||||
tools: ToolsPanelProps;
|
||||
prompts: PromptsPanelProps;
|
||||
resources: ResourcesPanelProps;
|
||||
apps: AppsPanelProps;
|
||||
tasks: TasksPanelProps;
|
||||
logs: LogsPanelProps;
|
||||
protocol: ProtocolPanelProps;
|
||||
network: NetworkPanelProps;
|
||||
console: ConsolePanelProps;
|
||||
}
|
||||
|
||||
export function InspectorView({
|
||||
deepLink,
|
||||
deepLinkStatus,
|
||||
servers: serversInput,
|
||||
serverListWritable = true,
|
||||
activeServer,
|
||||
erroredServerId,
|
||||
connectedServerId,
|
||||
version,
|
||||
connectionStatus,
|
||||
connectErrorMessage,
|
||||
initializeResult,
|
||||
latencyMs,
|
||||
tools,
|
||||
excludedTools = [],
|
||||
malformedListItems = [],
|
||||
prompts,
|
||||
resources,
|
||||
resourceTemplates,
|
||||
toolsListChanged,
|
||||
promptsListChanged,
|
||||
resourcesListChanged,
|
||||
toolsLoadError,
|
||||
promptsLoadError,
|
||||
resourcesLoadError,
|
||||
subscriptions,
|
||||
subscriptionStreamState,
|
||||
logs,
|
||||
tasks,
|
||||
progressByTaskId,
|
||||
protocol,
|
||||
protocolEra,
|
||||
network,
|
||||
stderrLogs,
|
||||
toolCallState,
|
||||
getPromptState,
|
||||
readResourceState,
|
||||
toolsUi,
|
||||
promptsUi,
|
||||
resourcesUi,
|
||||
appsUi,
|
||||
tasksUi,
|
||||
logsUi,
|
||||
protocolUi,
|
||||
networkUi,
|
||||
consoleUi,
|
||||
currentLogLevel,
|
||||
sandboxPath,
|
||||
bridgeFactory,
|
||||
appRendererRef,
|
||||
pinnedProtocolIds,
|
||||
onToggleTheme,
|
||||
onOpenClientSettings,
|
||||
onToggleConnection,
|
||||
onDisconnect,
|
||||
onServerAdd,
|
||||
onServerImportConfig,
|
||||
onServerImportJson,
|
||||
onServerExport,
|
||||
onConnectionInfo,
|
||||
onServerSettings,
|
||||
onServerEdit,
|
||||
onServerClone,
|
||||
onServerRemove,
|
||||
onServerReorder,
|
||||
highlightedServerIds,
|
||||
onClearHighlight,
|
||||
serverSupportsTaskToolCalls,
|
||||
onToolsUiChange,
|
||||
onCallTool,
|
||||
onCancelToolCall,
|
||||
onClearToolResult,
|
||||
onReadResourceContents,
|
||||
onRefreshTools,
|
||||
toolsPagination,
|
||||
promptsPagination,
|
||||
resourcesPagination,
|
||||
onPromptsUiChange,
|
||||
onGetPrompt,
|
||||
onCopyPromptMessages,
|
||||
onRefreshPrompts,
|
||||
onResourcesUiChange,
|
||||
onReadResource,
|
||||
onSubscribeResource,
|
||||
onUnsubscribeResource,
|
||||
onRefreshResources,
|
||||
onCompleteArgument,
|
||||
completionsSupported,
|
||||
subscriptionsSupported,
|
||||
onTasksUiChange,
|
||||
onCancelTask,
|
||||
onClearCompletedTasks,
|
||||
onRefreshTasks,
|
||||
onSetLogLevel,
|
||||
modernLogLevel = null,
|
||||
onSetModernLogLevel,
|
||||
onLogsUiChange,
|
||||
onClearLogs,
|
||||
onExportLogs,
|
||||
onProtocolUiChange,
|
||||
onClearProtocol,
|
||||
onExportProtocol,
|
||||
onClearProtocolSection,
|
||||
onExportProtocolSection,
|
||||
onReplayProtocol,
|
||||
onTogglePinProtocol,
|
||||
onNetworkUiChange,
|
||||
onClearNetwork,
|
||||
onExportNetwork,
|
||||
onConsoleUiChange,
|
||||
onClearConsole,
|
||||
onExportConsole,
|
||||
onAppsUiChange,
|
||||
onSelectApp,
|
||||
onOpenApp,
|
||||
onCloseApp,
|
||||
onAppError,
|
||||
onRefreshApps,
|
||||
activeTab: activeTabProp,
|
||||
onActiveTabChange,
|
||||
shell,
|
||||
connection,
|
||||
servers: serverList,
|
||||
tools: toolsPanel,
|
||||
prompts: promptsPanel,
|
||||
resources: resourcesPanel,
|
||||
apps: appsPanel,
|
||||
tasks: tasksPanel,
|
||||
logs: logsPanel,
|
||||
protocol: protocolPanel,
|
||||
network: networkPanel,
|
||||
console: consolePanel,
|
||||
}: InspectorViewProps) {
|
||||
// Unpack the bundles back into the locals the body already uses, so the
|
||||
// grouping is confined to the prop boundary.
|
||||
const {
|
||||
deepLink,
|
||||
deepLinkStatus,
|
||||
version,
|
||||
malformedListItems = [],
|
||||
activeTab: activeTabProp,
|
||||
onActiveTabChange,
|
||||
onToggleTheme,
|
||||
onOpenClientSettings,
|
||||
} = shell;
|
||||
const {
|
||||
activeServer,
|
||||
erroredServerId,
|
||||
connectedServerId,
|
||||
connectionStatus,
|
||||
connectErrorMessage,
|
||||
initializeResult,
|
||||
latencyMs,
|
||||
protocolEra,
|
||||
onCompleteArgument,
|
||||
completionsSupported,
|
||||
onToggleConnection,
|
||||
onDisconnect,
|
||||
} = connection;
|
||||
const {
|
||||
servers: serversInput,
|
||||
serverListWritable = true,
|
||||
highlightedServerIds,
|
||||
onClearHighlight,
|
||||
onServerAdd,
|
||||
onServerImportConfig,
|
||||
onServerImportJson,
|
||||
onServerExport,
|
||||
onConnectionInfo,
|
||||
onServerSettings,
|
||||
onServerEdit,
|
||||
onServerClone,
|
||||
onServerRemove,
|
||||
onServerReorder,
|
||||
} = serverList;
|
||||
const {
|
||||
tools,
|
||||
excludedTools = [],
|
||||
toolsListChanged,
|
||||
toolsLoadError,
|
||||
toolsUi,
|
||||
toolCallState,
|
||||
toolsPagination,
|
||||
serverSupportsTaskToolCalls,
|
||||
onToolsUiChange,
|
||||
onCallTool,
|
||||
onCancelToolCall,
|
||||
onClearToolResult,
|
||||
onRefreshTools,
|
||||
onReadResourceContents,
|
||||
} = toolsPanel;
|
||||
const {
|
||||
prompts,
|
||||
promptsListChanged,
|
||||
promptsLoadError,
|
||||
promptsUi,
|
||||
getPromptState,
|
||||
promptsPagination,
|
||||
onPromptsUiChange,
|
||||
onGetPrompt,
|
||||
onCopyPromptMessages,
|
||||
onRefreshPrompts,
|
||||
} = promptsPanel;
|
||||
const {
|
||||
resources,
|
||||
resourceTemplates,
|
||||
subscriptions,
|
||||
subscriptionStreamState,
|
||||
subscriptionsSupported,
|
||||
resourcesListChanged,
|
||||
resourcesLoadError,
|
||||
resourcesUi,
|
||||
readResourceState,
|
||||
resourcesPagination,
|
||||
onResourcesUiChange,
|
||||
onReadResource,
|
||||
onSubscribeResource,
|
||||
onUnsubscribeResource,
|
||||
onRefreshResources,
|
||||
} = resourcesPanel;
|
||||
const {
|
||||
appsUi,
|
||||
sandboxPath,
|
||||
bridgeFactory,
|
||||
appRendererRef,
|
||||
onAppsUiChange,
|
||||
onSelectApp,
|
||||
onOpenApp,
|
||||
onCloseApp,
|
||||
onAppError,
|
||||
onRefreshApps,
|
||||
} = appsPanel;
|
||||
const {
|
||||
tasks,
|
||||
progressByTaskId,
|
||||
tasksUi,
|
||||
onTasksUiChange,
|
||||
onCancelTask,
|
||||
onClearCompletedTasks,
|
||||
onRefreshTasks,
|
||||
} = tasksPanel;
|
||||
const {
|
||||
logs,
|
||||
logsUi,
|
||||
currentLogLevel,
|
||||
modernLogLevel = null,
|
||||
onSetLogLevel,
|
||||
onSetModernLogLevel,
|
||||
onLogsUiChange,
|
||||
onClearLogs,
|
||||
onExportLogs,
|
||||
} = logsPanel;
|
||||
const {
|
||||
protocol,
|
||||
protocolUi,
|
||||
pinnedProtocolIds,
|
||||
onProtocolUiChange,
|
||||
onClearProtocol,
|
||||
onExportProtocol,
|
||||
onClearProtocolSection,
|
||||
onExportProtocolSection,
|
||||
onReplayProtocol,
|
||||
onTogglePinProtocol,
|
||||
} = protocolPanel;
|
||||
const {
|
||||
network,
|
||||
networkUi,
|
||||
onNetworkUiChange,
|
||||
onClearNetwork,
|
||||
onExportNetwork,
|
||||
} = networkPanel;
|
||||
const {
|
||||
stderrLogs,
|
||||
consoleUi,
|
||||
onConsoleUiChange,
|
||||
onClearConsole,
|
||||
onExportConsole,
|
||||
} = consolePanel;
|
||||
// UI-only state. Connection state, primitive lists, and all action
|
||||
// dispatching live in the parent; this component only owns view-local
|
||||
// toggles (sort direction, list compact). Tab selection is lifted (#1417).
|
||||
|
|
|
|||
379
clients/web/src/components/views/InspectorView/types.ts
Normal file
379
clients/web/src/components/views/InspectorView/types.ts
Normal file
|
|
@ -0,0 +1,379 @@
|
|||
// Domain prop bundles for `InspectorView` (#2130).
|
||||
//
|
||||
// The view used to take ~130 flat props, which made App.tsx's JSX a prop wall
|
||||
// rather than a component tree. Each bundle below groups one domain's data,
|
||||
// UI state, and actions into a single object, so the call site reads as the
|
||||
// tree it is and a hook can hand over exactly one bundle.
|
||||
//
|
||||
// Two conventions keep the change reviewable and keep it from rippling
|
||||
// downward:
|
||||
//
|
||||
// - **Field names are unchanged.** Every field carries the exact name it had
|
||||
// as a flat prop, so the bundles are a regrouping, not a rename.
|
||||
// - **The bundles stop here.** `InspectorView` destructures them back into
|
||||
// the same locals and passes them down to the screens as before, so
|
||||
// nothing below the view knows they exist.
|
||||
import type { Ref } from "react";
|
||||
import type {
|
||||
InitializeResult,
|
||||
LoggingLevel,
|
||||
Prompt,
|
||||
ProtocolEra,
|
||||
ReadResourceResult,
|
||||
Resource,
|
||||
ResourceTemplateType as ResourceTemplate,
|
||||
Task,
|
||||
Tool,
|
||||
} from "@modelcontextprotocol/client";
|
||||
import type { MalformedListItem } from "@inspector/core/mcp";
|
||||
import type {
|
||||
ConnectionStatus,
|
||||
ExcludedTool,
|
||||
FetchRequestEntry,
|
||||
InspectorResourceSubscription,
|
||||
MessageEntry,
|
||||
ResourceSubscriptionStreamState,
|
||||
ServerEntry,
|
||||
StderrLogEntry,
|
||||
} from "@inspector/core/mcp/types.js";
|
||||
import type { ReplayParamsOverride } from "../../../lib/protocolReplay";
|
||||
import type { DeepLink, DeepLinkParseStatus } from "../../../utils/deepLink";
|
||||
import type { ListPaginationControlsProps } from "../../elements/ListPaginationControls/ListPaginationControls";
|
||||
import type {
|
||||
AppRendererHandle,
|
||||
BridgeFactory,
|
||||
} from "../../elements/AppRenderer/AppRenderer";
|
||||
import type { LogEntryData } from "../../elements/LogEntry/LogEntry";
|
||||
import type { TaskProgress } from "../../groups/TaskCard/TaskCard";
|
||||
import type {
|
||||
ToolCallState,
|
||||
ToolsUiState,
|
||||
} from "../../screens/ToolsScreen/ToolsScreen";
|
||||
import type { AppsUiState } from "../../screens/AppsScreen/AppsScreen";
|
||||
import type {
|
||||
GetPromptState,
|
||||
PromptsUiState,
|
||||
} from "../../screens/PromptsScreen/PromptsScreen";
|
||||
import type {
|
||||
ReadResourceState,
|
||||
ResourcesUiState,
|
||||
} from "../../screens/ResourcesScreen/ResourcesScreen";
|
||||
import type { LogsUiState } from "../../screens/LoggingScreen/LoggingScreen";
|
||||
import type { TasksUiState } from "../../screens/TasksScreen/TasksScreen";
|
||||
import type { ProtocolUiState } from "../../screens/ProtocolScreen/ProtocolScreen";
|
||||
import type { NetworkUiState } from "../../screens/NetworkScreen/NetworkScreen";
|
||||
import type { ConsoleUiState } from "../../screens/ConsoleScreen/ConsoleScreen";
|
||||
|
||||
/**
|
||||
* App chrome and the few things that belong to no single screen: the active
|
||||
* tab, the theme/client-settings entry points, the footer version, the
|
||||
* deep-link parameters, and the malformed-list-item log that three separate
|
||||
* screens each filter for their own entries.
|
||||
*/
|
||||
export interface ShellProps {
|
||||
/**
|
||||
* Validated deep-link parameters from the page URL. When present and
|
||||
* `openApp` is set, the parent switches to the Apps tab and pre-selects that
|
||||
* app (with `appArgs` as the form values) once the connection is up and the
|
||||
* app list contains it. The connect itself is driven by the parent.
|
||||
*/
|
||||
deepLink?: DeepLink;
|
||||
/**
|
||||
* Outcome of parsing the initial-URL deep link, surfaced as `data-deeplink`
|
||||
* on the `connection-status` testid. Distinguishes "no deep link" from
|
||||
* "rejected" (token mismatch / bad serverUrl) — both leave `data-status`
|
||||
* idle, so an automated driver otherwise cannot tell them apart.
|
||||
*/
|
||||
deepLinkStatus?: DeepLinkParseStatus;
|
||||
/**
|
||||
* The Inspector build version (root `package.json`), shown at the left of the
|
||||
* footer row (#1682). Absent on a legacy backend that omits it — the version
|
||||
* label then renders nothing.
|
||||
*/
|
||||
version?: string;
|
||||
/**
|
||||
* Entries dropped from a list result as malformed, across every list method.
|
||||
* Each list panel filters for its own and warns about them (#1909). It spans
|
||||
* three screens, so it has no single domain owner.
|
||||
*/
|
||||
malformedListItems?: MalformedListItem[];
|
||||
/** Active inspector tab (lifted to App for OAuth resume). */
|
||||
activeTab: string;
|
||||
onActiveTabChange: (tab: string) => void;
|
||||
/**
|
||||
* Theme toggle (lives in the parent so the color scheme can also flow into
|
||||
* other top-level UI later).
|
||||
*/
|
||||
onToggleTheme: () => void;
|
||||
/** Open install-level client settings (client.json / EMA IdP). */
|
||||
onOpenClientSettings: () => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Live connection state and the capability facts more than one screen reads.
|
||||
* Driven by the parent via `useInspectorClient`.
|
||||
*/
|
||||
export interface ConnectionProps {
|
||||
activeServer?: string;
|
||||
/**
|
||||
* Id of the server whose last connection attempt failed (#1621). Its card in
|
||||
* the Servers screen draws a red border until another server is connected or
|
||||
* a new connection is attempted. Independent of `activeServer`, which the
|
||||
* parent clears on the failure's `disconnect` event.
|
||||
*
|
||||
* It is also the sole signal that opens the monitoring sidebar onto the
|
||||
* failure's diagnostics (#2108). That used to be gated on the `"error"`
|
||||
* connection status, which an OAuth failure never reaches — the parent drives
|
||||
* that leg and tears the client down itself, settling the session at
|
||||
* `"disconnected"`. So the parent must set this for *every* connect-attempt
|
||||
* failure, auth legs included, and clear it as each new attempt starts.
|
||||
*/
|
||||
erroredServerId?: string;
|
||||
/**
|
||||
* Id of the server that just connected successfully (#1682). Its card draws
|
||||
* the green highlight border and scrolls into view once the monitoring
|
||||
* sidebar has opened — the success mirror of `erroredServerId`.
|
||||
*/
|
||||
connectedServerId?: string;
|
||||
connectionStatus: ConnectionStatus;
|
||||
/**
|
||||
* Last connection-level error message (handshake failure, OAuth start
|
||||
* failure, deep-link automation failure). Surfaced as `data-error-message`
|
||||
* on the header's `connection-status` testid so an automated driver can read
|
||||
* *why* a connect failed without scraping a transient toast.
|
||||
*/
|
||||
connectErrorMessage?: string;
|
||||
initializeResult?: InitializeResult;
|
||||
latencyMs?: number;
|
||||
/** Negotiated protocol era (SEP §7.8), for the Protocol view's era badge. */
|
||||
protocolEra?: ProtocolEra;
|
||||
/**
|
||||
* Argument completion, shared by the Prompts and Resources screens — hence
|
||||
* here rather than in either one.
|
||||
*/
|
||||
onCompleteArgument?: (
|
||||
ref:
|
||||
| { type: "ref/resource"; uri: string }
|
||||
| { type: "ref/prompt"; name: string },
|
||||
argumentName: string,
|
||||
argumentValue: string,
|
||||
context: Record<string, string>,
|
||||
) => Promise<string[]>;
|
||||
completionsSupported?: boolean;
|
||||
// Connection lifecycle (dispatched to `useInspectorClient.connect/disconnect`).
|
||||
onToggleConnection: (id: string) => void;
|
||||
onDisconnect: () => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* The server catalog and every action on it. Static config only — the runtime
|
||||
* connection state comes from {@link ConnectionProps} and is merged into each
|
||||
* card by the view.
|
||||
*/
|
||||
export interface ServerListProps {
|
||||
servers: ServerEntry[];
|
||||
/**
|
||||
* Whether the server list is writable (catalog) or read-only (a `--config`
|
||||
* session file / ad-hoc launch). When false, the Servers screen hides all
|
||||
* catalog mutation controls. Defaults to true.
|
||||
*/
|
||||
serverListWritable?: boolean;
|
||||
/** Ids of freshly-added servers to highlight on the list (first is scrolled to). */
|
||||
highlightedServerIds?: string[];
|
||||
/** Clears the freshly-added highlight for a server (on click of its card). */
|
||||
onClearHighlight?: (id: string) => void;
|
||||
onServerAdd: () => void;
|
||||
onServerImportConfig: () => void;
|
||||
onServerImportJson: () => void;
|
||||
/** Download the current server list as a canonical `mcp.json` file. */
|
||||
onServerExport: () => void;
|
||||
onConnectionInfo: (id: string) => void;
|
||||
onServerSettings: (id: string) => void;
|
||||
onServerEdit: (id: string) => void;
|
||||
onServerClone: (id: string) => void;
|
||||
onServerRemove: (id: string) => void;
|
||||
/** Persist a new server ordering (drag-and-drop / keyboard reorder). */
|
||||
onServerReorder: (orderedIds: string[]) => void;
|
||||
}
|
||||
|
||||
/** The Tools screen: its list, lifted UI state, and actions. */
|
||||
export interface ToolsPanelProps {
|
||||
tools: Tool[];
|
||||
/**
|
||||
* Tools excluded from `tools/list` for invalid `x-mcp-header` annotations
|
||||
* (SEP-2243); shown in the Tools sidebar with the reason (#1632).
|
||||
*/
|
||||
excludedTools?: ExcludedTool[];
|
||||
/** "List changed since last refresh", from the managed-state layer (#1402). */
|
||||
toolsListChanged: boolean;
|
||||
/**
|
||||
* Last list-load failure, rendered above the sidebar list so a failed load
|
||||
* (including the connect-time one) can't read as "this server has none"
|
||||
* (#1953).
|
||||
*/
|
||||
toolsLoadError?: Error | null;
|
||||
/**
|
||||
* Lifted selection / search / filter state. Owned by the parent so it
|
||||
* persists across tab navigation within a live session — the screens unmount
|
||||
* on tab switch, so screen-local state would be lost (#1417).
|
||||
*/
|
||||
toolsUi: ToolsUiState;
|
||||
/** Panel-level "operation in flight" state. */
|
||||
toolCallState?: ToolCallState;
|
||||
/** Pagination controls for the Tools list (#1721). */
|
||||
toolsPagination: ListPaginationControlsProps;
|
||||
/** Whether the connected server advertises task-augmented tool calls. */
|
||||
serverSupportsTaskToolCalls: boolean;
|
||||
onToolsUiChange: (next: ToolsUiState) => void;
|
||||
onCallTool: (
|
||||
name: string,
|
||||
args: Record<string, unknown>,
|
||||
runAsTask?: boolean,
|
||||
) => void;
|
||||
onCancelToolCall?: () => void;
|
||||
onClearToolResult?: () => void;
|
||||
onRefreshTools: () => void;
|
||||
/**
|
||||
* Read-on-demand handler for `resource_link` blocks in a tool result.
|
||||
* Returns the linked resource's contents so the result panel can inline them.
|
||||
*/
|
||||
onReadResourceContents?: (uri: string) => Promise<ReadResourceResult>;
|
||||
}
|
||||
|
||||
/** The Prompts screen: its list, lifted UI state, and actions. */
|
||||
export interface PromptsPanelProps {
|
||||
prompts: Prompt[];
|
||||
promptsListChanged: boolean;
|
||||
promptsLoadError?: Error | null;
|
||||
promptsUi: PromptsUiState;
|
||||
getPromptState?: GetPromptState;
|
||||
/** Pagination controls for the Prompts list (#1721). */
|
||||
promptsPagination: ListPaginationControlsProps;
|
||||
onPromptsUiChange: (next: PromptsUiState) => void;
|
||||
onGetPrompt: (name: string, args: Record<string, string>) => void;
|
||||
onCopyPromptMessages?: () => void;
|
||||
onRefreshPrompts: () => void;
|
||||
}
|
||||
|
||||
/** The Resources screen: resources, templates, subscriptions, and actions. */
|
||||
export interface ResourcesPanelProps {
|
||||
resources: Resource[];
|
||||
resourceTemplates: ResourceTemplate[];
|
||||
subscriptions: InspectorResourceSubscription[];
|
||||
/**
|
||||
* Modern-era `subscriptions/listen` stream state (#1630). Drives the
|
||||
* Resources screen's stream badge/dot; `active: false` (or omitted) on the
|
||||
* legacy era.
|
||||
*/
|
||||
subscriptionStreamState?: ResourceSubscriptionStreamState;
|
||||
/**
|
||||
* Whether the connected server advertises the `resources.subscribe`
|
||||
* capability. When false, the Resources screen hides the Subscribe/
|
||||
* Unsubscribe button and the Subscriptions accordion section.
|
||||
*/
|
||||
subscriptionsSupported?: boolean;
|
||||
resourcesListChanged: boolean;
|
||||
/**
|
||||
* The Resources sidebar lists resources AND templates behind a single
|
||||
* Refresh, so App passes whichever of the two loads failed.
|
||||
*/
|
||||
resourcesLoadError?: Error | null;
|
||||
resourcesUi: ResourcesUiState;
|
||||
readResourceState?: ReadResourceState;
|
||||
/** Pagination controls for the Resources list (#1721). */
|
||||
resourcesPagination: ListPaginationControlsProps;
|
||||
onResourcesUiChange: (next: ResourcesUiState) => void;
|
||||
onReadResource: (uri: string) => void;
|
||||
onSubscribeResource: (uri: string) => void;
|
||||
onUnsubscribeResource: (uri: string) => void;
|
||||
onRefreshResources: () => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* The Apps screen and its sandbox wiring. The parent's web environment
|
||||
* provides the sandbox iframe URL (undefined when the sandbox controller is
|
||||
* unavailable), the per-app bridge factory, and the renderer handle the parent
|
||||
* uses to push tool input/result into the running app and tear it down.
|
||||
*/
|
||||
export interface AppsPanelProps {
|
||||
appsUi: AppsUiState;
|
||||
sandboxPath?: string;
|
||||
bridgeFactory: BridgeFactory;
|
||||
appRendererRef: Ref<AppRendererHandle>;
|
||||
onAppsUiChange: (next: AppsUiState) => void;
|
||||
onSelectApp: (name: string) => void;
|
||||
onOpenApp: (name: string, args: Record<string, unknown>) => void;
|
||||
onCloseApp: () => void;
|
||||
onAppError: (err: Error) => void;
|
||||
onRefreshApps: () => void;
|
||||
}
|
||||
|
||||
/** The Tasks monitor: the task list, its progress map, and actions. */
|
||||
export interface TasksPanelProps {
|
||||
tasks: Task[];
|
||||
progressByTaskId?: Record<string, TaskProgress>;
|
||||
tasksUi: TasksUiState;
|
||||
onTasksUiChange: (next: TasksUiState) => void;
|
||||
onCancelTask: (taskId: string) => void;
|
||||
onClearCompletedTasks: () => void;
|
||||
onRefreshTasks: () => void;
|
||||
}
|
||||
|
||||
/** The Logs monitor, including both eras' log-level controls. */
|
||||
export interface LogsPanelProps {
|
||||
logs: LogEntryData[];
|
||||
logsUi: LogsUiState;
|
||||
/**
|
||||
* The MCP `logging/setLevel` request has no echo notification, so the parent
|
||||
* keeps the optimistic current value.
|
||||
*/
|
||||
currentLogLevel: LoggingLevel;
|
||||
/**
|
||||
* Modern-era per-request log level currently stamped, or `null` when opted
|
||||
* out (#1629). On modern connections the Logs sidebar shows a per-request
|
||||
* opt-in control instead of the legacy `logging/setLevel` selector.
|
||||
*/
|
||||
modernLogLevel?: LoggingLevel | null;
|
||||
onSetLogLevel: (level: LoggingLevel) => void;
|
||||
/** Set (or clear, with `null`) the modern per-request log level. */
|
||||
onSetModernLogLevel?: (level: LoggingLevel | null) => void;
|
||||
onLogsUiChange: (next: LogsUiState) => void;
|
||||
onClearLogs: () => void;
|
||||
onExportLogs: () => void;
|
||||
}
|
||||
|
||||
/** The Protocol monitor: the message log, pinning, replay, and exports. */
|
||||
export interface ProtocolPanelProps {
|
||||
protocol: MessageEntry[];
|
||||
protocolUi: ProtocolUiState;
|
||||
/**
|
||||
* Pinned message ids. Optional because pin state isn't persisted yet (#1244
|
||||
* is single-PR; persistence is a separate concern).
|
||||
*/
|
||||
pinnedProtocolIds?: Set<string>;
|
||||
onProtocolUiChange: (next: ProtocolUiState) => void;
|
||||
onClearProtocol: () => void;
|
||||
onExportProtocol: () => void;
|
||||
onClearProtocolSection: (section: "pinned" | "history") => void;
|
||||
onExportProtocolSection: (section: "pinned" | "history") => void;
|
||||
onReplayProtocol: (id: string, overrideParams?: ReplayParamsOverride) => void;
|
||||
onTogglePinProtocol: (id: string) => void;
|
||||
}
|
||||
|
||||
/** The Network monitor: captured HTTP traffic and its actions. */
|
||||
export interface NetworkPanelProps {
|
||||
network: FetchRequestEntry[];
|
||||
networkUi: NetworkUiState;
|
||||
onNetworkUiChange: (next: NetworkUiState) => void;
|
||||
onClearNetwork: () => void;
|
||||
onExportNetwork: () => void;
|
||||
}
|
||||
|
||||
/** The Console monitor: captured stdio stderr. Empty for HTTP servers (#1621). */
|
||||
export interface ConsolePanelProps {
|
||||
stderrLogs: StderrLogEntry[];
|
||||
consoleUi: ConsoleUiState;
|
||||
onConsoleUiChange: (next: ConsoleUiState) => void;
|
||||
onClearConsole: () => void;
|
||||
onExportConsole: () => void;
|
||||
}
|
||||
|
|
@ -912,6 +912,11 @@ export function useConnectionLifecycle({
|
|||
serverId === activeServerId ? inspectorClient : null,
|
||||
isActiveConnection: serverId === activeServerId,
|
||||
oauthStorage: webOAuthStorage,
|
||||
// No RFC 7009 revocation here (#2144): this clears a *half-finished*
|
||||
// flow so it can be retried. The authorization never completed, so
|
||||
// there is no grant to revoke — and any token still on disk belongs
|
||||
// to the very session this recovery is trying to rebuild.
|
||||
revoke: false,
|
||||
});
|
||||
} catch (err) {
|
||||
notifications.show({
|
||||
|
|
|
|||
|
|
@ -21,6 +21,7 @@ import { EMPTY_SETTINGS } from "../utils/serverSettingsDefaults";
|
|||
import { useSessionRef } from "./useSessionRef";
|
||||
import { useTabUiState } from "./useTabUiState";
|
||||
import {
|
||||
revocationSuffix,
|
||||
useOAuthRecovery,
|
||||
type FetchRequestSource,
|
||||
type OAuthRecovery,
|
||||
|
|
@ -58,6 +59,14 @@ vi.mock("@inspector/core/mcp/remote/index.js", () => ({
|
|||
RemoteInspectorClientStorage: class {
|
||||
saveSession = saveSessionMock;
|
||||
},
|
||||
// #2144: `clearServerOAuthAndDisconnect` builds the backend-proxied fetch the
|
||||
// revocation POST would travel on. Nothing here exercises a real request, so
|
||||
// this only has to exist.
|
||||
createRemoteFetch: () => remoteFetchMock,
|
||||
}));
|
||||
|
||||
const { remoteFetchMock } = vi.hoisted(() => ({
|
||||
remoteFetchMock: vi.fn<typeof fetch>(async () => new Response(null)),
|
||||
}));
|
||||
|
||||
vi.mock("../lib/authToken", () => ({ getAuthToken: () => "test-token" }));
|
||||
|
|
@ -262,7 +271,7 @@ beforeEach(() => {
|
|||
window.sessionStorage.clear();
|
||||
window.history.replaceState({}, "", "/");
|
||||
oauthStorageMock.load.mockResolvedValue(undefined);
|
||||
clearServerOAuthStateMock.mockResolvedValue(true);
|
||||
clearServerOAuthStateMock.mockResolvedValue({ cleared: true });
|
||||
saveSessionMock.mockResolvedValue(undefined);
|
||||
});
|
||||
|
||||
|
|
@ -1271,9 +1280,37 @@ describe("useOAuthRecovery", () => {
|
|||
});
|
||||
});
|
||||
|
||||
// #2144 — only the two outcomes a user can act on are surfaced. A skip
|
||||
// describes the status quo, and reporting it would turn a confirmation into a
|
||||
// notice about something that did not need to happen.
|
||||
describe("revocationSuffix", () => {
|
||||
it("announces a successful revocation", () => {
|
||||
expect(
|
||||
revocationSuffix({
|
||||
status: "revoked",
|
||||
tokenTypeHint: "refresh_token",
|
||||
endpoint: "https://as.example/revoke",
|
||||
}),
|
||||
).toContain("revoked at the authorization server");
|
||||
});
|
||||
|
||||
it("warns that the grant may still be live after a failure", () => {
|
||||
const text = revocationSuffix({ status: "failed", detail: "boom" });
|
||||
expect(text).toContain("boom");
|
||||
expect(text).toContain("may still be valid");
|
||||
});
|
||||
|
||||
it("says nothing for a skip or an absent outcome", () => {
|
||||
expect(
|
||||
revocationSuffix({ status: "skipped", reason: "no_endpoint" }),
|
||||
).toBe("");
|
||||
expect(revocationSuffix(undefined)).toBe("");
|
||||
});
|
||||
});
|
||||
|
||||
describe("clearing stored OAuth state", () => {
|
||||
it("says nothing when there was nothing to clear", async () => {
|
||||
clearServerOAuthStateMock.mockResolvedValue(false);
|
||||
clearServerOAuthStateMock.mockResolvedValue({ cleared: false });
|
||||
const h = harness({ servers: [entry("a")], activeServerId: "a" });
|
||||
await act(async () => {
|
||||
await h.api().clearServerOAuthAndDisconnect(entry("a"));
|
||||
|
|
@ -1305,6 +1342,117 @@ describe("useOAuthRecovery", () => {
|
|||
).toBeDefined();
|
||||
});
|
||||
|
||||
// #2144 — this is the web client's production wiring for revocation.
|
||||
// Without asserting the arguments, removing the per-server opt-out or
|
||||
// handing it the page-origin fetch would leave every test green.
|
||||
it("passes the per-server revoke setting and the proxied fetch", async () => {
|
||||
const h = harness({ servers: [entry("a")], activeServerId: "a" });
|
||||
await act(async () => {
|
||||
await h.api().clearServerOAuthAndDisconnect({
|
||||
...entry("a"),
|
||||
settings: { ...EMPTY_SETTINGS, oauthRevokeOnClear: false },
|
||||
});
|
||||
});
|
||||
expect(clearServerOAuthStateMock).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ revoke: false, fetchFn: remoteFetchMock }),
|
||||
);
|
||||
});
|
||||
|
||||
it("defaults to revoking when the server did not opt out", async () => {
|
||||
const h = harness({ servers: [entry("a")], activeServerId: "a" });
|
||||
await act(async () => {
|
||||
await h.api().clearServerOAuthAndDisconnect(entry("a"));
|
||||
});
|
||||
expect(clearServerOAuthStateMock).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ revoke: true }),
|
||||
);
|
||||
});
|
||||
|
||||
// #2144 — the RFC 7009 leg is a bounded network request, so this callback
|
||||
// can stay suspended for seconds. `isActive`/`inspectorClient` are captured
|
||||
// before it, so without revalidating, a switch during the wait would
|
||||
// disconnect the session the user just moved to and run the session-wide
|
||||
// cleanup against it.
|
||||
it("does not disconnect a session switched to while a clear is in flight", async () => {
|
||||
let settle: (r: { cleared: boolean }) => void = () => {};
|
||||
clearServerOAuthStateMock.mockImplementation(
|
||||
() =>
|
||||
new Promise((resolve) => {
|
||||
settle = resolve as typeof settle;
|
||||
}),
|
||||
);
|
||||
const client = fakeClient();
|
||||
const h = harness({
|
||||
servers: [entry("a"), entry("b")],
|
||||
activeServerId: "a",
|
||||
client,
|
||||
});
|
||||
|
||||
let done: Promise<void>;
|
||||
await act(async () => {
|
||||
done = h.api().clearServerOAuthAndDisconnect(entry("a"));
|
||||
await Promise.resolve();
|
||||
});
|
||||
|
||||
// The user switches away while the clear is still running.
|
||||
h.rerender({
|
||||
servers: [entry("a"), entry("b")],
|
||||
activeServerId: "b",
|
||||
client,
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
settle({ cleared: true });
|
||||
await done;
|
||||
});
|
||||
|
||||
expect(client.disconnect).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
// A disconnect/reconnect to the SAME server builds a replacement client, so
|
||||
// an id-only check passes again and the old clear would run its
|
||||
// session-wide cleanup — including the disconnect — against the new session.
|
||||
it("does not disconnect a replacement client for the same server", async () => {
|
||||
let settle: (r: { cleared: boolean }) => void = () => {};
|
||||
clearServerOAuthStateMock.mockImplementation(
|
||||
() =>
|
||||
new Promise((resolve) => {
|
||||
settle = resolve as typeof settle;
|
||||
}),
|
||||
);
|
||||
const original = fakeClient();
|
||||
const h = harness({
|
||||
servers: [entry("a")],
|
||||
activeServerId: "a",
|
||||
client: original,
|
||||
});
|
||||
|
||||
let done: Promise<void>;
|
||||
await act(async () => {
|
||||
done = h.api().clearServerOAuthAndDisconnect(entry("a"));
|
||||
await Promise.resolve();
|
||||
});
|
||||
|
||||
// Same server, new client — a reconnect while the clear is pending.
|
||||
const replacement = fakeClient();
|
||||
h.rerender({
|
||||
servers: [entry("a")],
|
||||
activeServerId: "a",
|
||||
client: replacement,
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
settle({ cleared: true });
|
||||
await done;
|
||||
});
|
||||
|
||||
// Neither client is torn down: the replacement is the live session and
|
||||
// must not be touched, and the original is already gone — disconnecting
|
||||
// it would only drag `finalizeExplicitDisconnect` across the new one.
|
||||
expect(original.disconnect).not.toHaveBeenCalled();
|
||||
expect(replacement.disconnect).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("clears the resume snapshot on an explicit disconnect", () => {
|
||||
writeOAuthResumeSnapshot({
|
||||
version: 1,
|
||||
|
|
|
|||
|
|
@ -5,6 +5,7 @@ import type { InspectorClient } from "@inspector/core/mcp/index.js";
|
|||
import type { TypedEvent } from "@inspector/core/mcp/inspectorClientEventTarget.js";
|
||||
import type {
|
||||
ConnectionStatus,
|
||||
InspectorServerSettings,
|
||||
MCPServerConfig,
|
||||
ServerEntry,
|
||||
} from "@inspector/core/mcp/types.js";
|
||||
|
|
@ -17,6 +18,7 @@ import {
|
|||
} from "@inspector/core/auth/index.js";
|
||||
import { RemoteInspectorClientStorage } from "@inspector/core/mcp/remote/index.js";
|
||||
import { AuthRecoveryRequiredError } from "@inspector/core/auth/challenge.js";
|
||||
import type { TokenRevocationOutcome } from "@inspector/core/auth/revocation.js";
|
||||
import type {
|
||||
AuthChallenge,
|
||||
AuthChallengeReason,
|
||||
|
|
@ -31,6 +33,7 @@ import { isEmaClientNotConfiguredError } from "@inspector/core/auth/ema/clientCo
|
|||
import type { OAuthDetails } from "../components/groups/ConnectionInfoContent/ConnectionInfoContent";
|
||||
import { oauthDetailsFromConnectionState } from "../components/groups/ConnectionInfoContent/oauthDetailsFromConnectionState";
|
||||
import { getWebRemoteOAuthStorage } from "../lib/remoteOAuthStorage";
|
||||
import { getWebProxiedFetch } from "../lib/webProxiedFetch";
|
||||
import { clearServerOAuthState } from "../lib/clearServerOAuthState";
|
||||
import { getAuthToken } from "../lib/authToken";
|
||||
import {
|
||||
|
|
@ -121,6 +124,36 @@ export interface ClearableServer {
|
|||
id: string;
|
||||
name: string;
|
||||
config: MCPServerConfig;
|
||||
/**
|
||||
* Read for `oauthRevokeOnClear` (#2144). Optional so a caller that only has
|
||||
* the identity fields still type-checks; an absent value means the default,
|
||||
* on.
|
||||
*/
|
||||
settings?: InspectorServerSettings;
|
||||
}
|
||||
|
||||
/**
|
||||
* The sentence appended to the "OAuth state cleared" toast describing what the
|
||||
* RFC 7009 leg did (#2144).
|
||||
*
|
||||
* Only the two outcomes a user can act on are surfaced. A success is worth
|
||||
* saying because the whole point of the feature is invisible otherwise — the
|
||||
* local clear looks identical either way. A failure is worth saying because the
|
||||
* grant is still live at the authorization server and the user may want to
|
||||
* revoke it by hand. The remaining skips ("this server advertises no revocation
|
||||
* endpoint", "there were no tokens") describe the status quo and would turn a
|
||||
* confirmation into a notice about something that did not need to happen.
|
||||
*/
|
||||
export function revocationSuffix(
|
||||
outcome: TokenRevocationOutcome | undefined,
|
||||
): string {
|
||||
if (outcome?.status === "revoked") {
|
||||
return " The grant was also revoked at the authorization server.";
|
||||
}
|
||||
if (outcome?.status === "failed") {
|
||||
return ` Revoking the grant at the authorization server failed (${outcome.detail}), so it may still be valid there.`;
|
||||
}
|
||||
return "";
|
||||
}
|
||||
|
||||
export interface UseOAuthRecoveryOptions {
|
||||
|
|
@ -1221,36 +1254,78 @@ export function useOAuthRecovery({
|
|||
const clearServerOAuthAndDisconnect = useCallback(
|
||||
async (server: ClearableServer) => {
|
||||
const isActive = server.id === activeServerId;
|
||||
const cleared = await clearServerOAuthState({
|
||||
const client = isActive ? inspectorClient : null;
|
||||
// The RFC 7009 leg is a bounded network request (#2144), so this callback
|
||||
// can stay suspended for seconds — long enough for the user to close the
|
||||
// modal and switch servers. `isActive` and `inspectorClient` were
|
||||
// snapshotted before it, so everything below would otherwise apply to
|
||||
// whatever session is active *now*: disconnecting a client the user just
|
||||
// switched to, and running the session-wide UI cleanup against it.
|
||||
//
|
||||
// The session ref is the live answer. Only the parts that touch the
|
||||
// *session* are gated on it; the store write already happened and the
|
||||
// toast still belongs to the server the user asked about.
|
||||
// The client identity is part of the check, not just the server id: a
|
||||
// disconnect/reconnect to the SAME server builds a replacement
|
||||
// `InspectorClient`, so an id-only check passes again and the old clear
|
||||
// would run its session-wide cleanup against the new session.
|
||||
const stillTargetsActiveSession = (): boolean =>
|
||||
isActive &&
|
||||
sessionRef.current.activeServerId === server.id &&
|
||||
sessionRef.current.inspectorClient === client;
|
||||
|
||||
const { cleared, revocation } = await clearServerOAuthState({
|
||||
config: server.config,
|
||||
inspectorClient: isActive ? inspectorClient : null,
|
||||
inspectorClient: client,
|
||||
isActiveConnection: isActive,
|
||||
oauthStorage: webOAuthStorage,
|
||||
revoke: server.settings?.oauthRevokeOnClear !== false,
|
||||
fetchFn: getWebProxiedFetch(getAuthToken()),
|
||||
});
|
||||
if (!cleared) return;
|
||||
|
||||
if (isActive && inspectorClient) {
|
||||
if (client && stillTargetsActiveSession()) {
|
||||
try {
|
||||
await inspectorClient.disconnect();
|
||||
await client.disconnect();
|
||||
} catch (err) {
|
||||
// The clear has already succeeded, so this must not propagate: the
|
||||
// caller's catch would report "Could not clear the stored OAuth
|
||||
// state", which is false. Reported as what it is, and the
|
||||
// cleared-successfully toast below still goes out.
|
||||
notifications.show({
|
||||
title: "Cleared, but the session did not disconnect cleanly",
|
||||
message: err instanceof Error ? err.message : String(err),
|
||||
color: "yellow",
|
||||
});
|
||||
} finally {
|
||||
setConnectionInfoOAuthWhenConnected(undefined);
|
||||
finalizeExplicitDisconnect();
|
||||
// Revalidate after the second await for the same reason.
|
||||
if (stillTargetsActiveSession()) {
|
||||
setConnectionInfoOAuthWhenConnected(undefined);
|
||||
finalizeExplicitDisconnect();
|
||||
}
|
||||
}
|
||||
} else {
|
||||
} else if (!isActive || stillTargetsActiveSession()) {
|
||||
// No client to disconnect — either this is a stored-only clear, or the
|
||||
// active session has none yet (it is being built or torn down). Either
|
||||
// way the resume snapshot is stale and must go; skipping it would leave
|
||||
// OAuth recovery state pointing at credentials that no longer exist.
|
||||
// Guarded so a clear whose session has moved on still publishes
|
||||
// nothing.
|
||||
clearOAuthResumeOnExplicitDisconnect();
|
||||
}
|
||||
|
||||
notifications.show({
|
||||
title: "OAuth state cleared",
|
||||
message: isActive
|
||||
? "Stored tokens and client registration were removed. Reconnect to run a fresh authorization flow."
|
||||
: `Stored OAuth state was removed for "${server.name}". Connect to authorize again.`,
|
||||
? `Stored tokens and client registration were removed. Reconnect to run a fresh authorization flow.${revocationSuffix(revocation)}`
|
||||
: `Stored OAuth state was removed for "${server.name}". Connect to authorize again.${revocationSuffix(revocation)}`,
|
||||
color: "blue",
|
||||
});
|
||||
},
|
||||
[
|
||||
activeServerId,
|
||||
inspectorClient,
|
||||
sessionRef,
|
||||
webOAuthStorage,
|
||||
finalizeExplicitDisconnect,
|
||||
clearOAuthResumeOnExplicitDisconnect,
|
||||
|
|
|
|||
|
|
@ -1,40 +1,47 @@
|
|||
import { describe, it, expect, beforeEach, vi } from "vitest";
|
||||
import { BrowserOAuthStorage } from "@inspector/core/auth/browser/storage.js";
|
||||
import type { TokenRevocationOutcome } from "@inspector/core/auth/revocation.js";
|
||||
import type { InspectorClient } from "@inspector/core/mcp/inspectorClient.js";
|
||||
import { clearServerOAuthState } from "./clearServerOAuthState";
|
||||
|
||||
const SERVER_URL = "https://mcp.example.com/mcp";
|
||||
|
||||
function skipped(): TokenRevocationOutcome {
|
||||
return { status: "skipped", reason: "no_endpoint" };
|
||||
}
|
||||
|
||||
describe("clearServerOAuthState", () => {
|
||||
let storage: BrowserOAuthStorage;
|
||||
|
||||
beforeEach(async () => {
|
||||
storage = new BrowserOAuthStorage();
|
||||
await storage.clear("https://mcp.example.com/mcp");
|
||||
await storage.clear(SERVER_URL);
|
||||
});
|
||||
|
||||
it("clears storage by server URL when not the active connection", async () => {
|
||||
await storage.saveTokens("https://mcp.example.com/mcp", {
|
||||
await storage.saveTokens(SERVER_URL, {
|
||||
access_token: "tok",
|
||||
token_type: "Bearer",
|
||||
});
|
||||
|
||||
const cleared = await clearServerOAuthState({
|
||||
config: { type: "streamable-http", url: "https://mcp.example.com/mcp" },
|
||||
const { cleared } = await clearServerOAuthState({
|
||||
config: { type: "streamable-http", url: SERVER_URL },
|
||||
isActiveConnection: false,
|
||||
oauthStorage: storage,
|
||||
});
|
||||
|
||||
expect(cleared).toBe(true);
|
||||
expect(
|
||||
await storage.getTokens("https://mcp.example.com/mcp"),
|
||||
).toBeUndefined();
|
||||
expect(await storage.getTokens(SERVER_URL)).toBeUndefined();
|
||||
});
|
||||
|
||||
it("uses the live client when clearing the active connection", async () => {
|
||||
const clearOAuthTokens = vi.fn<InspectorClient["clearOAuthTokens"]>();
|
||||
const clearOAuthTokens = vi.fn<InspectorClient["clearOAuthTokens"]>(
|
||||
async () => skipped(),
|
||||
);
|
||||
const inspectorClient = { clearOAuthTokens };
|
||||
|
||||
const cleared = await clearServerOAuthState({
|
||||
config: { type: "streamable-http", url: "https://mcp.example.com/mcp" },
|
||||
const { cleared } = await clearServerOAuthState({
|
||||
config: { type: "streamable-http", url: SERVER_URL },
|
||||
inspectorClient,
|
||||
isActiveConnection: true,
|
||||
oauthStorage: storage,
|
||||
|
|
@ -42,15 +49,117 @@ describe("clearServerOAuthState", () => {
|
|||
|
||||
expect(cleared).toBe(true);
|
||||
expect(clearOAuthTokens).toHaveBeenCalledTimes(1);
|
||||
expect(clearOAuthTokens).toHaveBeenCalledWith({ revoke: true });
|
||||
});
|
||||
|
||||
it("returns false for stdio servers", async () => {
|
||||
it("returns cleared: false for stdio servers", async () => {
|
||||
await expect(
|
||||
clearServerOAuthState({
|
||||
config: { type: "stdio", command: "node", args: [] },
|
||||
isActiveConnection: false,
|
||||
oauthStorage: storage,
|
||||
}),
|
||||
).resolves.toBe(false);
|
||||
).resolves.toEqual({ cleared: false });
|
||||
});
|
||||
|
||||
// #2144 — the opt-out has to reach the live client, since that is where the
|
||||
// RFC 7009 request is actually made.
|
||||
it("forwards revoke: false to the live client", async () => {
|
||||
const clearOAuthTokens = vi.fn<InspectorClient["clearOAuthTokens"]>(
|
||||
async () => skipped(),
|
||||
);
|
||||
|
||||
await clearServerOAuthState({
|
||||
config: { type: "streamable-http", url: SERVER_URL },
|
||||
inspectorClient: { clearOAuthTokens },
|
||||
isActiveConnection: true,
|
||||
oauthStorage: storage,
|
||||
revoke: false,
|
||||
});
|
||||
|
||||
expect(clearOAuthTokens).toHaveBeenCalledWith({ revoke: false });
|
||||
});
|
||||
|
||||
// #2144 — the non-active path revokes from the store directly. The snapshot
|
||||
// is taken before the clear (after it there is no token, client id or cached
|
||||
// metadata to build a request from), but the clear itself runs before the
|
||||
// network — so by the time the request goes out the store is already empty.
|
||||
it("clears before sending, using the snapshot it took first", async () => {
|
||||
await storage.saveTokens(
|
||||
SERVER_URL,
|
||||
{
|
||||
access_token: "tok",
|
||||
token_type: "Bearer",
|
||||
refresh_token: "refresh-tok",
|
||||
},
|
||||
// Issuer-bound and matching the metadata below: an unkeyed grant records
|
||||
// no authorization server and is deliberately refused.
|
||||
{ issuer: "https://as.example.com" },
|
||||
);
|
||||
await storage.saveServerMetadata(SERVER_URL, {
|
||||
issuer: "https://as.example.com",
|
||||
authorization_endpoint: "https://as.example.com/authorize",
|
||||
token_endpoint: "https://as.example.com/token",
|
||||
revocation_endpoint: "https://as.example.com/revoke",
|
||||
response_types_supported: ["code"],
|
||||
});
|
||||
|
||||
let tokensAtRequestTime: unknown = "unset";
|
||||
const fetchFn = vi.fn<typeof fetch>(async () => {
|
||||
tokensAtRequestTime = await storage.getTokens(SERVER_URL);
|
||||
return new Response(null, { status: 200 });
|
||||
});
|
||||
|
||||
const { revocation } = await clearServerOAuthState({
|
||||
config: { type: "streamable-http", url: SERVER_URL },
|
||||
isActiveConnection: false,
|
||||
oauthStorage: storage,
|
||||
fetchFn,
|
||||
});
|
||||
|
||||
expect(revocation).toEqual({
|
||||
status: "revoked",
|
||||
tokenTypeHint: "refresh_token",
|
||||
endpoint: "https://as.example.com/revoke",
|
||||
});
|
||||
// The store was already empty when the request went out — that is the
|
||||
// ordering, and it is what stops a concurrently-written grant being wiped.
|
||||
expect(tokensAtRequestTime).toBeUndefined();
|
||||
expect(await storage.getTokens(SERVER_URL)).toBeUndefined();
|
||||
});
|
||||
|
||||
// Without a backend-proxied fetch the request would go out on the page
|
||||
// origin, where a real authorization server's missing CORS headers reject it.
|
||||
// Skipping is honest; attempting it would fail loudly on real deployments and
|
||||
// succeed on permissive ones.
|
||||
it("skips revocation on the non-active path when given no fetch", async () => {
|
||||
await storage.saveTokens(SERVER_URL, {
|
||||
access_token: "tok",
|
||||
token_type: "Bearer",
|
||||
});
|
||||
|
||||
const { revocation } = await clearServerOAuthState({
|
||||
config: { type: "streamable-http", url: SERVER_URL },
|
||||
isActiveConnection: false,
|
||||
oauthStorage: storage,
|
||||
});
|
||||
|
||||
expect(revocation).toEqual({ status: "skipped", reason: "disabled" });
|
||||
expect(await storage.getTokens(SERVER_URL)).toBeUndefined();
|
||||
});
|
||||
|
||||
it("skips the request on the non-active path when revoke is off", async () => {
|
||||
const fetchFn = vi.fn<typeof fetch>();
|
||||
|
||||
const { revocation } = await clearServerOAuthState({
|
||||
config: { type: "streamable-http", url: SERVER_URL },
|
||||
isActiveConnection: false,
|
||||
oauthStorage: storage,
|
||||
revoke: false,
|
||||
fetchFn,
|
||||
});
|
||||
|
||||
expect(fetchFn).not.toHaveBeenCalled();
|
||||
expect(revocation).toEqual({ status: "skipped", reason: "disabled" });
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -1,3 +1,8 @@
|
|||
import {
|
||||
clearAndPlanRevocation,
|
||||
executeOAuthRevocation,
|
||||
type TokenRevocationOutcome,
|
||||
} from "@inspector/core/auth/revocation.js";
|
||||
import type { OAuthStorage } from "@inspector/core/auth/storage.js";
|
||||
import { getOAuthServerUrl } from "@inspector/core/mcp/config.js";
|
||||
import type { InspectorClient } from "@inspector/core/mcp/inspectorClient.js";
|
||||
|
|
@ -10,25 +15,76 @@ export interface ClearServerOAuthStateParams {
|
|||
isActiveConnection: boolean;
|
||||
/** Shared web OAuth store; required so clear hits the same blob as connect. */
|
||||
oauthStorage: OAuthStorage;
|
||||
/**
|
||||
* Whether to revoke the grant at the authorization server (RFC 7009, #2144).
|
||||
* Defaults to on. Two callers turn it off: a server whose settings
|
||||
* opted out, and `lost_authorization_state` recovery — that path clears a
|
||||
* half-finished flow in order to retry it, so there is no completed grant to
|
||||
* revoke and the request would be noise at best.
|
||||
*/
|
||||
revoke?: boolean;
|
||||
/**
|
||||
* Fetch used for the revocation POST when this server is **not** the active
|
||||
* connection (there is no live client to borrow one from). In the browser
|
||||
* this must be the backend-proxied fetch the OAuth flow itself uses —
|
||||
* `globalThis.fetch` would put the request on the page's origin, where an
|
||||
* authorization server that serves no CORS headers rejects it. Omitting it
|
||||
* skips revocation on that path rather than sending a request that cannot
|
||||
* work.
|
||||
*/
|
||||
fetchFn?: typeof fetch;
|
||||
}
|
||||
|
||||
export interface ClearServerOAuthStateResult {
|
||||
/** False when the config has no OAuth server URL — nothing was cleared. */
|
||||
cleared: boolean;
|
||||
/** What the RFC 7009 leg did. Absent when nothing was cleared. */
|
||||
revocation?: TokenRevocationOutcome;
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear persisted OAuth state (tokens, DCR/CIMD client id, PKCE, etc.) for an
|
||||
* HTTP MCP server. When clearing the active connection, uses the live client so
|
||||
* in-memory flow state is reset too.
|
||||
* HTTP MCP server, revoking the grant at the authorization server. When
|
||||
* clearing the active connection, uses the live client so in-memory flow state
|
||||
* is reset too.
|
||||
*
|
||||
* Revocation is best-effort throughout — an authorization server advertising no
|
||||
* `revocation_endpoint` is untouched, and a failure is reported in the result
|
||||
* rather than thrown — so the local clear always finishes.
|
||||
*/
|
||||
export async function clearServerOAuthState(
|
||||
params: ClearServerOAuthStateParams,
|
||||
): Promise<boolean> {
|
||||
): Promise<ClearServerOAuthStateResult> {
|
||||
const serverUrl = getOAuthServerUrl(params.config);
|
||||
if (!serverUrl) {
|
||||
return false;
|
||||
return { cleared: false };
|
||||
}
|
||||
|
||||
const revoke = params.revoke !== false;
|
||||
|
||||
if (params.isActiveConnection && params.inspectorClient) {
|
||||
await params.inspectorClient.clearOAuthTokens();
|
||||
} else {
|
||||
await params.oauthStorage.clear(serverUrl);
|
||||
const revocation = await params.inspectorClient.clearOAuthTokens({
|
||||
revoke,
|
||||
});
|
||||
return { cleared: true, revocation };
|
||||
}
|
||||
return true;
|
||||
|
||||
// No proxied fetch on hand means no request we could usefully make, so the
|
||||
// leg is reported as skipped rather than attempted against the page origin.
|
||||
const fetchFn = params.fetchFn;
|
||||
// Takes the state and deletes it in ONE atomic storage step, then sends. The
|
||||
// clear must not wait on the network: this server can be inactive when the
|
||||
// call starts and complete a *fresh* authorization while the request is in
|
||||
// flight, at which point a later clear would delete the new credentials. The
|
||||
// session checks in `useOAuthRecovery` run after this helper returns and
|
||||
// cannot protect the store, so the ordering is what does (#2144).
|
||||
const plan = await clearAndPlanRevocation({
|
||||
serverUrl,
|
||||
storage: params.oauthStorage,
|
||||
enabled: revoke && fetchFn !== undefined,
|
||||
});
|
||||
const revocation: TokenRevocationOutcome = fetchFn
|
||||
? await executeOAuthRevocation(plan, { fetchFn })
|
||||
: { status: "skipped", reason: "disabled" };
|
||||
return { cleared: true, revocation };
|
||||
}
|
||||
|
|
|
|||
66
clients/web/src/lib/webProxiedFetch.test.ts
Normal file
66
clients/web/src/lib/webProxiedFetch.test.ts
Normal file
|
|
@ -0,0 +1,66 @@
|
|||
import { describe, it, expect, beforeEach, vi } from "vitest";
|
||||
|
||||
const { createRemoteFetchMock } = vi.hoisted(() => ({
|
||||
createRemoteFetchMock: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@inspector/core/mcp/remote/index.js", () => ({
|
||||
createRemoteFetch: createRemoteFetchMock,
|
||||
}));
|
||||
|
||||
import {
|
||||
getWebProxiedFetch,
|
||||
resetWebProxiedFetchCacheForTests,
|
||||
} from "./webProxiedFetch";
|
||||
|
||||
describe("getWebProxiedFetch", () => {
|
||||
beforeEach(() => {
|
||||
resetWebProxiedFetchCacheForTests();
|
||||
createRemoteFetchMock.mockReset();
|
||||
createRemoteFetchMock.mockImplementation(() => vi.fn());
|
||||
});
|
||||
|
||||
it("builds a remote fetch against the page origin and the API token", () => {
|
||||
getWebProxiedFetch("tok");
|
||||
expect(createRemoteFetchMock).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
baseUrl: `${window.location.protocol}//${window.location.host}`,
|
||||
authToken: "tok",
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("reuses the instance for the same origin and token", () => {
|
||||
const first = getWebProxiedFetch("tok");
|
||||
expect(getWebProxiedFetch("tok")).toBe(first);
|
||||
expect(createRemoteFetchMock).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("rebuilds when the token changes", () => {
|
||||
const first = getWebProxiedFetch("tok");
|
||||
expect(getWebProxiedFetch("other")).not.toBe(first);
|
||||
expect(createRemoteFetchMock).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it("throws when window is unavailable", () => {
|
||||
vi.stubGlobal("window", undefined);
|
||||
expect(() => getWebProxiedFetch()).toThrow(
|
||||
"getWebProxiedFetch requires a browser environment",
|
||||
);
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
it("forwards a fetch that reaches the injected base fetch", async () => {
|
||||
const inner = vi.fn<typeof fetch>(async () => new Response(null));
|
||||
createRemoteFetchMock.mockImplementation(
|
||||
({ fetchFn }: { fetchFn: typeof fetch }) => fetchFn,
|
||||
);
|
||||
const proxied = getWebProxiedFetch();
|
||||
const globalFetch = vi
|
||||
.spyOn(globalThis, "fetch")
|
||||
.mockImplementation(inner as typeof fetch);
|
||||
await proxied("https://example.com");
|
||||
expect(inner).toHaveBeenCalled();
|
||||
globalFetch.mockRestore();
|
||||
});
|
||||
});
|
||||
44
clients/web/src/lib/webProxiedFetch.ts
Normal file
44
clients/web/src/lib/webProxiedFetch.ts
Normal file
|
|
@ -0,0 +1,44 @@
|
|||
import { createRemoteFetch } from "@inspector/core/mcp/remote/index.js";
|
||||
|
||||
let cached: { cacheKey: string; fetchFn: typeof fetch } | undefined;
|
||||
|
||||
const defaultFetch: typeof fetch = (...args) => globalThis.fetch(...args);
|
||||
|
||||
/**
|
||||
* The backend-proxied fetch the browser must use for any request aimed at an
|
||||
* authorization server rather than at the Inspector itself.
|
||||
*
|
||||
* `createWebEnvironment` hands the same thing to `InspectorClient`, so an OAuth
|
||||
* request made through a live client already travels this way. This exists for
|
||||
* the paths that have no client to borrow one from — clearing (and revoking)
|
||||
* the stored OAuth state of a server that is not the active connection.
|
||||
*
|
||||
* Going direct is not an option there: an authorization server serves no CORS
|
||||
* headers for a page origin it has never heard of, so `globalThis.fetch` would
|
||||
* fail on nearly every real deployment while working on a permissive one — the
|
||||
* worst shape of bug to carry.
|
||||
*
|
||||
* Cached as a cache of one, keyed the same way `getWebRemoteOAuthStorage` keys
|
||||
* its store: the web app has a stable origin and a page-lifetime API token, so
|
||||
* the key does not change within a session.
|
||||
*/
|
||||
export function getWebProxiedFetch(authToken?: string): typeof fetch {
|
||||
if (typeof window === "undefined") {
|
||||
throw new Error("getWebProxiedFetch requires a browser environment");
|
||||
}
|
||||
const baseUrl = `${window.location.protocol}//${window.location.host}`;
|
||||
const cacheKey = `${baseUrl}\0${authToken ?? ""}`;
|
||||
if (cached?.cacheKey === cacheKey) {
|
||||
return cached.fetchFn;
|
||||
}
|
||||
cached = {
|
||||
cacheKey,
|
||||
fetchFn: createRemoteFetch({ baseUrl, authToken, fetchFn: defaultFetch }),
|
||||
};
|
||||
return cached.fetchFn;
|
||||
}
|
||||
|
||||
/** @internal Vitest isolation */
|
||||
export function resetWebProxiedFetchCacheForTests(): void {
|
||||
cached = undefined;
|
||||
}
|
||||
|
|
@ -57,6 +57,7 @@ function createStorage(
|
|||
clearServerMetadata: vi.fn(),
|
||||
clearIdpSession: vi.fn(),
|
||||
clearEnterpriseManagedResourceServers: vi.fn(),
|
||||
takeRevocationSnapshot: vi.fn().mockResolvedValue({ byIssuer: {} }),
|
||||
getCodeVerifier: vi.fn(),
|
||||
getDiscoveryState: vi.fn().mockResolvedValue(undefined),
|
||||
saveDiscoveryState: vi.fn(),
|
||||
|
|
|
|||
|
|
@ -74,6 +74,7 @@ function createMemoryStorage(
|
|||
saveScope: vi.fn(),
|
||||
clear: vi.fn(),
|
||||
clearEnterpriseManagedResourceServers: vi.fn(),
|
||||
takeRevocationSnapshot: vi.fn().mockResolvedValue({ byIssuer: {} }),
|
||||
} as unknown as OAuthStorage;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -25,6 +25,7 @@ describe("idpSession", () => {
|
|||
clearIdpSession: vi.fn(),
|
||||
clear: vi.fn(),
|
||||
clearEnterpriseManagedResourceServers: vi.fn(),
|
||||
takeRevocationSnapshot: vi.fn().mockResolvedValue({ byIssuer: {} }),
|
||||
} as unknown as OAuthStorage;
|
||||
});
|
||||
|
||||
|
|
|
|||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Reference in a new issue