Extensions: advertised-extensions toggle in Server Settings (Phase 2 of #1633) (#1743)

* Extensions: advertised-extensions toggle in Server Settings (Phase 2 of #1633)

Adds the debugging knob from #1633: users choose which extensions the
Inspector advertises to a server, and a server may register different tools
in response. Builds on Phase 1's registry + advertisedExtensions option (#1738).

Settings + persistence (core):
- InspectorServerSettings.advertisedExtensions + StoredMCPServer field, wired
  through serverList's read/write converters and INSPECTOR_FIELD_KEY_MAP
  (omit-when-empty, byte-stable round-trip).
- validateSettings (remote PUT route): isBooleanRecord guard + carry a
  non-empty map; unknown/malformed rejected.
- App.tsx threads savedSettings.advertisedExtensions into the client options.

UI:
- ServerSettingsForm renders an "Advertised Extensions" group of checkboxes
  driven by ADVERTISABLE_EXTENSIONS; a per-server override wins over the
  registry default. Modal folds a toggle into settings.advertisedExtensions.

Test server + proof:
- composable-test-server extensionGatedTools: a tool registered disabled and
  enabled on `initialized` iff the client declared the mapped extension
  (getClientCapabilities). Legacy stateful leg. Threaded through
  load-config/resolve-config; advertised-extensions-http.json showcase + README.
- Integration test connects legacy with/without the tasks extension advertised
  and asserts the gated tool appears/disappears in tools/list — the acceptance
  criterion, proven end to end, not asserted.

Tests: serverList round-trip (non-empty/absent/empty), ServerSettingsForm
toggle rows, ServerSettingsModal fold, validateSettings accept/reject, and the
integration gating test. npm run ci green (per-file ≥90 gate held).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XNdjEPKLG637X8YmhDiEk5

* Extensions: add #1739 proof screenshots (advertised-extensions toggle)

End-to-end smoke test in the web client against the legacy
advertised-extensions-http.json test server (port 3220): connecting with the
Tasks extension advertised (default) lists echo + get_weather, unchecking the
Advertised Extensions → Tasks toggle and reconnecting lists only echo — the
server's tool registration changing on the client-declared extension.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XNdjEPKLG637X8YmhDiEk5

* Extensions: reconverge advertisedExtensions to no-override on default (review)

Address Phase 2 review observation #1: toggling an extension back to its
registry default now deletes the override key (and drops the whole map to
undefined when it empties), so the on-disk `advertisedExtensions` reconverges
to the byte-stable "no override" state instead of persisting a redundant
`{ key: <default> }`. Matches the omit-when-default policy the other settings
use. Only a value that differs from the default is persisted.

Adds a modal test asserting the reconvergence (re-checking the sole disabling
override drops advertisedExtensions to undefined).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XNdjEPKLG637X8YmhDiEk5

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Cliff Hall 2026-07-22 16:23:43 -04:00 • committed by GitHub
commit fe295396af
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
5 changed files with 585 additions and 0 deletions

View file

@ -137,6 +137,18 @@ function InteractiveRender(args: ServerSettingsFormProps) {
settings: { ...args.settings, paginatedLists: value },
});
}}
onAdvertisedExtensionChange={(key, checked) => {
args.onAdvertisedExtensionChange(key, checked);
updateArgs({
settings: {
...args.settings,
advertisedExtensions: {
...args.settings.advertisedExtensions,
[key]: checked,
},
},
});
}}
onMaxFetchRequestsChange={(value) => {
args.onMaxFetchRequestsChange(value);
updateArgs({
@ -191,6 +203,7 @@ const meta: Meta<typeof ServerSettingsForm> = {
onTimeoutChange: fn(),
onAutoRefreshChange: fn(),
onPaginatedListsChange: fn(),
onAdvertisedExtensionChange: fn(),
onMaxFetchRequestsChange: fn(),
onProtocolEraChange: fn(),
onModernLogLevelChange: fn(),

View file

@ -66,6 +66,7 @@ const baseHandlers = {
onTimeoutChange: vi.fn(),
onAutoRefreshChange: vi.fn(),
onPaginatedListsChange: vi.fn(),
onAdvertisedExtensionChange: vi.fn(),
onMaxFetchRequestsChange: vi.fn(),
onProtocolEraChange: vi.fn(),
onModernLogLevelChange: vi.fn(),
@ -452,6 +453,62 @@ describe("ServerSettingsForm", () => {
expect(onAutoRefreshChange).toHaveBeenCalledWith(true);
});
it("renders the Advertised Extensions section with Tasks checked by default", () => {
renderWithMantine(
<ServerSettingsForm
{...baseHandlers}
settings={emptySettings}
expandedSections={["options"]}
/>,
);
const tasks = screen.getByRole("checkbox", {
name: /Tasks \(io\.modelcontextprotocol\/tasks\)/,
});
// No override present → the registry default (advertised) shows checked.
expect(tasks).toBeChecked();
});
it("reflects an advertisedExtensions override that disables Tasks", () => {
renderWithMantine(
<ServerSettingsForm
{...baseHandlers}
settings={{
...emptySettings,
advertisedExtensions: { "io.modelcontextprotocol/tasks": false },
}}
expandedSections={["options"]}
/>,
);
expect(
screen.getByRole("checkbox", {
name: /Tasks \(io\.modelcontextprotocol\/tasks\)/,
}),
).not.toBeChecked();
});
it("invokes onAdvertisedExtensionChange when a Tasks toggle is clicked", async () => {
const user = userEvent.setup();
const onAdvertisedExtensionChange = vi.fn();
renderWithMantine(
<ServerSettingsForm
{...baseHandlers}
onAdvertisedExtensionChange={onAdvertisedExtensionChange}
settings={emptySettings}
expandedSections={["options"]}
/>,
);
// Default is checked; clicking flips it to unadvertised.
await user.click(
screen.getByRole("checkbox", {
name: /Tasks \(io\.modelcontextprotocol\/tasks\)/,
}),
);
expect(onAdvertisedExtensionChange).toHaveBeenCalledWith(
"io.modelcontextprotocol/tasks",
false,
);
});
it("renders the Network Log Size field reflecting maxFetchRequests", () => {
renderWithMantine(
<ServerSettingsForm

View file

@ -26,8 +26,22 @@ import {
MODERN_LOG_LEVELS,
} from "@inspector/core/mcp/types.js";
import { isOAuthCapableServerType } from "@inspector/core/mcp/config.js";
import { ADVERTISABLE_EXTENSIONS } from "@inspector/core/mcp/extensions.js";
import type { Root } from "@modelcontextprotocol/client";
/**
* Resolve the advertised state of an extension for the form: the per-server
* override wins, else the registry default. Mirrors `buildClientExtensions`'s
* resolution so the switches show exactly what the client will advertise.
*/
function isExtensionAdvertised(
settings: InspectorServerSettings,
key: string,
defaultAdvertised: boolean,
): boolean {
return settings.advertisedExtensions?.[key] ?? defaultAdvertised;
}
export type ServerSettingsSection =
| "options"
| "environment"
@ -65,6 +79,12 @@ export interface ServerSettingsFormProps {
) => void;
onAutoRefreshChange: (value: boolean) => void;
onPaginatedListsChange: (value: boolean) => void;
/**
* Toggle whether the Inspector advertises the extension `key` to this server.
* `checked` is the new advertise state; the modal folds it into
* `settings.advertisedExtensions`. (#1739)
*/
onAdvertisedExtensionChange: (key: string, checked: boolean) => void;
onMaxFetchRequestsChange: (value: number) => void;
onProtocolEraChange: (value: ServerProtocolEra) => void;
onModernLogLevelChange: (value: ModernLogLevel) => void;
@ -272,6 +292,7 @@ export function ServerSettingsForm({
onTimeoutChange,
onAutoRefreshChange,
onPaginatedListsChange,
onAdvertisedExtensionChange,
onMaxFetchRequestsChange,
onProtocolEraChange,
onModernLogLevelChange,
@ -380,6 +401,34 @@ export function ServerSettingsForm({
checked={settings.paginatedLists ?? false}
onChange={(e) => onPaginatedListsChange(e.currentTarget.checked)}
/>
<Stack gap="xs">
<Text size="sm" fw={500}>
Advertised Extensions
</Text>
<Text size="xs" c="var(--inspector-text-secondary)">
Which extensions the Inspector declares to this server in its
client capabilities. A server may register different tools
depending on what the client advertises — toggle these to debug
that. Takes effect on the next connect.
</Text>
{ADVERTISABLE_EXTENSIONS.map((ext) => (
<Checkbox
key={ext.key}
label={ext.label}
checked={isExtensionAdvertised(
settings,
ext.key,
ext.defaultAdvertised,
)}
onChange={(e) =>
onAdvertisedExtensionChange(
ext.key,
e.currentTarget.checked,
)
}
/>
))}
</Stack>
<NumberInput
label="Network Log Size"
description="Maximum number of HTTP requests kept in the Network log for this server. Older entries rotate out past this limit; a response body that arrives after its entry rotated out is dropped. Use 0 for unlimited (not recommended). Applies immediately to the active connection."

View file

@ -162,6 +162,63 @@ describe("ServerSettingsModal", () => {
);
});
it("folds an advertised-extension toggle into settings.advertisedExtensions (#1739)", async () => {
const user = userEvent.setup();
const onSettingsChange = vi.fn();
renderWithMantine(
<ServerSettingsModal
opened
settings={emptySettings}
serverType="streamable-http"
isStdio={false}
onClose={vi.fn()}
onSettingsChange={onSettingsChange}
/>,
);
// The Options section is expanded by default; Tasks is checked (registry
// default), so clicking it toggles the override off.
await user.click(
screen.getByRole("checkbox", {
name: /Tasks \(io\.modelcontextprotocol\/tasks\)/,
}),
);
expect(onSettingsChange).toHaveBeenCalledWith(
expect.objectContaining({
advertisedExtensions: { "io.modelcontextprotocol/tasks": false },
}),
);
});
it("reconverges to no override when an extension toggle returns to its default (#1739)", async () => {
const user = userEvent.setup();
const onSettingsChange = vi.fn();
renderWithMantine(
<ServerSettingsModal
opened
// Start from a disabling override so re-checking Tasks returns it to the
// registry default (advertised) and should drop the key entirely.
settings={{
...emptySettings,
advertisedExtensions: { "io.modelcontextprotocol/tasks": false },
}}
serverType="streamable-http"
isStdio={false}
onClose={vi.fn()}
onSettingsChange={onSettingsChange}
/>,
);
await user.click(
screen.getByRole("checkbox", {
name: /Tasks \(io\.modelcontextprotocol\/tasks\)/,
}),
);
// The only override returned to its default → the whole map drops to
// undefined (no-override), keeping the on-disk round-trip minimal.
expect(onSettingsChange).toHaveBeenCalledWith(
expect.objectContaining({ advertisedExtensions: undefined }),
);
});
it("hides the modern log-level control when this server negotiated legacy under 'auto' (#1629)", () => {
renderWithMantine(
<ServerSettingsModal

View file

@ -9,6 +9,7 @@ import type {
ServerType,
} from "@inspector/core/mcp/types.js";
import { isOAuthCapableServerType } from "@inspector/core/mcp/config.js";
import { ADVERTISABLE_EXTENSIONS } from "@inspector/core/mcp/extensions.js";
import { ListToggle } from "../../elements/ListToggle/ListToggle";
import {
ServerSettingsForm,
@ -176,6 +177,24 @@ export function ServerSettingsModal({
onSettingsChange({ ...settings, paginatedLists: value });
}
function handleAdvertisedExtensionChange(key: string, checked: boolean) {
const next = { ...settings.advertisedExtensions };
const ext = ADVERTISABLE_EXTENSIONS.find((e) => e.key === key);
// Reconverge to "no override" when the toggle returns to the registry
// default, so the on-disk map (and its byte-stable round-trip) stays minimal
// — matching the omit-when-default policy used for the other settings. Only
// a value that actually differs from the default is persisted.
if (ext && checked === ext.defaultAdvertised) {
delete next[key];
} else {
next[key] = checked;
}
onSettingsChange({
...settings,
advertisedExtensions: Object.keys(next).length > 0 ? next : undefined,
});
}
function handleMaxFetchRequestsChange(value: number) {
onSettingsChange({ ...settings, maxFetchRequests: value });
}
@ -251,6 +270,7 @@ export function ServerSettingsModal({
onTimeoutChange={handleTimeoutChange}
onAutoRefreshChange={handleAutoRefreshChange}
onPaginatedListsChange={handlePaginatedListsChange}
onAdvertisedExtensionChange={handleAdvertisedExtensionChange}
onMaxFetchRequestsChange={handleMaxFetchRequestsChange}
onProtocolEraChange={handleProtocolEraChange}
onModernLogLevelChange={handleModernLogLevelChange}