* 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
3 changed files with 585 additions and 0 deletions
|
|
@ -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",
|
||||
|
|
|
|||
Loading…
Reference in a new issue