feat(web): list-changed handling — indicator, pull-on-demand default, and per-server auto-refresh opt-in (#1402) (#1443)
* feat(web): wire the listChanged indicator across all screens (#1402) The per-screen list-changed indicator was hardcoded false in InspectorView for Tools, Apps, Prompts, and Resources, so it could never light up. Source it from the managed-state layer instead. Each ManagedXState now tracks a listChanged flag: set true when its */list_changed notification arrives (the existing auto-refresh leaves it set), exposed via getListChanged()/a listChangedChange event, and cleared by clearListChanged() or on disconnect. The useManagedX hooks surface the flag and clear it on a user-initiated refresh (the indicator's Refresh button = acknowledge). App threads the three flags to InspectorView, which maps them to the screens — Apps shares the tools flag since apps are a filtered view of tools. Tests cover set/clear/disconnect at the state-manager layer, the hook subscription + refresh-clears behavior, and the InspectorView routing (including Apps sharing the tools flag and no cross-wiring between screens). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix(core): close clear-after-await race in managed-list refresh (#1402 review) The useManagedX refresh() callbacks cleared the listChanged flag AFTER awaiting the fetch. If a */list_changed arrived mid-fetch, the state re-set the flag (and auto-refreshed), then the trailing clear wiped that genuinely-new signal. Clear BEFORE the await instead: the user's refresh acknowledges only the change in hand, and a mid-fetch change re-sets the flag and survives. Adds a race regression test on the tools hook. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix(web): drop duplicate Refresh on the Apps screen (#1402) AppControls uniquely had a standalone toolbar Refresh button AND the ListChangedIndicator on a separate row, so once the indicator could actually appear (this PR), the Apps screen showed two Refresh buttons. Match the other three controls (Tools/Prompts/Resources): the indicator is the single header-right refresh affordance. Updates AppControls and AppsScreen tests to assert exactly one Refresh (and none when the list hasn't changed). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * feat(core): stop auto-invoking */list on list_changed; user pulls via Refresh (#1402) With auto-refresh on, the list was already current by the time the list-changed indicator appeared, making its Refresh button moot. Now the managed Tools/Prompts/Resources state managers only flag the change on */list_changed (they no longer auto-refresh); the list stays as-is until the user clicks Refresh, which fetches the new list and clears the indicator. The connect-time initial load is unchanged. Resource templates (no own indicator, part of the Resources screen) also stop auto-refreshing; onRefreshResources now refreshes both resources and templates, since a single resources/list_changed covers both. Tasks are left auto-refreshing — they have no list-changed indicator and need live status updates. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * docs(core): correct ManagedResourceTemplatesState header comments (#1402 review) The class no longer subscribes to resourceTemplatesListChanged; update the stale file/class docstrings that still claimed it syncs on that notification to reflect connect + statusChange only. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * feat(web): add per-server 'Auto Refresh on List Changed Notifications' option (#1402) New Options section in ServerSettingsForm with a single checkbox. When checked, the managed Tools/Prompts/Resources (and resource templates) state auto-refreshes on */list_changed notifications; when unchecked (default) the notification only lights the list-changed indicator and the user pulls via Refresh. Plumbing: new optional autoRefreshOnListChanged on InspectorServerSettings and StoredMCPServer, round-tripped through serverList (lifted with a false default, persisted only when true, added to the exhaustive field-key map); surfaced from InspectorClient via a new getServerSettings() on the protocol; the state managers read it live at notification time to decide auto-refresh vs flag-only. Resource templates re-subscribe to their list_changed but only auto-refresh when the option is on (they have no indicator of their own). Tests cover the round-trip (true persists, false omitted), the form section (render/checked/toggle), and each state manager's on/off branch. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix(server): persist autoRefreshOnListChanged through validateSettings (#1402) The PUT /api/servers write path runs settings through validateSettings, which rebuilds the value from explicitly-named fields and silently drops unknown keys — so autoRefreshOnListChanged never reached disk (the serverList round-trip test passed because it calls inspectorSettingsToStoredFields directly, bypassing this path). Validate it as an optional boolean and include it in the built value; false is omitted on disk by inspectorSettingsToStoredFields as before. Adds integration tests over the real PUT path: true persists, false is omitted, and a non-boolean is rejected (400). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * feat(web): default the Server Settings modal to the Options section (#1402) Open 'Options' instead of 'Custom Headers' when the Server Settings modal first opens, so the new Auto Refresh toggle is the first thing surfaced. Updates the modal default-expanded section, the modal test's initial-state assertions, the three header tests (now expand 'Custom Headers' first since it's no longer open by default), and the story defaults. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * chore: drop accidentally-committed Inspector history dump; gitignore the class 83076441 swept an exported Inspector message-history JSON (manual-testing artifact at the repo root) into the tree via git add -A. Untrack it and extend the existing repo-root debug-artifact .gitignore block (alongside /*.png) to cover /inspector-history-*.json so these can't be re-added. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
9b558afee1
commit
b0a024269e
36 changed files with 1109 additions and 93 deletions
6
.gitignore
vendored
6
.gitignore
vendored
|
|
@ -8,6 +8,8 @@ test-servers/build
|
||||||
.env
|
.env
|
||||||
/.playwright-mcp/
|
/.playwright-mcp/
|
||||||
|
|
||||||
# Local debugging screenshots dropped at the repo root (e.g. attached to
|
# Local debugging artifacts dropped at the repo root (e.g. attached to
|
||||||
# Claude Code conversations). Should never be part of the source tree.
|
# Claude Code conversations): screenshots and exported Inspector history
|
||||||
|
# dumps. Should never be part of the source tree.
|
||||||
/*.png
|
/*.png
|
||||||
|
/inspector-history-*.json
|
||||||
|
|
|
||||||
|
|
@ -183,7 +183,10 @@ vi.mock("@inspector/core/react/useManagedResources.js", () => ({
|
||||||
useManagedResources: vi.fn(() => ({ resources: [], refresh: vi.fn() })),
|
useManagedResources: vi.fn(() => ({ resources: [], refresh: vi.fn() })),
|
||||||
}));
|
}));
|
||||||
vi.mock("@inspector/core/react/useManagedResourceTemplates.js", () => ({
|
vi.mock("@inspector/core/react/useManagedResourceTemplates.js", () => ({
|
||||||
useManagedResourceTemplates: vi.fn(() => ({ resourceTemplates: [] })),
|
useManagedResourceTemplates: vi.fn(() => ({
|
||||||
|
resourceTemplates: [],
|
||||||
|
refresh: vi.fn(),
|
||||||
|
})),
|
||||||
}));
|
}));
|
||||||
vi.mock("@inspector/core/react/useManagedRequestorTasks.js", () => ({
|
vi.mock("@inspector/core/react/useManagedRequestorTasks.js", () => ({
|
||||||
useManagedRequestorTasks: vi.fn(() => ({
|
useManagedRequestorTasks: vi.fn(() => ({
|
||||||
|
|
|
||||||
|
|
@ -277,6 +277,7 @@ const EMPTY_SETTINGS: InspectorServerSettings = {
|
||||||
connectionTimeout: 0,
|
connectionTimeout: 0,
|
||||||
requestTimeout: 0,
|
requestTimeout: 0,
|
||||||
taskTtl: DEFAULT_TASK_TTL_MS,
|
taskTtl: DEFAULT_TASK_TTL_MS,
|
||||||
|
autoRefreshOnListChanged: false,
|
||||||
roots: [],
|
roots: [],
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
@ -602,22 +603,23 @@ function App() {
|
||||||
instructions,
|
instructions,
|
||||||
protocolVersion,
|
protocolVersion,
|
||||||
} = useInspectorClient(inspectorClient);
|
} = useInspectorClient(inspectorClient);
|
||||||
const { tools, refresh: refreshTools } = useManagedTools(
|
const {
|
||||||
inspectorClient,
|
tools,
|
||||||
managedToolsState,
|
listChanged: toolsListChanged,
|
||||||
);
|
refresh: refreshTools,
|
||||||
const { prompts, refresh: refreshPrompts } = useManagedPrompts(
|
} = useManagedTools(inspectorClient, managedToolsState);
|
||||||
inspectorClient,
|
const {
|
||||||
managedPromptsState,
|
prompts,
|
||||||
);
|
listChanged: promptsListChanged,
|
||||||
const { resources, refresh: refreshResources } = useManagedResources(
|
refresh: refreshPrompts,
|
||||||
inspectorClient,
|
} = useManagedPrompts(inspectorClient, managedPromptsState);
|
||||||
managedResourcesState,
|
const {
|
||||||
);
|
resources,
|
||||||
const { resourceTemplates } = useManagedResourceTemplates(
|
listChanged: resourcesListChanged,
|
||||||
inspectorClient,
|
refresh: refreshResources,
|
||||||
managedResourceTemplatesState,
|
} = useManagedResources(inspectorClient, managedResourcesState);
|
||||||
);
|
const { resourceTemplates, refresh: refreshResourceTemplates } =
|
||||||
|
useManagedResourceTemplates(inspectorClient, managedResourceTemplatesState);
|
||||||
const {
|
const {
|
||||||
tasks,
|
tasks,
|
||||||
refresh: refreshTasks,
|
refresh: refreshTasks,
|
||||||
|
|
@ -1677,8 +1679,12 @@ function App() {
|
||||||
void refreshPrompts();
|
void refreshPrompts();
|
||||||
}, [refreshPrompts]);
|
}, [refreshPrompts]);
|
||||||
const onRefreshResources = useCallback(() => {
|
const onRefreshResources = useCallback(() => {
|
||||||
|
// Refresh both lists shown on the Resources screen. A single
|
||||||
|
// `notifications/resources/list_changed` covers resources and templates,
|
||||||
|
// and neither auto-refreshes anymore, so the user's Refresh pulls both.
|
||||||
void refreshResources();
|
void refreshResources();
|
||||||
}, [refreshResources]);
|
void refreshResourceTemplates();
|
||||||
|
}, [refreshResources, refreshResourceTemplates]);
|
||||||
const onRefreshTasks = useCallback(() => {
|
const onRefreshTasks = useCallback(() => {
|
||||||
// Surface list failures (e.g. the MAX_PAGES guard or a tasks/list error)
|
// Surface list failures (e.g. the MAX_PAGES guard or a tasks/list error)
|
||||||
// instead of letting the rejected promise go unhandled.
|
// instead of letting the rejected promise go unhandled.
|
||||||
|
|
@ -2095,6 +2101,9 @@ function App() {
|
||||||
prompts={prompts}
|
prompts={prompts}
|
||||||
resources={resources}
|
resources={resources}
|
||||||
resourceTemplates={resourceTemplates}
|
resourceTemplates={resourceTemplates}
|
||||||
|
toolsListChanged={toolsListChanged}
|
||||||
|
promptsListChanged={promptsListChanged}
|
||||||
|
resourcesListChanged={resourcesListChanged}
|
||||||
subscriptions={subscriptions}
|
subscriptions={subscriptions}
|
||||||
logs={logs}
|
logs={logs}
|
||||||
tasks={tasks}
|
tasks={tasks}
|
||||||
|
|
|
||||||
|
|
@ -119,33 +119,28 @@ describe("AppControls", () => {
|
||||||
expect(onSelectApp).not.toHaveBeenCalled();
|
expect(onSelectApp).not.toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("invokes onRefreshList when the toolbar Refresh button is clicked", async () => {
|
it("does not show the list-changed indicator (and no Refresh) when listChanged is false", () => {
|
||||||
const user = userEvent.setup();
|
|
||||||
const onRefreshList = vi.fn();
|
|
||||||
renderWithMantine(
|
|
||||||
<AppControls {...baseProps} onRefreshList={onRefreshList} />,
|
|
||||||
);
|
|
||||||
await user.click(screen.getByRole("button", { name: "Refresh" }));
|
|
||||||
expect(onRefreshList).toHaveBeenCalledTimes(1);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("does not show the list-changed indicator when listChanged is false", () => {
|
|
||||||
renderWithMantine(<AppControls {...baseProps} />);
|
renderWithMantine(<AppControls {...baseProps} />);
|
||||||
expect(screen.queryByText("List updated")).not.toBeInTheDocument();
|
expect(screen.queryByText("List updated")).not.toBeInTheDocument();
|
||||||
|
// The indicator is the sole refresh affordance, so there's no standalone
|
||||||
|
// toolbar Refresh button to duplicate it.
|
||||||
|
expect(
|
||||||
|
screen.queryByRole("button", { name: "Refresh" }),
|
||||||
|
).not.toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("shows the list-changed indicator when listChanged is true", async () => {
|
it("shows a single Refresh via the list-changed indicator when listChanged is true", async () => {
|
||||||
const user = userEvent.setup();
|
const user = userEvent.setup();
|
||||||
const onRefreshList = vi.fn();
|
const onRefreshList = vi.fn();
|
||||||
renderWithMantine(
|
renderWithMantine(
|
||||||
<AppControls {...baseProps} listChanged onRefreshList={onRefreshList} />,
|
<AppControls {...baseProps} listChanged onRefreshList={onRefreshList} />,
|
||||||
);
|
);
|
||||||
expect(screen.getByText("List updated")).toBeInTheDocument();
|
expect(screen.getByText("List updated")).toBeInTheDocument();
|
||||||
// Both the toolbar button and the list-changed indicator's button render
|
// Exactly one Refresh button — the indicator's — not a duplicate alongside
|
||||||
// as "Refresh"; either one should drive onRefreshList.
|
// a standalone toolbar button.
|
||||||
const refreshButtons = screen.getAllByRole("button", { name: "Refresh" });
|
const refreshButtons = screen.getAllByRole("button", { name: "Refresh" });
|
||||||
expect(refreshButtons).toHaveLength(2);
|
expect(refreshButtons).toHaveLength(1);
|
||||||
await user.click(refreshButtons[1]);
|
await user.click(refreshButtons[0]);
|
||||||
expect(onRefreshList).toHaveBeenCalledTimes(1);
|
expect(onRefreshList).toHaveBeenCalledTimes(1);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,4 @@
|
||||||
import {
|
import {
|
||||||
Button,
|
|
||||||
Group,
|
Group,
|
||||||
ScrollArea,
|
ScrollArea,
|
||||||
Stack,
|
Stack,
|
||||||
|
|
@ -27,11 +26,6 @@ export interface AppControlsProps {
|
||||||
const LIST_MAX_HEIGHT =
|
const LIST_MAX_HEIGHT =
|
||||||
"calc(100vh - var(--app-shell-header-height, 0px) - var(--mantine-spacing-xl) * 2 - 220px)";
|
"calc(100vh - var(--app-shell-header-height, 0px) - var(--mantine-spacing-xl) * 2 - 220px)";
|
||||||
|
|
||||||
const ToolbarButton = Button.withProps({
|
|
||||||
variant: "subtle",
|
|
||||||
size: "sm",
|
|
||||||
});
|
|
||||||
|
|
||||||
const EmptyState = Text.withProps({
|
const EmptyState = Text.withProps({
|
||||||
c: "dimmed",
|
c: "dimmed",
|
||||||
ta: "center",
|
ta: "center",
|
||||||
|
|
@ -61,9 +55,8 @@ export function AppControls({
|
||||||
<Stack gap="sm">
|
<Stack gap="sm">
|
||||||
<Group justify="space-between">
|
<Group justify="space-between">
|
||||||
<Title order={4}>MCP Apps ({tools.length})</Title>
|
<Title order={4}>MCP Apps ({tools.length})</Title>
|
||||||
<ToolbarButton onClick={onRefreshList}>Refresh</ToolbarButton>
|
<ListChangedIndicator visible={listChanged} onRefresh={onRefreshList} />
|
||||||
</Group>
|
</Group>
|
||||||
<ListChangedIndicator visible={listChanged} onRefresh={onRefreshList} />
|
|
||||||
<TextInput
|
<TextInput
|
||||||
placeholder="Search apps..."
|
placeholder="Search apps..."
|
||||||
value={searchText}
|
value={searchText}
|
||||||
|
|
|
||||||
|
|
@ -90,6 +90,12 @@ function InteractiveRender(args: ServerSettingsFormProps) {
|
||||||
settings: { ...args.settings, [field]: value },
|
settings: { ...args.settings, [field]: value },
|
||||||
});
|
});
|
||||||
}}
|
}}
|
||||||
|
onAutoRefreshChange={(value) => {
|
||||||
|
args.onAutoRefreshChange(value);
|
||||||
|
updateArgs({
|
||||||
|
settings: { ...args.settings, autoRefreshOnListChanged: value },
|
||||||
|
});
|
||||||
|
}}
|
||||||
onOAuthChange={(oauth) => {
|
onOAuthChange={(oauth) => {
|
||||||
args.onOAuthChange(oauth);
|
args.onOAuthChange(oauth);
|
||||||
updateArgs({
|
updateArgs({
|
||||||
|
|
@ -110,7 +116,7 @@ const meta: Meta<typeof ServerSettingsForm> = {
|
||||||
component: ServerSettingsForm,
|
component: ServerSettingsForm,
|
||||||
render: InteractiveRender,
|
render: InteractiveRender,
|
||||||
args: {
|
args: {
|
||||||
expandedSections: ["headers"],
|
expandedSections: ["options"],
|
||||||
onExpandedSectionsChange: fn(),
|
onExpandedSectionsChange: fn(),
|
||||||
onAddHeader: fn(),
|
onAddHeader: fn(),
|
||||||
onRemoveHeader: fn(),
|
onRemoveHeader: fn(),
|
||||||
|
|
@ -119,6 +125,7 @@ const meta: Meta<typeof ServerSettingsForm> = {
|
||||||
onRemoveMetadata: fn(),
|
onRemoveMetadata: fn(),
|
||||||
onMetadataChange: fn(),
|
onMetadataChange: fn(),
|
||||||
onTimeoutChange: fn(),
|
onTimeoutChange: fn(),
|
||||||
|
onAutoRefreshChange: fn(),
|
||||||
onOAuthChange: fn(),
|
onOAuthChange: fn(),
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
@ -134,6 +141,7 @@ export const DefaultSettings: Story = {
|
||||||
|
|
||||||
export const WithHeaders: Story = {
|
export const WithHeaders: Story = {
|
||||||
args: {
|
args: {
|
||||||
|
expandedSections: ["headers"],
|
||||||
settings: {
|
settings: {
|
||||||
...defaultSettings,
|
...defaultSettings,
|
||||||
headers: [
|
headers: [
|
||||||
|
|
|
||||||
|
|
@ -45,6 +45,7 @@ const baseHandlers = {
|
||||||
onRemoveMetadata: vi.fn(),
|
onRemoveMetadata: vi.fn(),
|
||||||
onMetadataChange: vi.fn(),
|
onMetadataChange: vi.fn(),
|
||||||
onTimeoutChange: vi.fn(),
|
onTimeoutChange: vi.fn(),
|
||||||
|
onAutoRefreshChange: vi.fn(),
|
||||||
onOAuthChange: vi.fn(),
|
onOAuthChange: vi.fn(),
|
||||||
onAddRoot: vi.fn(),
|
onAddRoot: vi.fn(),
|
||||||
onRemoveRoot: vi.fn(),
|
onRemoveRoot: vi.fn(),
|
||||||
|
|
@ -236,6 +237,54 @@ describe("ServerSettingsForm", () => {
|
||||||
expect(screen.getByLabelText(/Task TTL/)).toHaveValue("45000 ms");
|
expect(screen.getByLabelText(/Task TTL/)).toHaveValue("45000 ms");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("renders the Options section with the Auto Refresh checkbox unchecked by default", () => {
|
||||||
|
renderWithMantine(
|
||||||
|
<ServerSettingsForm
|
||||||
|
{...baseHandlers}
|
||||||
|
settings={emptySettings}
|
||||||
|
expandedSections={["options"]}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
const checkbox = screen.getByRole("checkbox", {
|
||||||
|
name: /Auto Refresh on List Changed Notifications/,
|
||||||
|
});
|
||||||
|
expect(checkbox).not.toBeChecked();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("reflects autoRefreshOnListChanged=true as a checked box", () => {
|
||||||
|
renderWithMantine(
|
||||||
|
<ServerSettingsForm
|
||||||
|
{...baseHandlers}
|
||||||
|
settings={{ ...emptySettings, autoRefreshOnListChanged: true }}
|
||||||
|
expandedSections={["options"]}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
expect(
|
||||||
|
screen.getByRole("checkbox", {
|
||||||
|
name: /Auto Refresh on List Changed Notifications/,
|
||||||
|
}),
|
||||||
|
).toBeChecked();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("invokes onAutoRefreshChange when the Auto Refresh checkbox is toggled", async () => {
|
||||||
|
const user = userEvent.setup();
|
||||||
|
const onAutoRefreshChange = vi.fn();
|
||||||
|
renderWithMantine(
|
||||||
|
<ServerSettingsForm
|
||||||
|
{...baseHandlers}
|
||||||
|
onAutoRefreshChange={onAutoRefreshChange}
|
||||||
|
settings={emptySettings}
|
||||||
|
expandedSections={["options"]}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
await user.click(
|
||||||
|
screen.getByRole("checkbox", {
|
||||||
|
name: /Auto Refresh on List Changed Notifications/,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
expect(onAutoRefreshChange).toHaveBeenCalledWith(true);
|
||||||
|
});
|
||||||
|
|
||||||
it("invokes onOAuthChange when typing in client id", async () => {
|
it("invokes onOAuthChange when typing in client id", async () => {
|
||||||
const user = userEvent.setup();
|
const user = userEvent.setup();
|
||||||
const onOAuthChange = vi.fn();
|
const onOAuthChange = vi.fn();
|
||||||
|
|
|
||||||
|
|
@ -2,6 +2,7 @@ import {
|
||||||
Accordion,
|
Accordion,
|
||||||
ActionIcon,
|
ActionIcon,
|
||||||
Button,
|
Button,
|
||||||
|
Checkbox,
|
||||||
Group,
|
Group,
|
||||||
NumberInput,
|
NumberInput,
|
||||||
Stack,
|
Stack,
|
||||||
|
|
@ -15,6 +16,7 @@ import type {
|
||||||
import type { Root } from "@modelcontextprotocol/sdk/types.js";
|
import type { Root } from "@modelcontextprotocol/sdk/types.js";
|
||||||
|
|
||||||
export type ServerSettingsSection =
|
export type ServerSettingsSection =
|
||||||
|
| "options"
|
||||||
| "headers"
|
| "headers"
|
||||||
| "metadata"
|
| "metadata"
|
||||||
| "timeouts"
|
| "timeouts"
|
||||||
|
|
@ -35,6 +37,7 @@ export interface ServerSettingsFormProps {
|
||||||
field: "connectionTimeout" | "requestTimeout" | "taskTtl",
|
field: "connectionTimeout" | "requestTimeout" | "taskTtl",
|
||||||
value: number,
|
value: number,
|
||||||
) => void;
|
) => void;
|
||||||
|
onAutoRefreshChange: (value: boolean) => void;
|
||||||
onOAuthChange: (oauth: OAuthSettings) => void;
|
onOAuthChange: (oauth: OAuthSettings) => void;
|
||||||
onAddRoot: () => void;
|
onAddRoot: () => void;
|
||||||
onRemoveRoot: (index: number) => void;
|
onRemoveRoot: (index: number) => void;
|
||||||
|
|
@ -143,6 +146,7 @@ export function ServerSettingsForm({
|
||||||
onRemoveMetadata,
|
onRemoveMetadata,
|
||||||
onMetadataChange,
|
onMetadataChange,
|
||||||
onTimeoutChange,
|
onTimeoutChange,
|
||||||
|
onAutoRefreshChange,
|
||||||
onOAuthChange,
|
onOAuthChange,
|
||||||
onAddRoot,
|
onAddRoot,
|
||||||
onRemoveRoot,
|
onRemoveRoot,
|
||||||
|
|
@ -173,6 +177,20 @@ export function ServerSettingsForm({
|
||||||
}
|
}
|
||||||
variant="separated"
|
variant="separated"
|
||||||
>
|
>
|
||||||
|
<Accordion.Item value="options">
|
||||||
|
<Accordion.Control>Options</Accordion.Control>
|
||||||
|
<Accordion.Panel>
|
||||||
|
<Stack gap="md">
|
||||||
|
<Checkbox
|
||||||
|
label="Auto Refresh on List Changed Notifications"
|
||||||
|
description="When checked, tool/prompt/resource lists refresh automatically when the server sends a */list_changed notification. When unchecked, the list-changed indicator appears and you refresh on demand."
|
||||||
|
checked={settings.autoRefreshOnListChanged ?? false}
|
||||||
|
onChange={(e) => onAutoRefreshChange(e.currentTarget.checked)}
|
||||||
|
/>
|
||||||
|
</Stack>
|
||||||
|
</Accordion.Panel>
|
||||||
|
</Accordion.Item>
|
||||||
|
|
||||||
<Accordion.Item value="headers">
|
<Accordion.Item value="headers">
|
||||||
<Accordion.Control>Custom Headers</Accordion.Control>
|
<Accordion.Control>Custom Headers</Accordion.Control>
|
||||||
<Accordion.Panel>
|
<Accordion.Panel>
|
||||||
|
|
|
||||||
|
|
@ -71,7 +71,7 @@ describe("ServerSettingsModal", () => {
|
||||||
expect(onClose).toHaveBeenCalled();
|
expect(onClose).toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("calls onSettingsChange when adding a header", async () => {
|
it("calls onSettingsChange when adding a header after expanding the section", async () => {
|
||||||
const user = userEvent.setup();
|
const user = userEvent.setup();
|
||||||
const onSettingsChange = vi.fn();
|
const onSettingsChange = vi.fn();
|
||||||
renderWithMantine(
|
renderWithMantine(
|
||||||
|
|
@ -82,6 +82,7 @@ describe("ServerSettingsModal", () => {
|
||||||
onSettingsChange={onSettingsChange}
|
onSettingsChange={onSettingsChange}
|
||||||
/>,
|
/>,
|
||||||
);
|
);
|
||||||
|
await user.click(screen.getByRole("button", { name: "Custom Headers" }));
|
||||||
await user.click(screen.getByRole("button", { name: "+ Add Header" }));
|
await user.click(screen.getByRole("button", { name: "+ Add Header" }));
|
||||||
expect(onSettingsChange).toHaveBeenCalledWith({
|
expect(onSettingsChange).toHaveBeenCalledWith({
|
||||||
...emptySettings,
|
...emptySettings,
|
||||||
|
|
@ -100,6 +101,7 @@ describe("ServerSettingsModal", () => {
|
||||||
onSettingsChange={onSettingsChange}
|
onSettingsChange={onSettingsChange}
|
||||||
/>,
|
/>,
|
||||||
);
|
);
|
||||||
|
await user.click(screen.getByRole("button", { name: "Custom Headers" }));
|
||||||
const removeButtons = screen.getAllByRole("button", { name: "X" });
|
const removeButtons = screen.getAllByRole("button", { name: "X" });
|
||||||
await user.click(removeButtons[0]);
|
await user.click(removeButtons[0]);
|
||||||
expect(onSettingsChange).toHaveBeenCalledWith({
|
expect(onSettingsChange).toHaveBeenCalledWith({
|
||||||
|
|
@ -119,6 +121,7 @@ describe("ServerSettingsModal", () => {
|
||||||
onSettingsChange={onSettingsChange}
|
onSettingsChange={onSettingsChange}
|
||||||
/>,
|
/>,
|
||||||
);
|
);
|
||||||
|
await user.click(screen.getByRole("button", { name: "Custom Headers" }));
|
||||||
const valueInput = screen.getByDisplayValue("Bearer abc");
|
const valueInput = screen.getByDisplayValue("Bearer abc");
|
||||||
await user.type(valueInput, "1");
|
await user.type(valueInput, "1");
|
||||||
expect(onSettingsChange).toHaveBeenCalled();
|
expect(onSettingsChange).toHaveBeenCalled();
|
||||||
|
|
@ -298,6 +301,7 @@ describe("ServerSettingsModal", () => {
|
||||||
onSettingsChange={vi.fn()}
|
onSettingsChange={vi.fn()}
|
||||||
/>,
|
/>,
|
||||||
);
|
);
|
||||||
|
const optionsControl = screen.getByRole("button", { name: "Options" });
|
||||||
const headersControl = screen.getByRole("button", {
|
const headersControl = screen.getByRole("button", {
|
||||||
name: "Custom Headers",
|
name: "Custom Headers",
|
||||||
});
|
});
|
||||||
|
|
@ -308,14 +312,15 @@ describe("ServerSettingsModal", () => {
|
||||||
const oauthControl = screen.getByRole("button", { name: "OAuth Settings" });
|
const oauthControl = screen.getByRole("button", { name: "OAuth Settings" });
|
||||||
const rootsControl = screen.getByRole("button", { name: "Roots" });
|
const rootsControl = screen.getByRole("button", { name: "Roots" });
|
||||||
|
|
||||||
// Initially only "headers" is expanded.
|
// Initially only "options" is expanded.
|
||||||
expect(headersControl.getAttribute("aria-expanded")).toBe("true");
|
expect(optionsControl.getAttribute("aria-expanded")).toBe("true");
|
||||||
expect(metadataControl.getAttribute("aria-expanded")).toBe("false");
|
expect(headersControl.getAttribute("aria-expanded")).toBe("false");
|
||||||
|
|
||||||
// Not every section is expanded → ListToggle starts in compact mode
|
// Not every section is expanded → ListToggle starts in compact mode
|
||||||
// and exposes "Expand all" as its aria-label.
|
// and exposes "Expand all" as its aria-label.
|
||||||
await user.click(screen.getByRole("button", { name: "Expand all" }));
|
await user.click(screen.getByRole("button", { name: "Expand all" }));
|
||||||
|
|
||||||
|
expect(headersControl.getAttribute("aria-expanded")).toBe("true");
|
||||||
expect(metadataControl.getAttribute("aria-expanded")).toBe("true");
|
expect(metadataControl.getAttribute("aria-expanded")).toBe("true");
|
||||||
expect(timeoutsControl.getAttribute("aria-expanded")).toBe("true");
|
expect(timeoutsControl.getAttribute("aria-expanded")).toBe("true");
|
||||||
expect(oauthControl.getAttribute("aria-expanded")).toBe("true");
|
expect(oauthControl.getAttribute("aria-expanded")).toBe("true");
|
||||||
|
|
@ -323,7 +328,7 @@ describe("ServerSettingsModal", () => {
|
||||||
|
|
||||||
// After expanding every section the toggle flips to "Collapse all".
|
// After expanding every section the toggle flips to "Collapse all".
|
||||||
await user.click(screen.getByRole("button", { name: "Collapse all" }));
|
await user.click(screen.getByRole("button", { name: "Collapse all" }));
|
||||||
|
expect(optionsControl.getAttribute("aria-expanded")).toBe("false");
|
||||||
expect(headersControl.getAttribute("aria-expanded")).toBe("false");
|
expect(headersControl.getAttribute("aria-expanded")).toBe("false");
|
||||||
expect(metadataControl.getAttribute("aria-expanded")).toBe("false");
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -11,6 +11,7 @@ import {
|
||||||
} from "../ServerSettingsForm/ServerSettingsForm";
|
} from "../ServerSettingsForm/ServerSettingsForm";
|
||||||
|
|
||||||
const ALL_SECTIONS: ServerSettingsSection[] = [
|
const ALL_SECTIONS: ServerSettingsSection[] = [
|
||||||
|
"options",
|
||||||
"headers",
|
"headers",
|
||||||
"metadata",
|
"metadata",
|
||||||
"timeouts",
|
"timeouts",
|
||||||
|
|
@ -33,7 +34,7 @@ export function ServerSettingsModal({
|
||||||
}: ServerSettingsModalProps) {
|
}: ServerSettingsModalProps) {
|
||||||
const [expandedSections, setExpandedSections] = useState<
|
const [expandedSections, setExpandedSections] = useState<
|
||||||
ServerSettingsSection[]
|
ServerSettingsSection[]
|
||||||
>(["headers"]);
|
>(["options"]);
|
||||||
|
|
||||||
const allExpanded = expandedSections.length === ALL_SECTIONS.length;
|
const allExpanded = expandedSections.length === ALL_SECTIONS.length;
|
||||||
|
|
||||||
|
|
@ -99,6 +100,10 @@ export function ServerSettingsModal({
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function handleAutoRefreshChange(value: boolean) {
|
||||||
|
onSettingsChange({ ...settings, autoRefreshOnListChanged: value });
|
||||||
|
}
|
||||||
|
|
||||||
function handleAddRoot() {
|
function handleAddRoot() {
|
||||||
onSettingsChange({
|
onSettingsChange({
|
||||||
...settings,
|
...settings,
|
||||||
|
|
@ -153,6 +158,7 @@ export function ServerSettingsModal({
|
||||||
onRemoveMetadata={handleRemoveMetadata}
|
onRemoveMetadata={handleRemoveMetadata}
|
||||||
onMetadataChange={handleMetadataChange}
|
onMetadataChange={handleMetadataChange}
|
||||||
onTimeoutChange={handleTimeoutChange}
|
onTimeoutChange={handleTimeoutChange}
|
||||||
|
onAutoRefreshChange={handleAutoRefreshChange}
|
||||||
onOAuthChange={handleOAuthChange}
|
onOAuthChange={handleOAuthChange}
|
||||||
onAddRoot={handleAddRoot}
|
onAddRoot={handleAddRoot}
|
||||||
onRemoveRoot={handleRemoveRoot}
|
onRemoveRoot={handleRemoveRoot}
|
||||||
|
|
|
||||||
|
|
@ -288,27 +288,26 @@ describe("AppsScreen", () => {
|
||||||
).toBeInTheDocument();
|
).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("renders the ListChangedIndicator when listChanged is true", async () => {
|
it("renders a single Refresh via the ListChangedIndicator when listChanged is true", async () => {
|
||||||
const user = userEvent.setup();
|
const user = userEvent.setup();
|
||||||
const onRefreshList = vi.fn();
|
const onRefreshList = vi.fn();
|
||||||
renderWithMantine(
|
renderWithMantine(
|
||||||
<AppsScreen {...buildProps({ listChanged: true, onRefreshList })} />,
|
<AppsScreen {...buildProps({ listChanged: true, onRefreshList })} />,
|
||||||
);
|
);
|
||||||
// The indicator and toolbar both render "Refresh" buttons; the indicator
|
// The indicator's Refresh is the only refresh affordance — no standalone
|
||||||
// is a sibling of the "List updated" label, so target it via that label.
|
// toolbar button duplicating it.
|
||||||
expect(screen.getByText("List updated")).toBeInTheDocument();
|
expect(screen.getByText("List updated")).toBeInTheDocument();
|
||||||
const refreshButtons = screen.getAllByRole("button", { name: "Refresh" });
|
const refreshButtons = screen.getAllByRole("button", { name: "Refresh" });
|
||||||
expect(refreshButtons).toHaveLength(2);
|
expect(refreshButtons).toHaveLength(1);
|
||||||
await user.click(refreshButtons[0]);
|
await user.click(refreshButtons[0]);
|
||||||
expect(onRefreshList).toHaveBeenCalledTimes(1);
|
expect(onRefreshList).toHaveBeenCalledTimes(1);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("invokes onRefreshList when the toolbar Refresh button is clicked", async () => {
|
it("shows no Refresh button when listChanged is false", () => {
|
||||||
const user = userEvent.setup();
|
renderWithMantine(<AppsScreen {...buildProps()} />);
|
||||||
const onRefreshList = vi.fn();
|
expect(
|
||||||
renderWithMantine(<AppsScreen {...buildProps({ onRefreshList })} />);
|
screen.queryByRole("button", { name: "Refresh" }),
|
||||||
await user.click(screen.getByRole("button", { name: "Refresh" }));
|
).not.toBeInTheDocument();
|
||||||
expect(onRefreshList).toHaveBeenCalledTimes(1);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it("renders the tool icon next to the header title when present", async () => {
|
it("renders the tool icon next to the header title when present", async () => {
|
||||||
|
|
|
||||||
|
|
@ -56,6 +56,9 @@ function makeProps(
|
||||||
prompts: [],
|
prompts: [],
|
||||||
resources: [],
|
resources: [],
|
||||||
resourceTemplates: [],
|
resourceTemplates: [],
|
||||||
|
toolsListChanged: false,
|
||||||
|
promptsListChanged: false,
|
||||||
|
resourcesListChanged: false,
|
||||||
subscriptions: [],
|
subscriptions: [],
|
||||||
logs: [],
|
logs: [],
|
||||||
tasks: [],
|
tasks: [],
|
||||||
|
|
@ -585,4 +588,104 @@ describe("InspectorView", () => {
|
||||||
await screen.findByRole("button", { name: "Expand all" }),
|
await screen.findByRole("button", { name: "Expand all" }),
|
||||||
).toBeInTheDocument();
|
).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("listChanged indicator wiring (#1402)", () => {
|
||||||
|
// The indicator only mounts on the active screen, so each case connects,
|
||||||
|
// navigates to the target tab, and asserts the "List updated" affordance.
|
||||||
|
async function gotoTab(tab: string) {
|
||||||
|
const user = userEvent.setup();
|
||||||
|
const tabSelect = await screen.findByDisplayValue("Servers");
|
||||||
|
await user.click(tabSelect);
|
||||||
|
await user.click(await screen.findByText(tab));
|
||||||
|
return user;
|
||||||
|
}
|
||||||
|
|
||||||
|
it("routes toolsListChanged to the Tools screen indicator", async () => {
|
||||||
|
renderWithMantine(
|
||||||
|
<InspectorView
|
||||||
|
{...makeProps({
|
||||||
|
servers: [sampleServer],
|
||||||
|
activeServer: "alpha",
|
||||||
|
connectionStatus: "connected",
|
||||||
|
initializeResult: connectedInit,
|
||||||
|
toolsListChanged: true,
|
||||||
|
})}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
await gotoTab("Tools");
|
||||||
|
expect(await screen.findByText("List updated")).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("shares the tools flag with the Apps screen (apps are filtered tools)", async () => {
|
||||||
|
renderWithMantine(
|
||||||
|
<InspectorView
|
||||||
|
{...makeProps({
|
||||||
|
servers: [sampleServer],
|
||||||
|
activeServer: "alpha",
|
||||||
|
connectionStatus: "connected",
|
||||||
|
initializeResult: connectedInit,
|
||||||
|
toolsListChanged: true,
|
||||||
|
})}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
await gotoTab("Apps");
|
||||||
|
expect(await screen.findByText("List updated")).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("routes promptsListChanged to the Prompts screen indicator", async () => {
|
||||||
|
renderWithMantine(
|
||||||
|
<InspectorView
|
||||||
|
{...makeProps({
|
||||||
|
servers: [sampleServer],
|
||||||
|
activeServer: "alpha",
|
||||||
|
connectionStatus: "connected",
|
||||||
|
initializeResult: connectedInit,
|
||||||
|
promptsListChanged: true,
|
||||||
|
})}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
await gotoTab("Prompts");
|
||||||
|
expect(await screen.findByText("List updated")).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("routes resourcesListChanged to the Resources screen indicator", async () => {
|
||||||
|
renderWithMantine(
|
||||||
|
<InspectorView
|
||||||
|
{...makeProps({
|
||||||
|
servers: [sampleServer],
|
||||||
|
activeServer: "alpha",
|
||||||
|
connectionStatus: "connected",
|
||||||
|
initializeResult: connectedInit,
|
||||||
|
resourcesListChanged: true,
|
||||||
|
})}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
await gotoTab("Resources");
|
||||||
|
expect(await screen.findByText("List updated")).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not show the indicator on a screen whose flag is false (no cross-wiring)", async () => {
|
||||||
|
renderWithMantine(
|
||||||
|
<InspectorView
|
||||||
|
{...makeProps({
|
||||||
|
servers: [sampleServer],
|
||||||
|
activeServer: "alpha",
|
||||||
|
connectionStatus: "connected",
|
||||||
|
initializeResult: connectedInit,
|
||||||
|
// Tools changed, but Prompts did not — the Prompts screen must
|
||||||
|
// stay quiet.
|
||||||
|
toolsListChanged: true,
|
||||||
|
promptsListChanged: false,
|
||||||
|
})}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
await gotoTab("Prompts");
|
||||||
|
// The Prompts screen has mounted (its heading is present)...
|
||||||
|
expect(
|
||||||
|
await screen.findByRole("heading", { name: "Prompts" }),
|
||||||
|
).toBeInTheDocument();
|
||||||
|
// ...but the indicator is not, since promptsListChanged is false.
|
||||||
|
expect(screen.queryByText("List updated")).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -201,6 +201,13 @@ export interface InspectorViewProps {
|
||||||
resources: Resource[];
|
resources: Resource[];
|
||||||
resourceTemplates: ResourceTemplate[];
|
resourceTemplates: ResourceTemplate[];
|
||||||
subscriptions: InspectorResourceSubscription[];
|
subscriptions: InspectorResourceSubscription[];
|
||||||
|
|
||||||
|
// "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;
|
||||||
logs: LogEntryData[];
|
logs: LogEntryData[];
|
||||||
tasks: Task[];
|
tasks: Task[];
|
||||||
progressByTaskId?: Record<string, TaskProgress>;
|
progressByTaskId?: Record<string, TaskProgress>;
|
||||||
|
|
@ -336,6 +343,9 @@ export function InspectorView({
|
||||||
prompts,
|
prompts,
|
||||||
resources,
|
resources,
|
||||||
resourceTemplates,
|
resourceTemplates,
|
||||||
|
toolsListChanged,
|
||||||
|
promptsListChanged,
|
||||||
|
resourcesListChanged,
|
||||||
subscriptions,
|
subscriptions,
|
||||||
logs,
|
logs,
|
||||||
tasks,
|
tasks,
|
||||||
|
|
@ -548,7 +558,7 @@ export function InspectorView({
|
||||||
tools={tools}
|
tools={tools}
|
||||||
callState={toolCallState}
|
callState={toolCallState}
|
||||||
ui={toolsUi}
|
ui={toolsUi}
|
||||||
listChanged={false}
|
listChanged={toolsListChanged}
|
||||||
serverSupportsTaskToolCalls={serverSupportsTaskToolCalls}
|
serverSupportsTaskToolCalls={serverSupportsTaskToolCalls}
|
||||||
onUiChange={onToolsUiChange}
|
onUiChange={onToolsUiChange}
|
||||||
onRefreshList={onRefreshTools}
|
onRefreshList={onRefreshTools}
|
||||||
|
|
@ -560,7 +570,7 @@ export function InspectorView({
|
||||||
<ScreenStage active={activeTab === "Apps"}>
|
<ScreenStage active={activeTab === "Apps"}>
|
||||||
<AppsScreen
|
<AppsScreen
|
||||||
tools={appTools}
|
tools={appTools}
|
||||||
listChanged={false}
|
listChanged={toolsListChanged}
|
||||||
sandboxPath={sandboxPath}
|
sandboxPath={sandboxPath}
|
||||||
bridgeFactory={bridgeFactory}
|
bridgeFactory={bridgeFactory}
|
||||||
rendererRef={appRendererRef}
|
rendererRef={appRendererRef}
|
||||||
|
|
@ -578,7 +588,7 @@ export function InspectorView({
|
||||||
prompts={prompts}
|
prompts={prompts}
|
||||||
getPromptState={getPromptState}
|
getPromptState={getPromptState}
|
||||||
ui={promptsUi}
|
ui={promptsUi}
|
||||||
listChanged={false}
|
listChanged={promptsListChanged}
|
||||||
completionsSupported={completionsSupported}
|
completionsSupported={completionsSupported}
|
||||||
onUiChange={onPromptsUiChange}
|
onUiChange={onPromptsUiChange}
|
||||||
onRefreshList={onRefreshPrompts}
|
onRefreshList={onRefreshPrompts}
|
||||||
|
|
@ -594,7 +604,7 @@ export function InspectorView({
|
||||||
subscriptions={subscriptions}
|
subscriptions={subscriptions}
|
||||||
readState={readResourceState}
|
readState={readResourceState}
|
||||||
ui={resourcesUi}
|
ui={resourcesUi}
|
||||||
listChanged={false}
|
listChanged={resourcesListChanged}
|
||||||
completionsSupported={completionsSupported}
|
completionsSupported={completionsSupported}
|
||||||
onUiChange={onResourcesUiChange}
|
onUiChange={onResourcesUiChange}
|
||||||
onRefreshList={onRefreshResources}
|
onRefreshList={onRefreshResources}
|
||||||
|
|
|
||||||
|
|
@ -209,6 +209,44 @@ describe("serverEntriesToMcpConfig", () => {
|
||||||
expect(round).toEqual(original);
|
expect(round).toEqual(original);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("round-trips autoRefreshOnListChanged: lifts true to settings and back to disk", () => {
|
||||||
|
const original: MCPConfig = {
|
||||||
|
mcpServers: {
|
||||||
|
delta: {
|
||||||
|
type: "streamable-http",
|
||||||
|
url: "https://x.test/mcp",
|
||||||
|
autoRefreshOnListChanged: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
const [entry] = mcpConfigToServerEntries(original);
|
||||||
|
expect(entry?.settings?.autoRefreshOnListChanged).toBe(true);
|
||||||
|
const round = serverEntriesToMcpConfig(mcpConfigToServerEntries(original));
|
||||||
|
expect(round).toEqual(original);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("omits autoRefreshOnListChanged from disk when false (the default)", () => {
|
||||||
|
// Absent on disk lifts to false in memory; writing it back must NOT inject
|
||||||
|
// the field, keeping the diff minimal for the default-off case. A benign
|
||||||
|
// inspector field (connectionTimeout) is present so `settings` is built.
|
||||||
|
const original: MCPConfig = {
|
||||||
|
mcpServers: {
|
||||||
|
epsilon: {
|
||||||
|
type: "streamable-http",
|
||||||
|
url: "https://x.test/mcp",
|
||||||
|
connectionTimeout: 5000,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
const [entry] = mcpConfigToServerEntries(original);
|
||||||
|
expect(entry?.settings?.autoRefreshOnListChanged).toBe(false);
|
||||||
|
const round = serverEntriesToMcpConfig(mcpConfigToServerEntries(original));
|
||||||
|
expect("autoRefreshOnListChanged" in (round.mcpServers.epsilon ?? {})).toBe(
|
||||||
|
false,
|
||||||
|
);
|
||||||
|
expect(round).toEqual(original);
|
||||||
|
});
|
||||||
|
|
||||||
it("lifts top-level Inspector-extension fields onto ServerEntry.settings (form shape)", () => {
|
it("lifts top-level Inspector-extension fields onto ServerEntry.settings (form shape)", () => {
|
||||||
const cfg: MCPConfig = {
|
const cfg: MCPConfig = {
|
||||||
mcpServers: {
|
mcpServers: {
|
||||||
|
|
@ -229,6 +267,8 @@ describe("serverEntriesToMcpConfig", () => {
|
||||||
requestTimeout: 0,
|
requestTimeout: 0,
|
||||||
// Absent taskTtl on disk → product default in memory (for the form)
|
// Absent taskTtl on disk → product default in memory (for the form)
|
||||||
taskTtl: 60000,
|
taskTtl: 60000,
|
||||||
|
// Absent autoRefreshOnListChanged on disk → false in memory (for the form)
|
||||||
|
autoRefreshOnListChanged: false,
|
||||||
// Absent roots on disk → empty list in memory (for the form)
|
// Absent roots on disk → empty list in memory (for the form)
|
||||||
roots: [],
|
roots: [],
|
||||||
// Nested oauth on disk → flat oauthClientId in memory
|
// Nested oauth on disk → flat oauthClientId in memory
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,6 @@
|
||||||
import { describe, it, expect, beforeEach } from "vitest";
|
import { describe, it, expect, beforeEach } from "vitest";
|
||||||
import type { Prompt } from "@modelcontextprotocol/sdk/types.js";
|
import type { Prompt } from "@modelcontextprotocol/sdk/types.js";
|
||||||
|
import type { InspectorServerSettings } from "@inspector/core/mcp/types.js";
|
||||||
import { ManagedPromptsState } from "@inspector/core/mcp/state/managedPromptsState";
|
import { ManagedPromptsState } from "@inspector/core/mcp/state/managedPromptsState";
|
||||||
import { FakeInspectorClient } from "@inspector/core/mcp/__tests__/fakeInspectorClient";
|
import { FakeInspectorClient } from "@inspector/core/mcp/__tests__/fakeInspectorClient";
|
||||||
|
|
||||||
|
|
@ -7,6 +8,16 @@ function prompt(name: string): Prompt {
|
||||||
return { name };
|
return { name };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const AUTO_REFRESH_SETTINGS: InspectorServerSettings = {
|
||||||
|
headers: [],
|
||||||
|
metadata: [],
|
||||||
|
connectionTimeout: 0,
|
||||||
|
requestTimeout: 0,
|
||||||
|
taskTtl: 60000,
|
||||||
|
autoRefreshOnListChanged: true,
|
||||||
|
roots: [],
|
||||||
|
};
|
||||||
|
|
||||||
function waitForPromptsChange(state: ManagedPromptsState): Promise<Prompt[]> {
|
function waitForPromptsChange(state: ManagedPromptsState): Promise<Prompt[]> {
|
||||||
return new Promise((resolve) => {
|
return new Promise((resolve) => {
|
||||||
state.addEventListener("promptsChange", (e) => resolve(e.detail), {
|
state.addEventListener("promptsChange", (e) => resolve(e.detail), {
|
||||||
|
|
@ -124,13 +135,29 @@ describe("ManagedPromptsState", () => {
|
||||||
expect(next.map((p) => p.name)).toEqual(["a"]);
|
expect(next.map((p) => p.name)).toEqual(["a"]);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("promptsListChanged event triggers a refresh", async () => {
|
it("promptsListChanged does NOT auto-refresh by default (the user pulls via Refresh)", async () => {
|
||||||
client.setStatus("connected");
|
client.setStatus("connected");
|
||||||
client.queuePromptPages({ prompts: [prompt("a"), prompt("b")] });
|
client.queuePromptPages({ prompts: [prompt("a"), prompt("b")] });
|
||||||
const changePromise = waitForPromptsChange(state);
|
|
||||||
client.dispatchTypedEvent("promptsListChanged");
|
client.dispatchTypedEvent("promptsListChanged");
|
||||||
const next = await changePromise;
|
// Yield so a stray refresh would have landed.
|
||||||
expect(next.map((p) => p.name)).toEqual(["a", "b"]);
|
await Promise.resolve();
|
||||||
|
await Promise.resolve();
|
||||||
|
expect(client.listPrompts).not.toHaveBeenCalled();
|
||||||
|
expect(state.getPrompts()).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("promptsListChanged auto-refreshes when the server opts in", async () => {
|
||||||
|
const autoClient = new FakeInspectorClient({
|
||||||
|
capabilities: { prompts: {} },
|
||||||
|
serverSettings: AUTO_REFRESH_SETTINGS,
|
||||||
|
});
|
||||||
|
autoClient.setStatus("connected");
|
||||||
|
const autoState = new ManagedPromptsState(autoClient);
|
||||||
|
autoClient.queuePromptPages({ prompts: [prompt("a")] });
|
||||||
|
const changed = waitForPromptsChange(autoState);
|
||||||
|
autoClient.dispatchTypedEvent("promptsListChanged");
|
||||||
|
expect((await changed).map((p) => p.name)).toEqual(["a"]);
|
||||||
|
expect(autoClient.listPrompts).toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("statusChange to disconnected clears prompts and dispatches promptsChange", async () => {
|
it("statusChange to disconnected clears prompts and dispatches promptsChange", async () => {
|
||||||
|
|
@ -178,6 +205,62 @@ describe("ManagedPromptsState", () => {
|
||||||
expect(state.getPrompts()).toEqual([]);
|
expect(state.getPrompts()).toEqual([]);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("listChanged (#1402)", () => {
|
||||||
|
function waitForListChanged(s: ManagedPromptsState): Promise<boolean> {
|
||||||
|
return new Promise((resolve) => {
|
||||||
|
s.addEventListener("listChangedChange", (e) => resolve(e.detail), {
|
||||||
|
once: true,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
it("starts cleared", () => {
|
||||||
|
expect(state.getListChanged()).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("promptsListChanged sets the flag and dispatches listChangedChange", async () => {
|
||||||
|
client.setStatus("connected");
|
||||||
|
client.queuePromptPages({ prompts: [prompt("a")] });
|
||||||
|
const changed = waitForListChanged(state);
|
||||||
|
client.dispatchTypedEvent("promptsListChanged");
|
||||||
|
expect(await changed).toBe(true);
|
||||||
|
expect(state.getListChanged()).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("clearListChanged resets the flag and dispatches false", async () => {
|
||||||
|
client.setStatus("connected");
|
||||||
|
client.queuePromptPages({ prompts: [prompt("a")] });
|
||||||
|
client.dispatchTypedEvent("promptsListChanged");
|
||||||
|
expect(state.getListChanged()).toBe(true);
|
||||||
|
|
||||||
|
const changed = waitForListChanged(state);
|
||||||
|
state.clearListChanged();
|
||||||
|
expect(await changed).toBe(false);
|
||||||
|
expect(state.getListChanged()).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("clearListChanged is a no-op (no event) when already cleared", () => {
|
||||||
|
let fired = false;
|
||||||
|
state.addEventListener("listChangedChange", () => {
|
||||||
|
fired = true;
|
||||||
|
});
|
||||||
|
state.clearListChanged();
|
||||||
|
expect(fired).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("disconnect clears the flag", async () => {
|
||||||
|
client.setStatus("connected");
|
||||||
|
client.queuePromptPages({ prompts: [prompt("a")] });
|
||||||
|
client.dispatchTypedEvent("promptsListChanged");
|
||||||
|
expect(state.getListChanged()).toBe(true);
|
||||||
|
|
||||||
|
const changed = waitForListChanged(state);
|
||||||
|
client.setStatus("disconnected");
|
||||||
|
expect(await changed).toBe(false);
|
||||||
|
expect(state.getListChanged()).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
it("destroy is idempotent", () => {
|
it("destroy is idempotent", () => {
|
||||||
state.destroy();
|
state.destroy();
|
||||||
expect(() => state.destroy()).not.toThrow();
|
expect(() => state.destroy()).not.toThrow();
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,6 @@
|
||||||
import { describe, it, expect, beforeEach } from "vitest";
|
import { describe, it, expect, beforeEach } from "vitest";
|
||||||
import type { ResourceTemplate } from "@modelcontextprotocol/sdk/types.js";
|
import type { ResourceTemplate } from "@modelcontextprotocol/sdk/types.js";
|
||||||
|
import type { InspectorServerSettings } from "@inspector/core/mcp/types.js";
|
||||||
import { ManagedResourceTemplatesState } from "@inspector/core/mcp/state/managedResourceTemplatesState";
|
import { ManagedResourceTemplatesState } from "@inspector/core/mcp/state/managedResourceTemplatesState";
|
||||||
import { FakeInspectorClient } from "@inspector/core/mcp/__tests__/fakeInspectorClient";
|
import { FakeInspectorClient } from "@inspector/core/mcp/__tests__/fakeInspectorClient";
|
||||||
|
|
||||||
|
|
@ -7,6 +8,16 @@ function template(name: string): ResourceTemplate {
|
||||||
return { uriTemplate: `tpl://{${name}}`, name };
|
return { uriTemplate: `tpl://{${name}}`, name };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const AUTO_REFRESH_SETTINGS: InspectorServerSettings = {
|
||||||
|
headers: [],
|
||||||
|
metadata: [],
|
||||||
|
connectionTimeout: 0,
|
||||||
|
requestTimeout: 0,
|
||||||
|
taskTtl: 60000,
|
||||||
|
autoRefreshOnListChanged: true,
|
||||||
|
roots: [],
|
||||||
|
};
|
||||||
|
|
||||||
function waitForChange(
|
function waitForChange(
|
||||||
state: ManagedResourceTemplatesState,
|
state: ManagedResourceTemplatesState,
|
||||||
): Promise<ResourceTemplate[]> {
|
): Promise<ResourceTemplate[]> {
|
||||||
|
|
@ -136,15 +147,33 @@ describe("ManagedResourceTemplatesState", () => {
|
||||||
expect(next.map((t) => t.name)).toEqual(["a"]);
|
expect(next.map((t) => t.name)).toEqual(["a"]);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("resourceTemplatesListChanged event triggers a refresh", async () => {
|
it("resourceTemplatesListChanged does NOT auto-refresh by default (refreshed via the Resources Refresh)", async () => {
|
||||||
client.setStatus("connected");
|
client.setStatus("connected");
|
||||||
client.queueResourceTemplatePages({
|
client.queueResourceTemplatePages({
|
||||||
resourceTemplates: [template("a"), template("b")],
|
resourceTemplates: [template("a"), template("b")],
|
||||||
});
|
});
|
||||||
const changePromise = waitForChange(state);
|
|
||||||
client.dispatchTypedEvent("resourceTemplatesListChanged");
|
client.dispatchTypedEvent("resourceTemplatesListChanged");
|
||||||
const next = await changePromise;
|
// Yield so a stray refresh would have landed.
|
||||||
expect(next.map((t) => t.name)).toEqual(["a", "b"]);
|
await Promise.resolve();
|
||||||
|
await Promise.resolve();
|
||||||
|
expect(client.listResourceTemplates).not.toHaveBeenCalled();
|
||||||
|
expect(state.getResourceTemplates()).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("resourceTemplatesListChanged auto-refreshes when the server opts in", async () => {
|
||||||
|
const autoClient = new FakeInspectorClient({
|
||||||
|
capabilities: { resources: {} },
|
||||||
|
serverSettings: AUTO_REFRESH_SETTINGS,
|
||||||
|
});
|
||||||
|
autoClient.setStatus("connected");
|
||||||
|
const autoState = new ManagedResourceTemplatesState(autoClient);
|
||||||
|
autoClient.queueResourceTemplatePages({
|
||||||
|
resourceTemplates: [template("a")],
|
||||||
|
});
|
||||||
|
const changed = waitForChange(autoState);
|
||||||
|
autoClient.dispatchTypedEvent("resourceTemplatesListChanged");
|
||||||
|
expect((await changed).map((t) => t.name)).toEqual(["a"]);
|
||||||
|
expect(autoClient.listResourceTemplates).toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("statusChange to disconnected clears templates and dispatches change", async () => {
|
it("statusChange to disconnected clears templates and dispatches change", async () => {
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,6 @@
|
||||||
import { describe, it, expect, beforeEach } from "vitest";
|
import { describe, it, expect, beforeEach } from "vitest";
|
||||||
import type { Resource } from "@modelcontextprotocol/sdk/types.js";
|
import type { Resource } from "@modelcontextprotocol/sdk/types.js";
|
||||||
|
import type { InspectorServerSettings } from "@inspector/core/mcp/types.js";
|
||||||
import { ManagedResourcesState } from "@inspector/core/mcp/state/managedResourcesState";
|
import { ManagedResourcesState } from "@inspector/core/mcp/state/managedResourcesState";
|
||||||
import { FakeInspectorClient } from "@inspector/core/mcp/__tests__/fakeInspectorClient";
|
import { FakeInspectorClient } from "@inspector/core/mcp/__tests__/fakeInspectorClient";
|
||||||
|
|
||||||
|
|
@ -7,6 +8,16 @@ function resource(uri: string): Resource {
|
||||||
return { uri, name: uri };
|
return { uri, name: uri };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const AUTO_REFRESH_SETTINGS: InspectorServerSettings = {
|
||||||
|
headers: [],
|
||||||
|
metadata: [],
|
||||||
|
connectionTimeout: 0,
|
||||||
|
requestTimeout: 0,
|
||||||
|
taskTtl: 60000,
|
||||||
|
autoRefreshOnListChanged: true,
|
||||||
|
roots: [],
|
||||||
|
};
|
||||||
|
|
||||||
function waitForResourcesChange(
|
function waitForResourcesChange(
|
||||||
state: ManagedResourcesState,
|
state: ManagedResourcesState,
|
||||||
): Promise<Resource[]> {
|
): Promise<Resource[]> {
|
||||||
|
|
@ -131,15 +142,31 @@ describe("ManagedResourcesState", () => {
|
||||||
expect(next.map((r) => r.uri)).toEqual(["a://1"]);
|
expect(next.map((r) => r.uri)).toEqual(["a://1"]);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("resourcesListChanged event triggers a refresh", async () => {
|
it("resourcesListChanged does NOT auto-refresh by default (the user pulls via Refresh)", async () => {
|
||||||
client.setStatus("connected");
|
client.setStatus("connected");
|
||||||
client.queueResourcePages({
|
client.queueResourcePages({
|
||||||
resources: [resource("a://1"), resource("a://2")],
|
resources: [resource("a://1"), resource("a://2")],
|
||||||
});
|
});
|
||||||
const changePromise = waitForResourcesChange(state);
|
|
||||||
client.dispatchTypedEvent("resourcesListChanged");
|
client.dispatchTypedEvent("resourcesListChanged");
|
||||||
const next = await changePromise;
|
// Yield so a stray refresh would have landed.
|
||||||
expect(next.map((r) => r.uri)).toEqual(["a://1", "a://2"]);
|
await Promise.resolve();
|
||||||
|
await Promise.resolve();
|
||||||
|
expect(client.listResources).not.toHaveBeenCalled();
|
||||||
|
expect(state.getResources()).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("resourcesListChanged auto-refreshes when the server opts in", async () => {
|
||||||
|
const autoClient = new FakeInspectorClient({
|
||||||
|
capabilities: { resources: {} },
|
||||||
|
serverSettings: AUTO_REFRESH_SETTINGS,
|
||||||
|
});
|
||||||
|
autoClient.setStatus("connected");
|
||||||
|
const autoState = new ManagedResourcesState(autoClient);
|
||||||
|
autoClient.queueResourcePages({ resources: [resource("a://1")] });
|
||||||
|
const changed = waitForResourcesChange(autoState);
|
||||||
|
autoClient.dispatchTypedEvent("resourcesListChanged");
|
||||||
|
expect((await changed).map((r) => r.uri)).toEqual(["a://1"]);
|
||||||
|
expect(autoClient.listResources).toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("statusChange to disconnected clears resources and dispatches resourcesChange", async () => {
|
it("statusChange to disconnected clears resources and dispatches resourcesChange", async () => {
|
||||||
|
|
@ -187,6 +214,62 @@ describe("ManagedResourcesState", () => {
|
||||||
expect(state.getResources()).toEqual([]);
|
expect(state.getResources()).toEqual([]);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("listChanged (#1402)", () => {
|
||||||
|
function waitForListChanged(s: ManagedResourcesState): Promise<boolean> {
|
||||||
|
return new Promise((resolve) => {
|
||||||
|
s.addEventListener("listChangedChange", (e) => resolve(e.detail), {
|
||||||
|
once: true,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
it("starts cleared", () => {
|
||||||
|
expect(state.getListChanged()).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("resourcesListChanged sets the flag and dispatches listChangedChange", async () => {
|
||||||
|
client.setStatus("connected");
|
||||||
|
client.queueResourcePages({ resources: [resource("a://1")] });
|
||||||
|
const changed = waitForListChanged(state);
|
||||||
|
client.dispatchTypedEvent("resourcesListChanged");
|
||||||
|
expect(await changed).toBe(true);
|
||||||
|
expect(state.getListChanged()).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("clearListChanged resets the flag and dispatches false", async () => {
|
||||||
|
client.setStatus("connected");
|
||||||
|
client.queueResourcePages({ resources: [resource("a://1")] });
|
||||||
|
client.dispatchTypedEvent("resourcesListChanged");
|
||||||
|
expect(state.getListChanged()).toBe(true);
|
||||||
|
|
||||||
|
const changed = waitForListChanged(state);
|
||||||
|
state.clearListChanged();
|
||||||
|
expect(await changed).toBe(false);
|
||||||
|
expect(state.getListChanged()).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("clearListChanged is a no-op (no event) when already cleared", () => {
|
||||||
|
let fired = false;
|
||||||
|
state.addEventListener("listChangedChange", () => {
|
||||||
|
fired = true;
|
||||||
|
});
|
||||||
|
state.clearListChanged();
|
||||||
|
expect(fired).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("disconnect clears the flag", async () => {
|
||||||
|
client.setStatus("connected");
|
||||||
|
client.queueResourcePages({ resources: [resource("a://1")] });
|
||||||
|
client.dispatchTypedEvent("resourcesListChanged");
|
||||||
|
expect(state.getListChanged()).toBe(true);
|
||||||
|
|
||||||
|
const changed = waitForListChanged(state);
|
||||||
|
client.setStatus("disconnected");
|
||||||
|
expect(await changed).toBe(false);
|
||||||
|
expect(state.getListChanged()).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
it("destroy is idempotent", () => {
|
it("destroy is idempotent", () => {
|
||||||
state.destroy();
|
state.destroy();
|
||||||
expect(() => state.destroy()).not.toThrow();
|
expect(() => state.destroy()).not.toThrow();
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,6 @@
|
||||||
import { describe, it, expect, beforeEach } from "vitest";
|
import { describe, it, expect, beforeEach } from "vitest";
|
||||||
import type { Tool } from "@modelcontextprotocol/sdk/types.js";
|
import type { Tool } from "@modelcontextprotocol/sdk/types.js";
|
||||||
|
import type { InspectorServerSettings } from "@inspector/core/mcp/types.js";
|
||||||
import { ManagedToolsState } from "@inspector/core/mcp/state/managedToolsState";
|
import { ManagedToolsState } from "@inspector/core/mcp/state/managedToolsState";
|
||||||
import { FakeInspectorClient } from "@inspector/core/mcp/__tests__/fakeInspectorClient";
|
import { FakeInspectorClient } from "@inspector/core/mcp/__tests__/fakeInspectorClient";
|
||||||
|
|
||||||
|
|
@ -7,6 +8,16 @@ function tool(name: string): Tool {
|
||||||
return { name, inputSchema: { type: "object" } };
|
return { name, inputSchema: { type: "object" } };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const AUTO_REFRESH_SETTINGS: InspectorServerSettings = {
|
||||||
|
headers: [],
|
||||||
|
metadata: [],
|
||||||
|
connectionTimeout: 0,
|
||||||
|
requestTimeout: 0,
|
||||||
|
taskTtl: 60000,
|
||||||
|
autoRefreshOnListChanged: true,
|
||||||
|
roots: [],
|
||||||
|
};
|
||||||
|
|
||||||
function waitForToolsChange(state: ManagedToolsState): Promise<Tool[]> {
|
function waitForToolsChange(state: ManagedToolsState): Promise<Tool[]> {
|
||||||
return new Promise((resolve) => {
|
return new Promise((resolve) => {
|
||||||
state.addEventListener("toolsChange", (e) => resolve(e.detail), {
|
state.addEventListener("toolsChange", (e) => resolve(e.detail), {
|
||||||
|
|
@ -122,13 +133,29 @@ describe("ManagedToolsState", () => {
|
||||||
expect(next.map((t) => t.name)).toEqual(["a"]);
|
expect(next.map((t) => t.name)).toEqual(["a"]);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("toolsListChanged event triggers a refresh", async () => {
|
it("toolsListChanged does NOT auto-refresh by default (the user pulls via Refresh)", async () => {
|
||||||
client.setStatus("connected");
|
client.setStatus("connected");
|
||||||
client.queueToolPages({ tools: [tool("a"), tool("b")] });
|
client.queueToolPages({ tools: [tool("a"), tool("b")] });
|
||||||
const changePromise = waitForToolsChange(state);
|
|
||||||
client.dispatchTypedEvent("toolsListChanged");
|
client.dispatchTypedEvent("toolsListChanged");
|
||||||
const next = await changePromise;
|
// Yield so a stray refresh would have landed.
|
||||||
expect(next.map((t) => t.name)).toEqual(["a", "b"]);
|
await Promise.resolve();
|
||||||
|
await Promise.resolve();
|
||||||
|
expect(client.listTools).not.toHaveBeenCalled();
|
||||||
|
expect(state.getTools()).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("toolsListChanged auto-refreshes when the server opts in", async () => {
|
||||||
|
const autoClient = new FakeInspectorClient({
|
||||||
|
capabilities: { tools: {} },
|
||||||
|
serverSettings: AUTO_REFRESH_SETTINGS,
|
||||||
|
});
|
||||||
|
autoClient.setStatus("connected");
|
||||||
|
const autoState = new ManagedToolsState(autoClient);
|
||||||
|
autoClient.queueToolPages({ tools: [tool("a")] });
|
||||||
|
const changed = waitForToolsChange(autoState);
|
||||||
|
autoClient.dispatchTypedEvent("toolsListChanged");
|
||||||
|
expect((await changed).map((t) => t.name)).toEqual(["a"]);
|
||||||
|
expect(autoClient.listTools).toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("statusChange to disconnected clears tools and dispatches toolsChange", async () => {
|
it("statusChange to disconnected clears tools and dispatches toolsChange", async () => {
|
||||||
|
|
@ -179,6 +206,62 @@ describe("ManagedToolsState", () => {
|
||||||
expect(state.getTools()).toEqual([]);
|
expect(state.getTools()).toEqual([]);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("listChanged (#1402)", () => {
|
||||||
|
function waitForListChanged(s: ManagedToolsState): Promise<boolean> {
|
||||||
|
return new Promise((resolve) => {
|
||||||
|
s.addEventListener("listChangedChange", (e) => resolve(e.detail), {
|
||||||
|
once: true,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
it("starts cleared", () => {
|
||||||
|
expect(state.getListChanged()).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("toolsListChanged sets the flag and dispatches listChangedChange", async () => {
|
||||||
|
client.setStatus("connected");
|
||||||
|
client.queueToolPages({ tools: [tool("a")] });
|
||||||
|
const changed = waitForListChanged(state);
|
||||||
|
client.dispatchTypedEvent("toolsListChanged");
|
||||||
|
expect(await changed).toBe(true);
|
||||||
|
expect(state.getListChanged()).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("clearListChanged resets the flag and dispatches false", async () => {
|
||||||
|
client.setStatus("connected");
|
||||||
|
client.queueToolPages({ tools: [tool("a")] });
|
||||||
|
client.dispatchTypedEvent("toolsListChanged");
|
||||||
|
expect(state.getListChanged()).toBe(true);
|
||||||
|
|
||||||
|
const changed = waitForListChanged(state);
|
||||||
|
state.clearListChanged();
|
||||||
|
expect(await changed).toBe(false);
|
||||||
|
expect(state.getListChanged()).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("clearListChanged is a no-op (no event) when already cleared", () => {
|
||||||
|
let fired = false;
|
||||||
|
state.addEventListener("listChangedChange", () => {
|
||||||
|
fired = true;
|
||||||
|
});
|
||||||
|
state.clearListChanged();
|
||||||
|
expect(fired).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("disconnect clears the flag", async () => {
|
||||||
|
client.setStatus("connected");
|
||||||
|
client.queueToolPages({ tools: [tool("a")] });
|
||||||
|
client.dispatchTypedEvent("toolsListChanged");
|
||||||
|
expect(state.getListChanged()).toBe(true);
|
||||||
|
|
||||||
|
const changed = waitForListChanged(state);
|
||||||
|
client.setStatus("disconnected");
|
||||||
|
expect(await changed).toBe(false);
|
||||||
|
expect(state.getListChanged()).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
it("destroy is idempotent", () => {
|
it("destroy is idempotent", () => {
|
||||||
state.destroy();
|
state.destroy();
|
||||||
expect(() => state.destroy()).not.toThrow();
|
expect(() => state.destroy()).not.toThrow();
|
||||||
|
|
|
||||||
|
|
@ -102,4 +102,41 @@ describe("useManagedPrompts", () => {
|
||||||
|
|
||||||
expect(result.current.prompts).toEqual([]);
|
expect(result.current.prompts).toEqual([]);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("listChanged (#1402)", () => {
|
||||||
|
it("starts false and reflects listChangedChange from the state", async () => {
|
||||||
|
const { result } = renderHook(() => useManagedPrompts(client, state));
|
||||||
|
expect(result.current.listChanged).toBe(false);
|
||||||
|
|
||||||
|
client.queuePromptPages({ prompts: [prompt("a")] });
|
||||||
|
act(() => {
|
||||||
|
client.dispatchTypedEvent("promptsListChanged");
|
||||||
|
});
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(result.current.listChanged).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("refresh() clears the indicator", async () => {
|
||||||
|
const { result } = renderHook(() => useManagedPrompts(client, state));
|
||||||
|
client.queuePromptPages({ prompts: [prompt("a")] });
|
||||||
|
act(() => {
|
||||||
|
client.dispatchTypedEvent("promptsListChanged");
|
||||||
|
});
|
||||||
|
await waitFor(() => expect(result.current.listChanged).toBe(true));
|
||||||
|
|
||||||
|
client.queuePromptPages({ prompts: [prompt("a")] });
|
||||||
|
await act(async () => {
|
||||||
|
await result.current.refresh();
|
||||||
|
});
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(result.current.listChanged).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("defaults to false when state is null", () => {
|
||||||
|
const { result } = renderHook(() => useManagedPrompts(client, null));
|
||||||
|
expect(result.current.listChanged).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -107,4 +107,41 @@ describe("useManagedResources", () => {
|
||||||
|
|
||||||
expect(result.current.resources).toEqual([]);
|
expect(result.current.resources).toEqual([]);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("listChanged (#1402)", () => {
|
||||||
|
it("starts false and reflects listChangedChange from the state", async () => {
|
||||||
|
const { result } = renderHook(() => useManagedResources(client, state));
|
||||||
|
expect(result.current.listChanged).toBe(false);
|
||||||
|
|
||||||
|
client.queueResourcePages({ resources: [resource("a://1")] });
|
||||||
|
act(() => {
|
||||||
|
client.dispatchTypedEvent("resourcesListChanged");
|
||||||
|
});
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(result.current.listChanged).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("refresh() clears the indicator", async () => {
|
||||||
|
const { result } = renderHook(() => useManagedResources(client, state));
|
||||||
|
client.queueResourcePages({ resources: [resource("a://1")] });
|
||||||
|
act(() => {
|
||||||
|
client.dispatchTypedEvent("resourcesListChanged");
|
||||||
|
});
|
||||||
|
await waitFor(() => expect(result.current.listChanged).toBe(true));
|
||||||
|
|
||||||
|
client.queueResourcePages({ resources: [resource("a://1")] });
|
||||||
|
await act(async () => {
|
||||||
|
await result.current.refresh();
|
||||||
|
});
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(result.current.listChanged).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("defaults to false when state is null", () => {
|
||||||
|
const { result } = renderHook(() => useManagedResources(client, null));
|
||||||
|
expect(result.current.listChanged).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
|
||||||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Reference in a new issue