Skip to content

Commit fe29539

Browse files
cliffhallclaude
andauthored
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) <[email protected]> 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) <[email protected]> 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) <[email protected]> Claude-Session: https://claude.ai/code/session_01XNdjEPKLG637X8YmhDiEk5 --------- Co-authored-by: Claude Opus 4.8 (1M context) <[email protected]>
1 parent 2f80958 commit fe29539

22 files changed

Lines changed: 585 additions & 0 deletions

‎README.md‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -118,6 +118,8 @@ A streamable-HTTP server can also serve the **modern (2026-07-28) protocol era**
118118

119119
`test-servers/configs/pagination-http.json` is the **page-by-page fetch showcase** (#1721). It serves 12 tools, 12 resources, and 12 prompts (presets `numbered_tools` / `numbered_resources` / `numbered_prompts`, `count: 12`) with `maxPageSize` of 4 for each, so every list paginates into three pages. Turn on **"Fetch Lists One Page at a Time"** (Server Settings — the `paginatedLists` setting, or the **Paginated** switch in a list sidebar) and the Tools/Resources/Prompts lists load page 1 only (4 items) with a **Load next page** control and an _N pages loaded_ status; each click fetches the next 4 and appends them, and Refresh resets to page 1. With the switch off (the default), the same lists auto-aggregate all three pages on connect.
120120

121+
`test-servers/configs/advertised-extensions-http.json` is the **advertised-extensions showcase** (#1739). It serves `echo` (always) and a `get_weather` tool that is **gated on the `io.modelcontextprotocol/tasks` extension** (`extensionGatedTools`): the tool is registered but starts disabled, and the server enables it on `notifications/initialized` only when the connected client declared that extension in its `capabilities.extensions`. Connect (the Inspector advertises the Tasks extension by default) and the Tools list shows both `echo` and `get_weather`. Open **Server Settings → Advertised Extensions**, uncheck **Tasks (io.modelcontextprotocol/tasks)**, and reconnect: the client now advertises no extensions, the server never enables `get_weather`, and the Tools list shows only `echo`. This demonstrates the debugging knob — a server legitimately changing tool registration on what the client advertises. (Legacy stateful leg only; the modern per-request leg has no persistent `oninitialized`.)
122+
121123
`test-servers/configs/logging-legacy-http.json` and `test-servers/configs/logging-modern-http.json` are the **logging era-fork showcase** (#1629). Both serve `logging: true` plus a `send_notification` tool that emits a `notifications/message` at a chosen level; the legacy one is a plain streamable-HTTP server (`logging/setLevel` era) and the modern one sets `transport.modern: true`. Connect to the legacy server and open the **Logs** tab to get the session-scoped **Set Active Level** selector + **Set** button; calling `send_notification` streams the log into the panel. Connect to the modern one with **Protocol Era = Modern** and the same tab instead shows the **Log Level per Request** control — pick a level to opt in and the client stamps `_meta["io.modelcontextprotocol/logLevel"]` on every subsequent request (verify in the Network tab's request body); calling `send_notification` then streams the log into the panel over the request's SSE response. Set the control back to **Off** and the same call is silently gated — the request omits the `logLevel` key, so the log never arrives. That gating is faithful to the spec ("a server MUST NOT emit `notifications/message` for a request that didn't opt in") because `send_notification` emits through the SDK's request-scoped, threshold-aware `extra.log` (`ctx.mcpReq.log`): on the modern leg it reads the per-request `logLevel` opt-in from the request envelope and drops the message when the client didn't opt in or the level is below the requested severity; on legacy it honors the session level from `logging/setLevel`. Because it emits through the request's `notify`, the modern response upgrades to SSE and the log rides the originating request's stream.
122124

123125
`test-servers/configs/subscriptions-legacy-http.json` and `test-servers/configs/subscriptions-modern-http.json` are the **resource-subscription era-fork showcase** (#1630). Both serve three `numbered_resources` with `subscriptions: true`; the legacy one also serves an `update_resource` tool, and the modern one sets `transport.modern: true`. Connect to the **legacy** server, open a resource in the **Resources** tab and click **Subscribe** — the client sends `resources/subscribe` (Network/Protocol view) and the Subscriptions section lists the URI with no stream chrome; call `update_resource` with that URI and the server updates the content and emits `notifications/resources/updated`, stamping the subscribed tile's last-updated time. Connect to the **modern** server with **Protocol Era = Modern** and the same Subscribe instead sends **`subscriptions/listen`** (its filter carries `resourceSubscriptions` + the `resourcesListChanged` opt-in) and resolves on `notifications/subscriptions/acknowledged`; the Subscriptions section then shows the stream-status badge (`Connecting…` → `Listening`) in its header, and if the long-lived stream drops it reconnects by re-listing. The modern config deliberately **omits** `update_resource`: the SDK's modern leg is stateless/per-request (`createMcpHandler(() => createMcpServer(config))`), so the tool would run against a throwaway server instance — the content change wouldn't persist for the next `resources/read`, and its `resources/updated` wouldn't reach the (separate) listen stream — which is more confusing than useful. The live update-notification round-trip is therefore demonstrated on the legacy (stateful-session) server; the modern server is for the subscribe/listen/badge behavior. (The Inspector's _receive_ path is era-transparent, so a real stateful modern server that routes `resources/updated` onto the listen stream drives the subscribed tile the same way.)

‎clients/web/src/App.tsx‎

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2271,6 +2271,12 @@ function App() {
22712271
savedSettings.protocolEra,
22722272
),
22732273
}),
2274+
// Per-server advertised-extension overrides (#1739). Absent/empty falls
2275+
// back to the registry defaults in the InspectorClient constructor.
2276+
...(savedSettings?.advertisedExtensions &&
2277+
Object.keys(savedSettings.advertisedExtensions).length > 0 && {
2278+
advertisedExtensions: savedSettings.advertisedExtensions,
2279+
}),
22742280
// Set on the `/oauth/callback` rebuild so the client's `saveSession`
22752281
// events (and any later persistence) key off the same OAuth authId
22762282
// the pre-redirect page saved under.

‎clients/web/src/components/groups/ServerSettingsForm/ServerSettingsForm.stories.tsx‎

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -137,6 +137,18 @@ function InteractiveRender(args: ServerSettingsFormProps) {
137137
settings: { ...args.settings, paginatedLists: value },
138138
});
139139
}}
140+
onAdvertisedExtensionChange={(key, checked) => {
141+
args.onAdvertisedExtensionChange(key, checked);
142+
updateArgs({
143+
settings: {
144+
...args.settings,
145+
advertisedExtensions: {
146+
...args.settings.advertisedExtensions,
147+
[key]: checked,
148+
},
149+
},
150+
});
151+
}}
140152
onMaxFetchRequestsChange={(value) => {
141153
args.onMaxFetchRequestsChange(value);
142154
updateArgs({
@@ -191,6 +203,7 @@ const meta: Meta<typeof ServerSettingsForm> = {
191203
onTimeoutChange: fn(),
192204
onAutoRefreshChange: fn(),
193205
onPaginatedListsChange: fn(),
206+
onAdvertisedExtensionChange: fn(),
194207
onMaxFetchRequestsChange: fn(),
195208
onProtocolEraChange: fn(),
196209
onModernLogLevelChange: fn(),

‎clients/web/src/components/groups/ServerSettingsForm/ServerSettingsForm.test.tsx‎

Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -66,6 +66,7 @@ const baseHandlers = {
6666
onTimeoutChange: vi.fn(),
6767
onAutoRefreshChange: vi.fn(),
6868
onPaginatedListsChange: vi.fn(),
69+
onAdvertisedExtensionChange: vi.fn(),
6970
onMaxFetchRequestsChange: vi.fn(),
7071
onProtocolEraChange: vi.fn(),
7172
onModernLogLevelChange: vi.fn(),
@@ -452,6 +453,62 @@ describe("ServerSettingsForm", () => {
452453
expect(onAutoRefreshChange).toHaveBeenCalledWith(true);
453454
});
454455

456+
it("renders the Advertised Extensions section with Tasks checked by default", () => {
457+
renderWithMantine(
458+
<ServerSettingsForm
459+
{...baseHandlers}
460+
settings={emptySettings}
461+
expandedSections={["options"]}
462+
/>,
463+
);
464+
const tasks = screen.getByRole("checkbox", {
465+
name: /Tasks \(io\.modelcontextprotocol\/tasks\)/,
466+
});
467+
// No override present → the registry default (advertised) shows checked.
468+
expect(tasks).toBeChecked();
469+
});
470+
471+
it("reflects an advertisedExtensions override that disables Tasks", () => {
472+
renderWithMantine(
473+
<ServerSettingsForm
474+
{...baseHandlers}
475+
settings={{
476+
...emptySettings,
477+
advertisedExtensions: { "io.modelcontextprotocol/tasks": false },
478+
}}
479+
expandedSections={["options"]}
480+
/>,
481+
);
482+
expect(
483+
screen.getByRole("checkbox", {
484+
name: /Tasks \(io\.modelcontextprotocol\/tasks\)/,
485+
}),
486+
).not.toBeChecked();
487+
});
488+
489+
it("invokes onAdvertisedExtensionChange when a Tasks toggle is clicked", async () => {
490+
const user = userEvent.setup();
491+
const onAdvertisedExtensionChange = vi.fn();
492+
renderWithMantine(
493+
<ServerSettingsForm
494+
{...baseHandlers}
495+
onAdvertisedExtensionChange={onAdvertisedExtensionChange}
496+
settings={emptySettings}
497+
expandedSections={["options"]}
498+
/>,
499+
);
500+
// Default is checked; clicking flips it to unadvertised.
501+
await user.click(
502+
screen.getByRole("checkbox", {
503+
name: /Tasks \(io\.modelcontextprotocol\/tasks\)/,
504+
}),
505+
);
506+
expect(onAdvertisedExtensionChange).toHaveBeenCalledWith(
507+
"io.modelcontextprotocol/tasks",
508+
false,
509+
);
510+
});
511+
455512
it("renders the Network Log Size field reflecting maxFetchRequests", () => {
456513
renderWithMantine(
457514
<ServerSettingsForm

‎clients/web/src/components/groups/ServerSettingsForm/ServerSettingsForm.tsx‎

Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,8 +26,22 @@ import {
2626
MODERN_LOG_LEVELS,
2727
} from "@inspector/core/mcp/types.js";
2828
import { isOAuthCapableServerType } from "@inspector/core/mcp/config.js";
29+
import { ADVERTISABLE_EXTENSIONS } from "@inspector/core/mcp/extensions.js";
2930
import type { Root } from "@modelcontextprotocol/client";
3031

32+
/**
33+
* Resolve the advertised state of an extension for the form: the per-server
34+
* override wins, else the registry default. Mirrors `buildClientExtensions`'s
35+
* resolution so the switches show exactly what the client will advertise.
36+
*/
37+
function isExtensionAdvertised(
38+
settings: InspectorServerSettings,
39+
key: string,
40+
defaultAdvertised: boolean,
41+
): boolean {
42+
return settings.advertisedExtensions?.[key] ?? defaultAdvertised;
43+
}
44+
3145
export type ServerSettingsSection =
3246
| "options"
3347
| "environment"
@@ -65,6 +79,12 @@ export interface ServerSettingsFormProps {
6579
) => void;
6680
onAutoRefreshChange: (value: boolean) => void;
6781
onPaginatedListsChange: (value: boolean) => void;
82+
/**
83+
* Toggle whether the Inspector advertises the extension `key` to this server.
84+
* `checked` is the new advertise state; the modal folds it into
85+
* `settings.advertisedExtensions`. (#1739)
86+
*/
87+
onAdvertisedExtensionChange: (key: string, checked: boolean) => void;
6888
onMaxFetchRequestsChange: (value: number) => void;
6989
onProtocolEraChange: (value: ServerProtocolEra) => void;
7090
onModernLogLevelChange: (value: ModernLogLevel) => void;
@@ -272,6 +292,7 @@ export function ServerSettingsForm({
272292
onTimeoutChange,
273293
onAutoRefreshChange,
274294
onPaginatedListsChange,
295+
onAdvertisedExtensionChange,
275296
onMaxFetchRequestsChange,
276297
onProtocolEraChange,
277298
onModernLogLevelChange,
@@ -380,6 +401,34 @@ export function ServerSettingsForm({
380401
checked={settings.paginatedLists ?? false}
381402
onChange={(e) => onPaginatedListsChange(e.currentTarget.checked)}
382403
/>
404+
<Stack gap="xs">
405+
<Text size="sm" fw={500}>
406+
Advertised Extensions
407+
</Text>
408+
<Text size="xs" c="var(--inspector-text-secondary)">
409+
Which extensions the Inspector declares to this server in its
410+
client capabilities. A server may register different tools
411+
depending on what the client advertises — toggle these to debug
412+
that. Takes effect on the next connect.
413+
</Text>
414+
{ADVERTISABLE_EXTENSIONS.map((ext) => (
415+
<Checkbox
416+
key={ext.key}
417+
label={ext.label}
418+
checked={isExtensionAdvertised(
419+
settings,
420+
ext.key,
421+
ext.defaultAdvertised,
422+
)}
423+
onChange={(e) =>
424+
onAdvertisedExtensionChange(
425+
ext.key,
426+
e.currentTarget.checked,
427+
)
428+
}
429+
/>
430+
))}
431+
</Stack>
383432
<NumberInput
384433
label="Network Log Size"
385434
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."

‎clients/web/src/components/groups/ServerSettingsModal/ServerSettingsModal.test.tsx‎

Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -162,6 +162,63 @@ describe("ServerSettingsModal", () => {
162162
);
163163
});
164164

165+
it("folds an advertised-extension toggle into settings.advertisedExtensions (#1739)", async () => {
166+
const user = userEvent.setup();
167+
const onSettingsChange = vi.fn();
168+
renderWithMantine(
169+
<ServerSettingsModal
170+
opened
171+
settings={emptySettings}
172+
serverType="streamable-http"
173+
isStdio={false}
174+
onClose={vi.fn()}
175+
onSettingsChange={onSettingsChange}
176+
/>,
177+
);
178+
// The Options section is expanded by default; Tasks is checked (registry
179+
// default), so clicking it toggles the override off.
180+
await user.click(
181+
screen.getByRole("checkbox", {
182+
name: /Tasks \(io\.modelcontextprotocol\/tasks\)/,
183+
}),
184+
);
185+
expect(onSettingsChange).toHaveBeenCalledWith(
186+
expect.objectContaining({
187+
advertisedExtensions: { "io.modelcontextprotocol/tasks": false },
188+
}),
189+
);
190+
});
191+
192+
it("reconverges to no override when an extension toggle returns to its default (#1739)", async () => {
193+
const user = userEvent.setup();
194+
const onSettingsChange = vi.fn();
195+
renderWithMantine(
196+
<ServerSettingsModal
197+
opened
198+
// Start from a disabling override so re-checking Tasks returns it to the
199+
// registry default (advertised) and should drop the key entirely.
200+
settings={{
201+
...emptySettings,
202+
advertisedExtensions: { "io.modelcontextprotocol/tasks": false },
203+
}}
204+
serverType="streamable-http"
205+
isStdio={false}
206+
onClose={vi.fn()}
207+
onSettingsChange={onSettingsChange}
208+
/>,
209+
);
210+
await user.click(
211+
screen.getByRole("checkbox", {
212+
name: /Tasks \(io\.modelcontextprotocol\/tasks\)/,
213+
}),
214+
);
215+
// The only override returned to its default → the whole map drops to
216+
// undefined (no-override), keeping the on-disk round-trip minimal.
217+
expect(onSettingsChange).toHaveBeenCalledWith(
218+
expect.objectContaining({ advertisedExtensions: undefined }),
219+
);
220+
});
221+
165222
it("hides the modern log-level control when this server negotiated legacy under 'auto' (#1629)", () => {
166223
renderWithMantine(
167224
<ServerSettingsModal

‎clients/web/src/components/groups/ServerSettingsModal/ServerSettingsModal.tsx‎

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ import type {
99
ServerType,
1010
} from "@inspector/core/mcp/types.js";
1111
import { isOAuthCapableServerType } from "@inspector/core/mcp/config.js";
12+
import { ADVERTISABLE_EXTENSIONS } from "@inspector/core/mcp/extensions.js";
1213
import { ListToggle } from "../../elements/ListToggle/ListToggle";
1314
import {
1415
ServerSettingsForm,
@@ -176,6 +177,24 @@ export function ServerSettingsModal({
176177
onSettingsChange({ ...settings, paginatedLists: value });
177178
}
178179

180+
function handleAdvertisedExtensionChange(key: string, checked: boolean) {
181+
const next = { ...settings.advertisedExtensions };
182+
const ext = ADVERTISABLE_EXTENSIONS.find((e) => e.key === key);
183+
// Reconverge to "no override" when the toggle returns to the registry
184+
// default, so the on-disk map (and its byte-stable round-trip) stays minimal
185+
// — matching the omit-when-default policy used for the other settings. Only
186+
// a value that actually differs from the default is persisted.
187+
if (ext && checked === ext.defaultAdvertised) {
188+
delete next[key];
189+
} else {
190+
next[key] = checked;
191+
}
192+
onSettingsChange({
193+
...settings,
194+
advertisedExtensions: Object.keys(next).length > 0 ? next : undefined,
195+
});
196+
}
197+
179198
function handleMaxFetchRequestsChange(value: number) {
180199
onSettingsChange({ ...settings, maxFetchRequests: value });
181200
}
@@ -251,6 +270,7 @@ export function ServerSettingsModal({
251270
onTimeoutChange={handleTimeoutChange}
252271
onAutoRefreshChange={handleAutoRefreshChange}
253272
onPaginatedListsChange={handlePaginatedListsChange}
273+
onAdvertisedExtensionChange={handleAdvertisedExtensionChange}
254274
onMaxFetchRequestsChange={handleMaxFetchRequestsChange}
255275
onProtocolEraChange={handleProtocolEraChange}
256276
onModernLogLevelChange={handleModernLogLevelChange}

‎clients/web/src/test/core/mcp/serverList.test.ts‎

Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -284,6 +284,63 @@ describe("serverEntriesToMcpConfig", () => {
284284
expect(round).toEqual(original);
285285
});
286286

287+
it("round-trips advertisedExtensions: lifts a non-empty map to settings and back to disk", () => {
288+
const original: MCPConfig = {
289+
mcpServers: {
290+
exttest: {
291+
type: "streamable-http",
292+
url: "https://x.test/mcp",
293+
advertisedExtensions: { "io.modelcontextprotocol/tasks": false },
294+
},
295+
},
296+
};
297+
const [entry] = mcpConfigToServerEntries(original);
298+
expect(entry?.settings?.advertisedExtensions).toEqual({
299+
"io.modelcontextprotocol/tasks": false,
300+
});
301+
const round = serverEntriesToMcpConfig(mcpConfigToServerEntries(original));
302+
expect(round).toEqual(original);
303+
});
304+
305+
it("omits advertisedExtensions from disk when unset, and leaves settings unset", () => {
306+
const original: MCPConfig = {
307+
mcpServers: {
308+
extnone: {
309+
type: "streamable-http",
310+
url: "https://x.test/mcp",
311+
connectionTimeout: 5000,
312+
},
313+
},
314+
};
315+
const [entry] = mcpConfigToServerEntries(original);
316+
expect(entry?.settings?.advertisedExtensions).toBeUndefined();
317+
const round = serverEntriesToMcpConfig(mcpConfigToServerEntries(original));
318+
expect("advertisedExtensions" in (round.mcpServers.extnone ?? {})).toBe(
319+
false,
320+
);
321+
expect(round).toEqual(original);
322+
});
323+
324+
it("treats an empty advertisedExtensions map on disk as unset (omitted on write)", () => {
325+
const original: MCPConfig = {
326+
mcpServers: {
327+
extempty: {
328+
type: "streamable-http",
329+
url: "https://x.test/mcp",
330+
advertisedExtensions: {},
331+
},
332+
},
333+
};
334+
const [entry] = mcpConfigToServerEntries(original);
335+
// An empty map reads back as unset for the form...
336+
expect(entry?.settings?.advertisedExtensions).toBeUndefined();
337+
// ...and the write side omits it, so the empty map does not round-trip.
338+
const round = serverEntriesToMcpConfig(mcpConfigToServerEntries(original));
339+
expect("advertisedExtensions" in (round.mcpServers.extempty ?? {})).toBe(
340+
false,
341+
);
342+
});
343+
287344
it("round-trips maxFetchRequests: lifts a non-default value to settings and back to disk", () => {
288345
const original: MCPConfig = {
289346
mcpServers: {

0 commit comments

Comments
 (0)