* 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:
parent
2f80958d3f
commit
fe295396af
22 changed files with 585 additions and 0 deletions
|
|
@ -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.)
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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(),
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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."
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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: {
|
||||
|
|
|
|||
|
|
@ -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<TestServerHttp> {
|
||||
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<string, boolean>,
|
||||
): Promise<InspectorClient> {
|
||||
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<string[]> {
|
||||
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);
|
||||
});
|
||||
});
|
||||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -1354,6 +1354,12 @@ export function createRemoteApp(
|
|||
typeof (e as Record<string, unknown>).value === "string",
|
||||
);
|
||||
};
|
||||
const isBooleanRecord = (v: unknown): v is Record<string, boolean> => {
|
||||
if (v === null || typeof v !== "object" || Array.isArray(v)) return false;
|
||||
return Object.values(v as Record<string, unknown>).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.
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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<string, boolean>;
|
||||
/**
|
||||
* 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<string, boolean>;
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
||||

|
||||
|
||||
**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.
|
||||
|
||||

|
||||
|
||||
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`**.
|
||||
|
||||

|
||||
|
||||
Unchecking **Tasks** writes the per-server `advertisedExtensions` override. The
|
||||
change takes effect on the next connect (as the section note says).
|
||||
|
||||

|
||||
|
||||
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
|
||||
|
|
|
|||
BIN
pr-screenshots/advertised-ext-settings-tasks-off.png
Normal file
BIN
pr-screenshots/advertised-ext-settings-tasks-off.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 157 KiB |
BIN
pr-screenshots/advertised-ext-settings-tasks-on.png
Normal file
BIN
pr-screenshots/advertised-ext-settings-tasks-on.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 157 KiB |
BIN
pr-screenshots/advertised-ext-tools-tasks-off.png
Normal file
BIN
pr-screenshots/advertised-ext-tools-tasks-off.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 59 KiB |
BIN
pr-screenshots/advertised-ext-tools-tasks-on.png
Normal file
BIN
pr-screenshots/advertised-ext-tools-tasks-on.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 60 KiB |
14
test-servers/configs/advertised-extensions-http.json
Normal file
14
test-servers/configs/advertised-extensions-http.json
Normal file
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
@ -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<string, string>;
|
||||
/**
|
||||
* 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<string, string>,
|
||||
): 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<
|
||||
|
|
|
|||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Reference in a new issue