diff --git a/README.md b/README.md index 07b0afbf..202f4715 100644 --- a/README.md +++ b/README.md @@ -118,6 +118,8 @@ A streamable-HTTP server can also serve the **modern (2026-07-28) protocol era** `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. +`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`.) + `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. `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.) diff --git a/clients/web/src/App.tsx b/clients/web/src/App.tsx index 985bed1c..ee7c978a 100644 --- a/clients/web/src/App.tsx +++ b/clients/web/src/App.tsx @@ -2271,6 +2271,12 @@ function App() { savedSettings.protocolEra, ), }), + // Per-server advertised-extension overrides (#1739). Absent/empty falls + // back to the registry defaults in the InspectorClient constructor. + ...(savedSettings?.advertisedExtensions && + Object.keys(savedSettings.advertisedExtensions).length > 0 && { + advertisedExtensions: savedSettings.advertisedExtensions, + }), // Set on the `/oauth/callback` rebuild so the client's `saveSession` // events (and any later persistence) key off the same OAuth authId // the pre-redirect page saved under. diff --git a/clients/web/src/components/groups/ServerSettingsForm/ServerSettingsForm.stories.tsx b/clients/web/src/components/groups/ServerSettingsForm/ServerSettingsForm.stories.tsx index 212f2bd1..5fc898ae 100644 --- a/clients/web/src/components/groups/ServerSettingsForm/ServerSettingsForm.stories.tsx +++ b/clients/web/src/components/groups/ServerSettingsForm/ServerSettingsForm.stories.tsx @@ -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 = { onTimeoutChange: fn(), onAutoRefreshChange: fn(), onPaginatedListsChange: fn(), + onAdvertisedExtensionChange: fn(), onMaxFetchRequestsChange: fn(), onProtocolEraChange: fn(), onModernLogLevelChange: fn(), diff --git a/clients/web/src/components/groups/ServerSettingsForm/ServerSettingsForm.test.tsx b/clients/web/src/components/groups/ServerSettingsForm/ServerSettingsForm.test.tsx index 0855e1b9..7351ca48 100644 --- a/clients/web/src/components/groups/ServerSettingsForm/ServerSettingsForm.test.tsx +++ b/clients/web/src/components/groups/ServerSettingsForm/ServerSettingsForm.test.tsx @@ -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( + , + ); + 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( + , + ); + 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( + , + ); + // 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( 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)} /> + + + Advertised Extensions + + + 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. + + {ADVERTISABLE_EXTENSIONS.map((ext) => ( + + onAdvertisedExtensionChange( + ext.key, + e.currentTarget.checked, + ) + } + /> + ))} + { ); }); + it("folds an advertised-extension toggle into settings.advertisedExtensions (#1739)", async () => { + const user = userEvent.setup(); + const onSettingsChange = vi.fn(); + renderWithMantine( + , + ); + // 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( + , + ); + 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( 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} diff --git a/clients/web/src/test/core/mcp/serverList.test.ts b/clients/web/src/test/core/mcp/serverList.test.ts index e2b33ee1..22e9639b 100644 --- a/clients/web/src/test/core/mcp/serverList.test.ts +++ b/clients/web/src/test/core/mcp/serverList.test.ts @@ -284,6 +284,63 @@ describe("serverEntriesToMcpConfig", () => { expect(round).toEqual(original); }); + it("round-trips advertisedExtensions: lifts a non-empty map to settings and back to disk", () => { + const original: MCPConfig = { + mcpServers: { + exttest: { + type: "streamable-http", + url: "https://x.test/mcp", + advertisedExtensions: { "io.modelcontextprotocol/tasks": false }, + }, + }, + }; + const [entry] = mcpConfigToServerEntries(original); + expect(entry?.settings?.advertisedExtensions).toEqual({ + "io.modelcontextprotocol/tasks": false, + }); + const round = serverEntriesToMcpConfig(mcpConfigToServerEntries(original)); + expect(round).toEqual(original); + }); + + it("omits advertisedExtensions from disk when unset, and leaves settings unset", () => { + const original: MCPConfig = { + mcpServers: { + extnone: { + type: "streamable-http", + url: "https://x.test/mcp", + connectionTimeout: 5000, + }, + }, + }; + const [entry] = mcpConfigToServerEntries(original); + expect(entry?.settings?.advertisedExtensions).toBeUndefined(); + const round = serverEntriesToMcpConfig(mcpConfigToServerEntries(original)); + expect("advertisedExtensions" in (round.mcpServers.extnone ?? {})).toBe( + false, + ); + expect(round).toEqual(original); + }); + + it("treats an empty advertisedExtensions map on disk as unset (omitted on write)", () => { + const original: MCPConfig = { + mcpServers: { + extempty: { + type: "streamable-http", + url: "https://x.test/mcp", + advertisedExtensions: {}, + }, + }, + }; + const [entry] = mcpConfigToServerEntries(original); + // An empty map reads back as unset for the form... + expect(entry?.settings?.advertisedExtensions).toBeUndefined(); + // ...and the write side omits it, so the empty map does not round-trip. + const round = serverEntriesToMcpConfig(mcpConfigToServerEntries(original)); + expect("advertisedExtensions" in (round.mcpServers.extempty ?? {})).toBe( + false, + ); + }); + it("round-trips maxFetchRequests: lifts a non-default value to settings and back to disk", () => { const original: MCPConfig = { mcpServers: { diff --git a/clients/web/src/test/integration/mcp/inspectorClient-advertised-extensions.test.ts b/clients/web/src/test/integration/mcp/inspectorClient-advertised-extensions.test.ts new file mode 100644 index 00000000..97ca51c4 --- /dev/null +++ b/clients/web/src/test/integration/mcp/inspectorClient-advertised-extensions.test.ts @@ -0,0 +1,129 @@ +import { describe, it, expect, afterEach } from "vitest"; +import * as z from "zod/v4"; +import { InspectorClient } from "@inspector/core/mcp/inspectorClient.js"; +import { createTransportNode } from "@inspector/core/mcp/node/transport.js"; +import { TASKS_EXTENSION_KEY } from "@inspector/core/mcp/modernTaskSchemas.js"; +import { + createTestServerHttp, + type TestServerHttp, + createTestServerInfo, +} from "@modelcontextprotocol/inspector-test-server"; + +/** + * Live coverage of advertised-extension gating (#1739). The Inspector advertises + * a per-server `capabilities.extensions` map (Phase 1, #1738), and a server may + * register different tools depending on what the client declared. Here a legacy + * stateful server gates a tool on the `io.modelcontextprotocol/tasks` extension: + * with the extension advertised the tool appears in `tools/list`; with it + * disabled via `advertisedExtensions` the same tool is absent. Driven against a + * real server over a real transport. + * + * A FRESH server is started per scenario on purpose: the gate's `oninitialized` + * hook only enables the tool, so a server that already saw a tasks-advertising + * client would keep it enabled for a later connection. + */ +describe("advertised-extension gating (#1739)", () => { + const GATED_TOOL = "gated_tool"; + const ALWAYS_TOOL = "always_tool"; + + let client: InspectorClient | null = null; + const servers: TestServerHttp[] = []; + + afterEach(async () => { + if (client) { + try { + await client.disconnect(); + } catch { + // ignore + } + client = null; + } + while (servers.length) { + const s = servers.pop(); + try { + await s?.stop(); + } catch { + // ignore + } + } + }); + + async function startGatedServer(): Promise { + const started = createTestServerHttp({ + serverInfo: createTestServerInfo("advertised-ext-test", "1.0.0"), + tools: [ + { + name: ALWAYS_TOOL, + description: "Always registered", + inputSchema: { message: z.string().optional() }, + handler: async () => ({ + content: [{ type: "text" as const, text: "always" }], + }), + }, + { + name: GATED_TOOL, + description: "Registered only when the client declares tasks", + inputSchema: { message: z.string().optional() }, + handler: async () => ({ + content: [{ type: "text" as const, text: "gated" }], + }), + }, + ], + extensionGatedTools: { [TASKS_EXTENSION_KEY]: GATED_TOOL }, + }); + await started.start(); + servers.push(started); + return started; + } + + async function connect( + url: string, + advertisedExtensions?: Record, + ): Promise { + const connected = new InspectorClient( + { type: "streamable-http", url }, + { + environment: { transport: createTransportNode }, + ...(advertisedExtensions && { advertisedExtensions }), + }, + ); + await connected.connect(); + client = connected; + return connected; + } + + async function toolNames(connected: InspectorClient): Promise { + const { tools } = await connected.listTools(); + return tools.map((t) => t.name); + } + + it("registers the gated tool when the client advertises the extension (default)", async () => { + const started = await startGatedServer(); + // Default config: the Tasks extension is advertised by the registry default. + const connected = await connect(started.url); + const names = await toolNames(connected); + expect(names).toContain(ALWAYS_TOOL); + expect(names).toContain(GATED_TOOL); + }); + + it("registers the gated tool when the extension is explicitly advertised", async () => { + const started = await startGatedServer(); + const connected = await connect(started.url, { + [TASKS_EXTENSION_KEY]: true, + }); + const names = await toolNames(connected); + expect(names).toContain(GATED_TOOL); + }); + + it("omits the gated tool when the extension is not advertised", async () => { + const started = await startGatedServer(); + // Disable the only registry extension → the client sends no extensions, + // so the server never enables the gated tool. + const connected = await connect(started.url, { + [TASKS_EXTENSION_KEY]: false, + }); + const names = await toolNames(connected); + expect(names).toContain(ALWAYS_TOOL); + expect(names).not.toContain(GATED_TOOL); + }); +}); diff --git a/clients/web/src/test/integration/mcp/remote/server-extra-coverage.test.ts b/clients/web/src/test/integration/mcp/remote/server-extra-coverage.test.ts index ee0182ae..04e55ed2 100644 --- a/clients/web/src/test/integration/mcp/remote/server-extra-coverage.test.ts +++ b/clients/web/src/test/integration/mcp/remote/server-extra-coverage.test.ts @@ -513,6 +513,27 @@ describe("server.ts supplemental coverage", () => { expect((await res.json()).error).toMatch(/modernLogLevel/); }); + it("rejects a non-boolean-valued advertisedExtensions (#1739)", async () => { + const res = await postSettings({ + ...base, + advertisedExtensions: { "io.modelcontextprotocol/tasks": "yes" }, + }); + expect((await res.json()).error).toMatch(/advertisedExtensions/); + }); + + it("rejects a non-object advertisedExtensions (#1739)", async () => { + const res = await postSettings({ + ...base, + advertisedExtensions: ["io.modelcontextprotocol/tasks"], + }); + expect((await res.json()).error).toMatch(/advertisedExtensions/); + }); + + it("accepts an empty advertisedExtensions map without persisting it (#1739)", async () => { + const res = await postSettings({ ...base, advertisedExtensions: {} }); + expect(res.status).toBe(200); + }); + it("accepts a fully-populated valid settings payload", async () => { const res = await postSettings({ ...base, @@ -521,6 +542,7 @@ describe("server.ts supplemental coverage", () => { taskTtl: 1000, autoRefreshOnListChanged: true, paginatedLists: true, + advertisedExtensions: { "io.modelcontextprotocol/tasks": false }, maxFetchRequests: 5, protocolEra: "modern", modernLogLevel: "off", diff --git a/core/mcp/remote/node/server.ts b/core/mcp/remote/node/server.ts index 0cec99ca..eec3f5d5 100644 --- a/core/mcp/remote/node/server.ts +++ b/core/mcp/remote/node/server.ts @@ -1354,6 +1354,12 @@ export function createRemoteApp( typeof (e as Record).value === "string", ); }; + const isBooleanRecord = (v: unknown): v is Record => { + if (v === null || typeof v !== "object" || Array.isArray(v)) return false; + return Object.values(v as Record).every( + (flag) => typeof flag === "boolean", + ); + }; if (!isKvArray(obj.headers)) { return { ok: false, @@ -1498,6 +1504,19 @@ export function createRemoteApp( "settings.modernLogLevel must be 'off' or a logging level (debug…emergency)", }; } + // advertisedExtensions is optional on the wire (older clients won't send + // it); when present it must be a flat record of boolean flags keyed by + // extension id, otherwise it defaults to absent below. + if ( + obj.advertisedExtensions !== undefined && + !isBooleanRecord(obj.advertisedExtensions) + ) { + return { + ok: false, + error: + "settings.advertisedExtensions must be an object of boolean flags", + }; + } // Build the validated value from explicitly named fields rather than // casting the raw object through. Unknown keys silently drop so a // misconfigured client can't smuggle stowaways onto disk, and consumers @@ -1568,6 +1587,15 @@ export function createRemoteApp( if (isModernLogLevel(obj.modernLogLevel)) { value.modernLogLevel = obj.modernLogLevel; } + // Optional; carry a non-empty boolean-flag map. Empty/absent reads back as + // unset downstream (omit-on-empty in inspectorSettingsToStoredFields), so a + // client that sent none writes no spurious advertisedExtensions to disk. + if ( + isBooleanRecord(obj.advertisedExtensions) && + Object.keys(obj.advertisedExtensions).length > 0 + ) { + value.advertisedExtensions = { ...obj.advertisedExtensions }; + } // Empty cwd coerces to absent on the value (matching the read side); the // write-through distinguishes "sent cwd: '' " (clear) from "cwd omitted" // (preserve) via `cwdProvided` below rather than the value alone. diff --git a/core/mcp/serverList.ts b/core/mcp/serverList.ts index 57cc6c1d..c77c803a 100644 --- a/core/mcp/serverList.ts +++ b/core/mcp/serverList.ts @@ -124,6 +124,7 @@ type StoredInspectorFields = Pick< | "taskTtl" | "autoRefreshOnListChanged" | "paginatedLists" + | "advertisedExtensions" | "maxFetchRequests" | "oauth" | "roots" @@ -191,6 +192,7 @@ export function storedFieldsToInspectorSettings( stored.taskTtl !== undefined || stored.autoRefreshOnListChanged !== undefined || stored.paginatedLists !== undefined || + stored.advertisedExtensions !== undefined || stored.maxFetchRequests !== undefined || stored.oauth !== undefined || stored.roots !== undefined || @@ -237,6 +239,15 @@ export function storedFieldsToInspectorSettings( if (isModernLogLevel(stored.modernLogLevel)) { settings.modernLogLevel = stored.modernLogLevel; } + // Optional map with no default; carried through only when the file has a + // non-empty object. An absent/empty field reads back as unset, which the + // write side then omits — keeping a byte-stable round-trip. + if ( + stored.advertisedExtensions && + Object.keys(stored.advertisedExtensions).length > 0 + ) { + settings.advertisedExtensions = { ...stored.advertisedExtensions }; + } // Truthiness drops empty-string OAuth fields — mirrors the write-side // coercion in `validateSettings` (server.ts) so a round-trip can't // accidentally surface `oauthClientId: ""` to the form, where the @@ -311,6 +322,16 @@ export function inspectorSettingsToStoredFields( out.paginatedLists = true; } + // Persist only when the user has toggled at least one extension override; + // an empty map reads back as unset (above), keeping the diff minimal for the + // common (no-override) case. + if ( + settings.advertisedExtensions && + Object.keys(settings.advertisedExtensions).length > 0 + ) { + out.advertisedExtensions = { ...settings.advertisedExtensions }; + } + // Persist only when it differs from the default era. Absent reads back as // DEFAULT_PROTOCOL_ERA, so writing the default would inject the field into // hand-edited files that never had it and break byte-stable round-trips. @@ -389,6 +410,7 @@ const INSPECTOR_FIELD_KEY_MAP = { taskTtl: true, autoRefreshOnListChanged: true, paginatedLists: true, + advertisedExtensions: true, maxFetchRequests: true, oauth: true, roots: true, diff --git a/core/mcp/types.ts b/core/mcp/types.ts index 3c6b4187..92b7ad3f 100644 --- a/core/mcp/types.ts +++ b/core/mcp/types.ts @@ -138,6 +138,13 @@ export type StoredMCPServer = MCPServerConfig & { * false (the default). (#1721) */ paginatedLists?: boolean; + /** + * Per-extension overrides for which extensions the Inspector advertises to + * this server (keyed by extension id; a present key wins over the registry + * default). Inspector-specific. Omitted on disk when empty, keeping the file + * diff minimal for servers that never toggled one. (#1739) + */ + advertisedExtensions?: Record; /** * Maximum number of HTTP fetch requests retained in the Network log for this * server (oldest rotate out past the cap). Inspector-specific. Omitted on @@ -691,6 +698,15 @@ export interface InspectorServerSettings { * on disk as `modernLogLevel` and omitted when it equals the default. */ modernLogLevel?: ModernLogLevel; + /** + * Per-extension overrides for which extensions the Inspector advertises to + * this server in `capabilities.extensions`, keyed by extension id. A present + * key wins over the registry default in `ADVERTISABLE_EXTENSIONS`; an absent + * key falls back to it. Toggling an entry is a debugging knob — a server may + * change tool registration on a client-declared extension. Persisted on disk + * as `advertisedExtensions` and omitted when empty. (#1739) + */ + advertisedExtensions?: Record; } /** diff --git a/pr-screenshots/README.md b/pr-screenshots/README.md index b7b612e2..518024e2 100644 --- a/pr-screenshots/README.md +++ b/pr-screenshots/README.md @@ -1,3 +1,39 @@ +# Advertised-extensions toggle (#1739) — proof screenshots + +End-to-end verification of the advertised-extensions debugging knob against a +real legacy HTTP test server (`test-servers/configs/advertised-extensions-http.json` +on port 3220), driven in a real browser through the web client's remote-proxy +transport. The server registers `echo` (always) and `get_weather` **gated on the +`io.modelcontextprotocol/tasks` extension** (`extensionGatedTools`): the tool is +enabled on `notifications/initialized` only when the connected client declared +that extension. + +![Advertised Extensions — Tasks advertised (default)](advertised-ext-settings-tasks-on.png) + +**Server Settings → Advertised Extensions** (the new section). With no override, +**Tasks (io.modelcontextprotocol/tasks)** is checked — the registry default — +so the Inspector advertises it in its client capabilities. + +![Tools with Tasks advertised](advertised-ext-tools-tasks-on.png) + +Connected with Tasks advertised. The server sees the declared extension on +`initialized` and enables the gated tool, so `tools/list` returns **both `echo` +and `get_weather`**. + +![Advertised Extensions — Tasks unchecked](advertised-ext-settings-tasks-off.png) + +Unchecking **Tasks** writes the per-server `advertisedExtensions` override. The +change takes effect on the next connect (as the section note says). + +![Tools with Tasks not advertised](advertised-ext-tools-tasks-off.png) + +After reconnecting, the client advertises no extensions, so the server never +enables the gated tool — `tools/list` now returns **only `echo`**. The server's +tool registration demonstrably changed based on what the client advertised, +exactly the acceptance criterion for #1739. + +--- + # x-mcp-header Tools tooling (#1632) — proof screenshots End-to-end verification of the SEP-2243 `x-mcp-header` Tools tooling against a diff --git a/pr-screenshots/advertised-ext-settings-tasks-off.png b/pr-screenshots/advertised-ext-settings-tasks-off.png new file mode 100644 index 00000000..06af4e66 Binary files /dev/null and b/pr-screenshots/advertised-ext-settings-tasks-off.png differ diff --git a/pr-screenshots/advertised-ext-settings-tasks-on.png b/pr-screenshots/advertised-ext-settings-tasks-on.png new file mode 100644 index 00000000..6fd72f3b Binary files /dev/null and b/pr-screenshots/advertised-ext-settings-tasks-on.png differ diff --git a/pr-screenshots/advertised-ext-tools-tasks-off.png b/pr-screenshots/advertised-ext-tools-tasks-off.png new file mode 100644 index 00000000..48219646 Binary files /dev/null and b/pr-screenshots/advertised-ext-tools-tasks-off.png differ diff --git a/pr-screenshots/advertised-ext-tools-tasks-on.png b/pr-screenshots/advertised-ext-tools-tasks-on.png new file mode 100644 index 00000000..4d536b65 Binary files /dev/null and b/pr-screenshots/advertised-ext-tools-tasks-on.png differ diff --git a/test-servers/configs/advertised-extensions-http.json b/test-servers/configs/advertised-extensions-http.json new file mode 100644 index 00000000..68262c1d --- /dev/null +++ b/test-servers/configs/advertised-extensions-http.json @@ -0,0 +1,14 @@ +{ + "serverInfo": { + "name": "advertised-extensions", + "version": "1.0.0" + }, + "tools": [{ "preset": "echo" }, { "preset": "get_weather" }], + "extensionGatedTools": { + "io.modelcontextprotocol/tasks": "get_weather" + }, + "transport": { + "type": "streamable-http", + "port": 3220 + } +} diff --git a/test-servers/src/composable-test-server.ts b/test-servers/src/composable-test-server.ts index c38b9528..4c836e4b 100644 --- a/test-servers/src/composable-test-server.ts +++ b/test-servers/src/composable-test-server.ts @@ -472,6 +472,19 @@ export interface ServerConfig { resourceTemplates?: number; prompts?: number; }; + /** + * Gate a tool's visibility in `tools/list` on a client-declared extension + * (SEP-2133 `capabilities.extensions`). Maps extension id → tool name (the + * tool must be among the registered presets). The named tool is registered + * but starts disabled; on `notifications/initialized`, if the connected + * client declared that extension, it is enabled and appears in `tools/list`. + * + * Demonstrates that advertised client extensions change server tool + * registration (#1739 / #1633). Legacy stateful leg only — the modern + * per-request leg builds a fresh server per request with no persistent + * `oninitialized`, so a gated tool stays disabled there. + */ + extensionGatedTools?: Record; /** * Whether to advertise tasks capability * If enabled, server will advertise tasks capability with list and cancel support @@ -1304,9 +1317,45 @@ export function createMcpServer(config: ServerConfig): McpServer { wireModernTaskHandlers(mcpServer, modernTaskRuntime); } + // Extension-gated tools (#1739): start each gated tool disabled, then enable + // it on `initialized` iff the connected client declared its extension. + if (config.extensionGatedTools) { + wireExtensionGatedTools(mcpServer, state, config.extensionGatedTools); + } + return mcpServer; } +/** + * Gate the named tools on a client-declared extension. Each gated tool is + * disabled up front (so it is absent from `tools/list` by default), and the + * server's `oninitialized` hook enables it only when the connected client + * advertised the mapped extension id in `capabilities.extensions`. Because + * `initialized` arrives before the client's first `tools/list`, the enable + * lands in time for the list to reflect it. Chains any existing + * `oninitialized` so this composes with other wiring. + */ +function wireExtensionGatedTools( + mcpServer: McpServer, + state: ServerState, + gates: Record, +): void { + const entries = Object.entries(gates); + for (const [, toolName] of entries) { + state.registeredTools.get(toolName)?.disable(); + } + const previous = mcpServer.server.oninitialized; + mcpServer.server.oninitialized = () => { + previous?.(); + const declared = mcpServer.server.getClientCapabilities()?.extensions ?? {}; + for (const [extensionId, toolName] of entries) { + if (declared[extensionId] !== undefined) { + state.registeredTools.get(toolName)?.enable(); + } + } + }; +} + /** Structural view of the low-level `Server`'s request-handler registry. */ interface RawHandlerHost { _requestHandlers: Map< diff --git a/test-servers/src/load-config.ts b/test-servers/src/load-config.ts index 21e9da33..760363cb 100644 --- a/test-servers/src/load-config.ts +++ b/test-servers/src/load-config.ts @@ -66,6 +66,13 @@ export interface ConfigFile { resourceTemplates?: number; prompts?: number; }; + /** + * Gate a tool's `tools/list` visibility on a client-declared extension. Maps + * extension id → tool name; the tool appears only when the connected client + * advertised that extension (#1739 / #1633). Legacy stateful leg only. See + * {@link ServerConfig.extensionGatedTools}. + */ + extensionGatedTools?: Record; oauth?: ConfigFileOAuth; transport: { type: "stdio" | "streamable-http" | "sse"; diff --git a/test-servers/src/resolve-config.ts b/test-servers/src/resolve-config.ts index 6a7f0cb7..491bfed6 100644 --- a/test-servers/src/resolve-config.ts +++ b/test-servers/src/resolve-config.ts @@ -91,6 +91,7 @@ export function resolveConfig(config: ConfigFile): ServerConfig { tasks: config.tasks, tasksExtension: config.tasksExtension, maxPageSize: config.maxPageSize, + extensionGatedTools: config.extensionGatedTools, serverType: isHttp ? (transport.type as "sse" | "streamable-http") : undefined,