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
10 changed files with 1109 additions and 93 deletions

View file

@ -209,6 +209,44 @@ describe("serverEntriesToMcpConfig", () => {
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)", () => {
const cfg: MCPConfig = {
mcpServers: {
@ -229,6 +267,8 @@ describe("serverEntriesToMcpConfig", () => {
requestTimeout: 0,
// Absent taskTtl on disk → product default in memory (for the form)
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)
roots: [],
// Nested oauth on disk → flat oauthClientId in memory

View file

@ -1,5 +1,6 @@
import { describe, it, expect, beforeEach } from "vitest";
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 { FakeInspectorClient } from "@inspector/core/mcp/__tests__/fakeInspectorClient";
@ -7,6 +8,16 @@ function prompt(name: string): Prompt {
return { name };
}
const AUTO_REFRESH_SETTINGS: InspectorServerSettings = {
headers: [],
metadata: [],
connectionTimeout: 0,
requestTimeout: 0,
taskTtl: 60000,
autoRefreshOnListChanged: true,
roots: [],
};
function waitForPromptsChange(state: ManagedPromptsState): Promise<Prompt[]> {
return new Promise((resolve) => {
state.addEventListener("promptsChange", (e) => resolve(e.detail), {
@ -124,13 +135,29 @@ describe("ManagedPromptsState", () => {
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.queuePromptPages({ prompts: [prompt("a"), prompt("b")] });
const changePromise = waitForPromptsChange(state);
client.dispatchTypedEvent("promptsListChanged");
const next = await changePromise;
expect(next.map((p) => p.name)).toEqual(["a", "b"]);
// Yield so a stray refresh would have landed.
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 () => {
@ -178,6 +205,62 @@ describe("ManagedPromptsState", () => {
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", () => {
state.destroy();
expect(() => state.destroy()).not.toThrow();

View file

@ -1,5 +1,6 @@
import { describe, it, expect, beforeEach } from "vitest";
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 { FakeInspectorClient } from "@inspector/core/mcp/__tests__/fakeInspectorClient";
@ -7,6 +8,16 @@ function template(name: string): ResourceTemplate {
return { uriTemplate: `tpl://{${name}}`, name };
}
const AUTO_REFRESH_SETTINGS: InspectorServerSettings = {
headers: [],
metadata: [],
connectionTimeout: 0,
requestTimeout: 0,
taskTtl: 60000,
autoRefreshOnListChanged: true,
roots: [],
};
function waitForChange(
state: ManagedResourceTemplatesState,
): Promise<ResourceTemplate[]> {
@ -136,15 +147,33 @@ describe("ManagedResourceTemplatesState", () => {
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.queueResourceTemplatePages({
resourceTemplates: [template("a"), template("b")],
});
const changePromise = waitForChange(state);
client.dispatchTypedEvent("resourceTemplatesListChanged");
const next = await changePromise;
expect(next.map((t) => t.name)).toEqual(["a", "b"]);
// Yield so a stray refresh would have landed.
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 () => {

View file

@ -1,5 +1,6 @@
import { describe, it, expect, beforeEach } from "vitest";
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 { FakeInspectorClient } from "@inspector/core/mcp/__tests__/fakeInspectorClient";
@ -7,6 +8,16 @@ function resource(uri: string): Resource {
return { uri, name: uri };
}
const AUTO_REFRESH_SETTINGS: InspectorServerSettings = {
headers: [],
metadata: [],
connectionTimeout: 0,
requestTimeout: 0,
taskTtl: 60000,
autoRefreshOnListChanged: true,
roots: [],
};
function waitForResourcesChange(
state: ManagedResourcesState,
): Promise<Resource[]> {
@ -131,15 +142,31 @@ describe("ManagedResourcesState", () => {
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.queueResourcePages({
resources: [resource("a://1"), resource("a://2")],
});
const changePromise = waitForResourcesChange(state);
client.dispatchTypedEvent("resourcesListChanged");
const next = await changePromise;
expect(next.map((r) => r.uri)).toEqual(["a://1", "a://2"]);
// Yield so a stray refresh would have landed.
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 () => {
@ -187,6 +214,62 @@ describe("ManagedResourcesState", () => {
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", () => {
state.destroy();
expect(() => state.destroy()).not.toThrow();

View file

@ -1,5 +1,6 @@
import { describe, it, expect, beforeEach } from "vitest";
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 { FakeInspectorClient } from "@inspector/core/mcp/__tests__/fakeInspectorClient";
@ -7,6 +8,16 @@ function tool(name: string): Tool {
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[]> {
return new Promise((resolve) => {
state.addEventListener("toolsChange", (e) => resolve(e.detail), {
@ -122,13 +133,29 @@ describe("ManagedToolsState", () => {
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.queueToolPages({ tools: [tool("a"), tool("b")] });
const changePromise = waitForToolsChange(state);
client.dispatchTypedEvent("toolsListChanged");
const next = await changePromise;
expect(next.map((t) => t.name)).toEqual(["a", "b"]);
// Yield so a stray refresh would have landed.
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 () => {
@ -179,6 +206,62 @@ describe("ManagedToolsState", () => {
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", () => {
state.destroy();
expect(() => state.destroy()).not.toThrow();

View file

@ -102,4 +102,41 @@ describe("useManagedPrompts", () => {
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([]);
});
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);
});
});
});

View file

@ -101,4 +101,82 @@ describe("useManagedTools", () => {
// observed value is the empty snapshot captured before the refresh.
expect(result.current.tools).toEqual([]);
});
describe("listChanged (#1402)", () => {
it("starts false and reflects listChangedChange from the state", async () => {
const { result } = renderHook(() => useManagedTools(client, state));
expect(result.current.listChanged).toBe(false);
client.queueToolPages({ tools: [tool("a")] });
act(() => {
client.dispatchTypedEvent("toolsListChanged");
});
await waitFor(() => {
expect(result.current.listChanged).toBe(true);
});
});
it("refresh() clears the indicator", async () => {
const { result } = renderHook(() => useManagedTools(client, state));
client.queueToolPages({ tools: [tool("a")] });
act(() => {
client.dispatchTypedEvent("toolsListChanged");
});
await waitFor(() => expect(result.current.listChanged).toBe(true));
client.queueToolPages({ tools: [tool("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(() => useManagedTools(client, null));
expect(result.current.listChanged).toBe(false);
});
it("keeps a change that fires during the awaited refresh (race regression)", async () => {
const { result } = renderHook(() => useManagedTools(client, state));
// Indicator starts set from an initial change.
client.queueToolPages({ tools: [tool("a")] });
act(() => {
client.dispatchTypedEvent("toolsListChanged");
});
await waitFor(() => expect(result.current.listChanged).toBe(true));
// Hang the user-refresh's fetch so a new change can land mid-flight.
let releaseRefresh: (value: { tools: Tool[] }) => void = () => {};
client.listTools.mockImplementationOnce(
() =>
new Promise<{ tools: Tool[] }>((resolve) => {
releaseRefresh = resolve;
}),
);
let refreshPromise!: Promise<Tool[]>;
act(() => {
refreshPromise = result.current.refresh();
});
// The hook clears BEFORE awaiting, so the indicator is already off.
await waitFor(() => expect(result.current.listChanged).toBe(false));
// A genuinely-new change arrives while the refresh is still in flight.
client.queueToolPages({ tools: [tool("a"), tool("b")] });
act(() => {
client.dispatchTypedEvent("toolsListChanged");
});
await waitFor(() => expect(result.current.listChanged).toBe(true));
// Completing the user refresh must NOT wipe the mid-flight change.
await act(async () => {
releaseRefresh({ tools: [tool("a")] });
await refreshPromise;
});
expect(result.current.listChanged).toBe(true);
});
});
});

View file

@ -403,6 +403,7 @@ describe("useServers", () => {
connectionTimeout: 5000,
requestTimeout: 30000,
taskTtl: 30000,
autoRefreshOnListChanged: false,
roots: [],
});
});
@ -484,6 +485,8 @@ describe("useServers", () => {
requestTimeout: 0,
// Absent taskTtl on disk reads back as the product default for the form.
taskTtl: 60000,
// Absent autoRefreshOnListChanged on disk reads back as false.
autoRefreshOnListChanged: false,
// Absent roots on disk reads back as an empty list for the form.
roots: [],
});

View file

@ -452,6 +452,87 @@ describe("/api/servers routes", () => {
expect(stored).not.toHaveProperty("connectionTimeout");
});
it("persists autoRefreshOnListChanged: true through the PUT write path", async () => {
// Regression: validateSettings rebuilds the value from named fields, so a
// new field is silently dropped unless it's explicitly handled there.
writeFileSync(
h.configPath,
JSON.stringify({
mcpServers: {
delta: { type: "streamable-http", url: "https://x.test/mcp" },
},
}),
);
const res = await fetch(`${h.baseUrl}/api/servers/delta`, {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
settings: {
headers: [],
metadata: [],
connectionTimeout: 0,
requestTimeout: 0,
autoRefreshOnListChanged: true,
},
}),
});
expect(res.status).toBe(200);
const stored = readConfig(h.configPath).mcpServers
.delta as unknown as Record<string, unknown>;
expect(stored.autoRefreshOnListChanged).toBe(true);
});
it("omits autoRefreshOnListChanged from disk when false", async () => {
writeFileSync(
h.configPath,
JSON.stringify({
mcpServers: {
delta: {
type: "streamable-http",
url: "https://x.test/mcp",
autoRefreshOnListChanged: true,
},
},
}),
);
const res = await fetch(`${h.baseUrl}/api/servers/delta`, {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
settings: {
headers: [],
metadata: [],
connectionTimeout: 0,
requestTimeout: 0,
autoRefreshOnListChanged: false,
},
}),
});
expect(res.status).toBe(200);
const stored = readConfig(h.configPath).mcpServers
.delta as unknown as Record<string, unknown>;
expect(stored).not.toHaveProperty("autoRefreshOnListChanged");
});
it("rejects a non-boolean autoRefreshOnListChanged", async () => {
const res = await fetch(`${h.baseUrl}/api/servers`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
id: "bad-autorefresh",
config: { type: "stdio", command: "node" },
settings: {
headers: [],
metadata: [],
connectionTimeout: 0,
requestTimeout: 0,
autoRefreshOnListChanged: "yes",
},
}),
});
expect(res.status).toBe(400);
});
it("rejects a non-object settings field", async () => {
const res = await fetch(`${h.baseUrl}/api/servers`, {
method: "POST",