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:
Cliff Hall 2026-06-08 12:58:21 -04:00 • committed by GitHub
commit b0a024269e
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
36 changed files with 1109 additions and 93 deletions

6
.gitignore vendored
View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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