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

View file

@ -90,6 +90,12 @@ function InteractiveRender(args: ServerSettingsFormProps) {
settings: { ...args.settings, [field]: value },
});
}}
onAutoRefreshChange={(value) => {
args.onAutoRefreshChange(value);
updateArgs({
settings: { ...args.settings, autoRefreshOnListChanged: value },
});
}}
onOAuthChange={(oauth) => {
args.onOAuthChange(oauth);
updateArgs({
@ -110,7 +116,7 @@ const meta: Meta<typeof ServerSettingsForm> = {
component: ServerSettingsForm,
render: InteractiveRender,
args: {
expandedSections: ["headers"],
expandedSections: ["options"],
onExpandedSectionsChange: fn(),
onAddHeader: fn(),
onRemoveHeader: fn(),
@ -119,6 +125,7 @@ const meta: Meta<typeof ServerSettingsForm> = {
onRemoveMetadata: fn(),
onMetadataChange: fn(),
onTimeoutChange: fn(),
onAutoRefreshChange: fn(),
onOAuthChange: fn(),
},
};
@ -134,6 +141,7 @@ export const DefaultSettings: Story = {
export const WithHeaders: Story = {
args: {
expandedSections: ["headers"],
settings: {
...defaultSettings,
headers: [

View file

@ -45,6 +45,7 @@ const baseHandlers = {
onRemoveMetadata: vi.fn(),
onMetadataChange: vi.fn(),
onTimeoutChange: vi.fn(),
onAutoRefreshChange: vi.fn(),
onOAuthChange: vi.fn(),
onAddRoot: vi.fn(),
onRemoveRoot: vi.fn(),
@ -236,6 +237,54 @@ describe("ServerSettingsForm", () => {
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 () => {
const user = userEvent.setup();
const onOAuthChange = vi.fn();

View file

@ -2,6 +2,7 @@ import {
Accordion,
ActionIcon,
Button,
Checkbox,
Group,
NumberInput,
Stack,
@ -15,6 +16,7 @@ import type {
import type { Root } from "@modelcontextprotocol/sdk/types.js";
export type ServerSettingsSection =
| "options"
| "headers"
| "metadata"
| "timeouts"
@ -35,6 +37,7 @@ export interface ServerSettingsFormProps {
field: "connectionTimeout" | "requestTimeout" | "taskTtl",
value: number,
) => void;
onAutoRefreshChange: (value: boolean) => void;
onOAuthChange: (oauth: OAuthSettings) => void;
onAddRoot: () => void;
onRemoveRoot: (index: number) => void;
@ -143,6 +146,7 @@ export function ServerSettingsForm({
onRemoveMetadata,
onMetadataChange,
onTimeoutChange,
onAutoRefreshChange,
onOAuthChange,
onAddRoot,
onRemoveRoot,
@ -173,6 +177,20 @@ export function ServerSettingsForm({
}
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.Control>Custom Headers</Accordion.Control>
<Accordion.Panel>