diff --git a/clients/web/src/components/groups/ConnectionInfoContent/ConnectionInfoContent.stories.tsx b/clients/web/src/components/groups/ConnectionInfoContent/ConnectionInfoContent.stories.tsx index 654bcb19..89bbb1af 100644 --- a/clients/web/src/components/groups/ConnectionInfoContent/ConnectionInfoContent.stories.tsx +++ b/clients/web/src/components/groups/ConnectionInfoContent/ConnectionInfoContent.stories.tsx @@ -50,6 +50,12 @@ export const ModernEra: Story = { capabilities: { tools: { listChanged: true }, resources: { subscribe: true }, + // The Server Extensions row reads the negotiated server capabilities + // (era-transparent), not discoverResult — carry it here too so the + // modern story demonstrates the extension. (#1740) + extensions: { + "io.modelcontextprotocol/tasks": {}, + }, }, }, clientCapabilities: fullClientCaps, diff --git a/clients/web/src/components/groups/ConnectionInfoContent/ConnectionInfoContent.test.tsx b/clients/web/src/components/groups/ConnectionInfoContent/ConnectionInfoContent.test.tsx index 74651d1f..9320fa21 100644 --- a/clients/web/src/components/groups/ConnectionInfoContent/ConnectionInfoContent.test.tsx +++ b/clients/web/src/components/groups/ConnectionInfoContent/ConnectionInfoContent.test.tsx @@ -57,7 +57,9 @@ describe("ConnectionInfoContent", () => { transport="stdio" />, ); - expect(screen.getByText("—")).toBeInTheDocument(); + // Exactly three em dashes: the missing server version, plus the two + // extension sections (the fixtures advertise none on either side). + expect(screen.getAllByText("—")).toHaveLength(3); }); it("renders server and client capability sections", () => { @@ -151,12 +153,9 @@ describe("ConnectionInfoContent", () => { expect(screen.getByText("Sessionless")).toBeInTheDocument(); expect(screen.getByText("Discovery")).toBeInTheDocument(); expect(screen.getByText("2026-07-28, 2025-11-25")).toBeInTheDocument(); - expect( - screen.getByText("io.modelcontextprotocol/tasks"), - ).toBeInTheDocument(); }); - it("renders em-dashes for empty supported versions and absent extensions", () => { + it("renders an em-dash for empty supported versions in Discovery", () => { renderWithMantine( { />, ); expect(screen.getByText("Supported versions")).toBeInTheDocument(); - expect(screen.getByText("Extensions")).toBeInTheDocument(); - // Both the empty-versions and no-extensions values render as an em dash. - expect(screen.getAllByText("—").length).toBeGreaterThanOrEqual(2); + // Extensions moved out of Discovery into their own era-transparent section. + expect(screen.queryByText("Discovery")).toBeInTheDocument(); + // Exactly three em dashes: empty supported versions, plus the two extension + // sections (the fixtures advertise none). + expect(screen.getAllByText("—")).toHaveLength(3); + }); + + it("shows server extensions on a LEGACY connection (no discovery), from server capabilities (#1740)", () => { + renderWithMantine( + , + ); + // No discoverResult (legacy) but the server's extension still renders, + // sourced from the negotiated server capabilities rather than discovery. + expect(screen.queryByText("Discovery")).not.toBeInTheDocument(); + expect(screen.getByText("Server Extensions")).toBeInTheDocument(); + expect( + screen.getByText("io.modelcontextprotocol/tasks"), + ).toBeInTheDocument(); + }); + + it("shows the Inspector's own advertised extensions (#1740)", () => { + renderWithMantine( + , + ); + expect(screen.getByText("Advertised Extensions")).toBeInTheDocument(); + expect( + screen.getByText( + "io.modelcontextprotocol/tasks, io.modelcontextprotocol/ui", + ), + ).toBeInTheDocument(); + }); + + it("renders em-dashes for the extensions sections when neither side advertises any (#1740)", () => { + renderWithMantine( + , + ); + expect(screen.getByText("Server Extensions")).toBeInTheDocument(); + expect(screen.getByText("Advertised Extensions")).toBeInTheDocument(); + // Exactly two em dashes: the two extension sections (the server version is + // present in the fixture, so it does not em-dash). + expect(screen.getAllByText("—")).toHaveLength(2); }); it("renders client registration kind when provided", () => { diff --git a/clients/web/src/components/groups/ConnectionInfoContent/ConnectionInfoContent.tsx b/clients/web/src/components/groups/ConnectionInfoContent/ConnectionInfoContent.tsx index a9e42466..66a8e0af 100644 --- a/clients/web/src/components/groups/ConnectionInfoContent/ConnectionInfoContent.tsx +++ b/clients/web/src/components/groups/ConnectionInfoContent/ConnectionInfoContent.tsx @@ -128,11 +128,14 @@ function formatSession( return isModernEra(era) ? "Sessionless" : "Session-based"; } -// The `extensions` map the server advertised in its `server/discover` -// capabilities (SEP-2133). Render the extension identifiers, or an em dash when -// none were advertised. -function formatExtensions(discoverResult: DiscoverResult): string { - const extensions = discoverResult.capabilities.extensions; +// Render an `extensions` capability map (SEP-2133) as a comma-separated list of +// its extension identifiers, or an em dash when none are present. Works for +// either side's map: the server's negotiated `capabilities.extensions` (present +// on both eras via `getServerCapabilities()`) or the Inspector's own advertised +// `clientCapabilities.extensions`. (#1740) +function formatExtensions( + extensions: Record | undefined, +): string { const keys = extensions ? Object.keys(extensions) : []; return keys.length > 0 ? keys.join(", ") : "—"; } @@ -220,9 +223,6 @@ export function ConnectionInfoContent({ ? discoverResult.supportedVersions.join(", ") : "—"} - - Extensions - {formatExtensions(discoverResult)} )} @@ -250,6 +250,23 @@ export function ConnectionInfoContent({ + {/* Extensions (SEP-2133) shown for both eras: the server's from its + negotiated capabilities, the Inspector's from what it advertised + (the Advertised Extensions setting). Mirrors the server/client + capability columns above. (#1740) */} + + + Server Extensions + {formatExtensions(capabilities.extensions)} + + + Advertised Extensions + + {formatExtensions(clientCapabilities.extensions)} + + + + {instructions && ( Server Instructions diff --git a/clients/web/src/test/core/mcp/extensions.test.ts b/clients/web/src/test/core/mcp/extensions.test.ts index 08716cf7..e027c9a3 100644 --- a/clients/web/src/test/core/mcp/extensions.test.ts +++ b/clients/web/src/test/core/mcp/extensions.test.ts @@ -2,11 +2,17 @@ import { describe, it, expect } from "vitest"; import { ADVERTISABLE_EXTENSIONS, EMA_EXTENSION_KEY, + UI_EXTENSION_KEY, + MCP_APP_MIME_TYPE, buildClientExtensions, } from "@inspector/core/mcp/extensions.js"; import { TASKS_EXTENSION_KEY } from "@inspector/core/mcp/modernTaskSchemas.js"; -describe("extensions (#1738)", () => { +// The `ui` extension carries a non-empty advertisement value; the others are +// declared with `{}`. Spelled out here so the map assertions stay readable. +const UI_ADVERTISEMENT = { mimeTypes: [MCP_APP_MIME_TYPE] }; + +describe("extensions (#1738, #1740)", () => { describe("ADVERTISABLE_EXTENSIONS registry", () => { it("lists the Tasks extension, advertised by default", () => { const tasks = ADVERTISABLE_EXTENSIONS.find( @@ -17,6 +23,20 @@ describe("extensions (#1738)", () => { expect(tasks?.label).toContain("Tasks"); }); + it("lists the UI extension advertised by default with the App MIME type (#1740)", () => { + const ui = ADVERTISABLE_EXTENSIONS.find( + (e) => e.key === UI_EXTENSION_KEY, + ); + expect(ui).toBeDefined(); + expect(ui?.defaultAdvertised).toBe(true); + expect(ui?.advertisement).toEqual(UI_ADVERTISEMENT); + // The exact value is drift-guarded against ext-apps' real RESOURCE_MIME_TYPE + // in src/test/integration/mcp/extensions-mimetype.test.ts (node env, where + // the ext-apps import resolves). A literal check here would only compare + // the constant to a copy of itself. + expect(typeof MCP_APP_MIME_TYPE).toBe("string"); + }); + it("does not list EMA (it follows the auth mode, not a toggle)", () => { expect( ADVERTISABLE_EXTENSIONS.some((e) => e.key === EMA_EXTENSION_KEY), @@ -33,15 +53,35 @@ describe("extensions (#1738)", () => { }); describe("buildClientExtensions", () => { - it("advertises registry defaults with no overrides (tasks on)", () => { + it("advertises registry defaults with no overrides (tasks + ui)", () => { const map = buildClientExtensions({ enterpriseManaged: false }); - expect(map).toEqual({ [TASKS_EXTENSION_KEY]: {} }); + expect(map).toEqual({ + [TASKS_EXTENSION_KEY]: {}, + [UI_EXTENSION_KEY]: UI_ADVERTISEMENT, + }); + }); + + it("stamps the UI extension's mimeTypes advertisement value (#1740)", () => { + const map = buildClientExtensions({ enterpriseManaged: false }); + expect(map[UI_EXTENSION_KEY]).toEqual(UI_ADVERTISEMENT); + }); + + it("does not alias the registry advertisement across builds (#1740)", () => { + // Mutating a stamped advertisement must not corrupt the registry for the + // next connection — the builder clones it. + const first = buildClientExtensions({ enterpriseManaged: false }); + (first[UI_EXTENSION_KEY] as { mimeTypes: string[] }).mimeTypes.push( + "text/evil", + ); + const second = buildClientExtensions({ enterpriseManaged: false }); + expect(second[UI_EXTENSION_KEY]).toEqual(UI_ADVERTISEMENT); }); it("adds EMA when enterpriseManaged, alongside the registry defaults", () => { const map = buildClientExtensions({ enterpriseManaged: true }); expect(map).toEqual({ [TASKS_EXTENSION_KEY]: {}, + [UI_EXTENSION_KEY]: UI_ADVERTISEMENT, [EMA_EXTENSION_KEY]: {}, }); }); @@ -54,15 +94,23 @@ describe("extensions (#1738)", () => { it("honors a user override that disables a default-on extension", () => { const map = buildClientExtensions({ enterpriseManaged: false, - advertised: { [TASKS_EXTENSION_KEY]: false }, + advertised: { [TASKS_EXTENSION_KEY]: false, [UI_EXTENSION_KEY]: false }, }); expect(map).toEqual({}); }); + it("can disable just the UI extension, keeping Tasks (#1740)", () => { + const map = buildClientExtensions({ + enterpriseManaged: false, + advertised: { [UI_EXTENSION_KEY]: false }, + }); + expect(map).toEqual({ [TASKS_EXTENSION_KEY]: {} }); + }); + it("honors a user override that keeps a default-on extension enabled", () => { const map = buildClientExtensions({ enterpriseManaged: false, - advertised: { [TASKS_EXTENSION_KEY]: true }, + advertised: { [TASKS_EXTENSION_KEY]: true, [UI_EXTENSION_KEY]: false }, }); expect(map).toEqual({ [TASKS_EXTENSION_KEY]: {} }); }); @@ -83,13 +131,16 @@ describe("extensions (#1738)", () => { enterpriseManaged: false, advertised: { "io.example/unknown": true }, }); - expect(map).toEqual({ [TASKS_EXTENSION_KEY]: {} }); + expect(map).toEqual({ + [TASKS_EXTENSION_KEY]: {}, + [UI_EXTENSION_KEY]: UI_ADVERTISEMENT, + }); }); it("layers EMA on even when all registry entries are disabled", () => { const map = buildClientExtensions({ enterpriseManaged: true, - advertised: { [TASKS_EXTENSION_KEY]: false }, + advertised: { [TASKS_EXTENSION_KEY]: false, [UI_EXTENSION_KEY]: false }, }); expect(map).toEqual({ [EMA_EXTENSION_KEY]: {} }); }); diff --git a/clients/web/src/test/integration/mcp/extensions-mimetype.test.ts b/clients/web/src/test/integration/mcp/extensions-mimetype.test.ts new file mode 100644 index 00000000..e185daa5 --- /dev/null +++ b/clients/web/src/test/integration/mcp/extensions-mimetype.test.ts @@ -0,0 +1,39 @@ +import { describe, it, expect } from "vitest"; +import { RESOURCE_MIME_TYPE } from "@modelcontextprotocol/ext-apps/app-bridge"; +// EXTENSION_ID lives on ext-apps' `/server` subpath, which the browser build +// avoids importing — but this node integration test can, so it's the one place +// the hardcoded key can be checked against the real constant. +import { EXTENSION_ID } from "@modelcontextprotocol/ext-apps/server"; +import { + MCP_APP_MIME_TYPE, + UI_EXTENSION_KEY, + buildClientExtensions, +} from "@inspector/core/mcp/extensions.js"; + +/** + * Drift guard for the MCP Apps UI advertisement (#1740). `MCP_APP_MIME_TYPE` is + * hardcoded in core (ext-apps' constant lives on a `/server` subpath / an + * extensionless re-export that doesn't resolve cleanly under NodeNext in the + * browser build). A conforming server checks the client's advertised `mimeTypes` + * before serving an App, so if the hardcoded string ever drifts from ext-apps' + * real `RESOURCE_MIME_TYPE`, Apps silently stop working against strict servers. + * + * This runs in the node integration project, where importing the real ext-apps + * value resolves — the one place the two can actually be compared. + */ +describe("MCP Apps UI extension constants (#1740)", () => { + it("MCP_APP_MIME_TYPE matches ext-apps' RESOURCE_MIME_TYPE exactly", () => { + expect(MCP_APP_MIME_TYPE).toBe(RESOURCE_MIME_TYPE); + }); + + it("UI_EXTENSION_KEY matches ext-apps' EXTENSION_ID exactly", () => { + // A server keys its Apps lookup on the extension id, so a drifted key + // silently disables Apps just like a drifted MIME type. + expect(UI_EXTENSION_KEY).toBe(EXTENSION_ID); + }); + + it("is the value the client actually advertises for the ui extension", () => { + const map = buildClientExtensions({ enterpriseManaged: false }); + expect(map[UI_EXTENSION_KEY]).toEqual({ mimeTypes: [RESOURCE_MIME_TYPE] }); + }); +}); diff --git a/core/mcp/extensions.ts b/core/mcp/extensions.ts index 6c67f257..db7e0e12 100644 --- a/core/mcp/extensions.ts +++ b/core/mcp/extensions.ts @@ -10,6 +10,25 @@ import { TASKS_EXTENSION_KEY } from "./modernTaskSchemas.js"; export const EMA_EXTENSION_KEY = "io.modelcontextprotocol/enterprise-managed-authorization"; +/** + * Extension identifier for the MCP Apps UI extension (SEP-ext-apps). Mirrors + * `EXTENSION_ID` from `@modelcontextprotocol/ext-apps`. Hardcoded rather than + * imported: that constant lives on the package's `/server` subpath, which would + * pull server-only code into the browser bundle. The Inspector always renders + * MCP Apps, so this is advertised by default (#1740). + */ +export const UI_EXTENSION_KEY = "io.modelcontextprotocol/ui"; + +/** + * The MCP Apps UI resource MIME type the Inspector renders. Mirrors + * `RESOURCE_MIME_TYPE` from `@modelcontextprotocol/ext-apps`; a server checks + * for it in the client's advertised `io.modelcontextprotocol/ui` `mimeTypes` to + * decide whether to serve an App. Hardcoded (stable spec string) because + * ext-apps re-exports it through an extensionless path that doesn't resolve + * cleanly under NodeNext — see the same note in `core/mcp/apps.ts`. + */ +export const MCP_APP_MIME_TYPE = "text/html;profile=mcp-app"; + /** * The value the client stamps for each advertised extension. The wire shape is * `{ [extensionId]: object }` (per `ClientCapabilities.extensions`); an empty @@ -35,6 +54,12 @@ export interface AdvertisableExtension { * no explicit preference (the toggle's default position). */ defaultAdvertised: boolean; + /** + * The object value stamped into `capabilities.extensions[key]` when + * advertised. Defaults to `{}` (declared, no sub-options); an extension that + * carries settings — e.g. the UI extension's `mimeTypes` — sets its own shape. + */ + advertisement?: ExtensionAdvertisement; } /** @@ -53,6 +78,15 @@ export const ADVERTISABLE_EXTENSIONS: readonly AdvertisableExtension[] = [ // (server-directed task creation). Harmless on legacy (extensions ignored). defaultAdvertised: true, }, + { + key: UI_EXTENSION_KEY, + label: "MCP Apps UI (io.modelcontextprotocol/ui)", + // The MCP Apps UI extension. The Inspector always renders App tools, so it + // advertises this by default with the App resource MIME type it supports — + // a conforming server checks the `mimeTypes` before serving a UI resource. + defaultAdvertised: true, + advertisement: { mimeTypes: [MCP_APP_MIME_TYPE] }, + }, ]; export interface BuildClientExtensionsInput { @@ -83,7 +117,13 @@ export function buildClientExtensions( for (const ext of ADVERTISABLE_EXTENSIONS) { const advertised = input.advertised?.[ext.key] ?? ext.defaultAdvertised; if (advertised) { - map[ext.key] = {}; + // Clone the registry advertisement so the returned map never aliases the + // shared `ADVERTISABLE_EXTENSIONS` entry — a later in-place mutation of a + // stamped value (e.g. `extensions[ui].mimeTypes`) can't corrupt the + // registry for subsequent connections. `{}` entries are fresh literals. + map[ext.key] = ext.advertisement + ? structuredClone(ext.advertisement) + : {}; } } if (input.enterpriseManaged) { diff --git a/pr-screenshots/README.md b/pr-screenshots/README.md index 518024e2..8fa09a67 100644 --- a/pr-screenshots/README.md +++ b/pr-screenshots/README.md @@ -1,3 +1,37 @@ +# Connection Info extensions + `io.modelcontextprotocol/ui` (#1740) — proof screenshots + +End-to-end verification of the Phase 3 Connection Info UI against the legacy +`advertised-extensions-http.json` test server (port 3220), driven in a real +browser through the web client's remote-proxy transport. + +![Connection Info on a legacy connection — Advertised Extensions shows tasks + ui](advertised-ext-conninfo-legacy.png) + +The Connection Info modal on a **legacy** connection (Era: **LEGACY**, no +Discovery section). The new **Server Extensions / Advertised Extensions** +two-column section renders — proving it is era-transparent (before #1740, +extensions only appeared in the modern-only Discovery section). **Advertised +Extensions** shows `io.modelcontextprotocol/tasks, io.modelcontextprotocol/ui`, +confirming the Inspector now advertises the MCP Apps `ui` extension. (Server +Extensions is `—` because this test server advertises none server-side.) + +![Connection Info after toggling Tasks off — Advertised Extensions shows only ui](advertised-ext-conninfo-tasksoff.png) + +After unchecking **Tasks** in Server Settings → Advertised Extensions (Phase 2) +and reconnecting, the **Advertised Extensions** column shows only +`io.modelcontextprotocol/ui` — the display reflects the per-server override live, +and the `ui` advertisement persists (it is a separate registry entry). + +> Note: the Connection Info modal is populated from the connection's +> `initializeResult`, which requires `serverInfo`. On the modern test server used +> here `serverInfo` wasn't populated, so the modal stays closed on that modern +> connection — a pre-existing connection-layer behavior, independent of this PR +> (which only changes content *inside* the modal). The era-transparent +> **Server Extensions** path is covered by unit tests +> (`ConnectionInfoContent.test.tsx` renders legacy server extensions with no +> `discoverResult`). + +--- + # Advertised-extensions toggle (#1739) — proof screenshots End-to-end verification of the advertised-extensions debugging knob against a diff --git a/pr-screenshots/advertised-ext-conninfo-legacy.png b/pr-screenshots/advertised-ext-conninfo-legacy.png new file mode 100644 index 00000000..c9957c7b Binary files /dev/null and b/pr-screenshots/advertised-ext-conninfo-legacy.png differ diff --git a/pr-screenshots/advertised-ext-conninfo-tasksoff.png b/pr-screenshots/advertised-ext-conninfo-tasksoff.png new file mode 100644 index 00000000..69f0fcef Binary files /dev/null and b/pr-screenshots/advertised-ext-conninfo-tasksoff.png differ