Extensions: Connection Info both eras + io.modelcontextprotocol/ui advertisement (Phase 3 of #1633) (#1744)

* Extensions: Connection Info both eras + io.modelcontextprotocol/ui advertisement (Phase 3 of #1633)

Completes the Extensions surfacing and advertises the MCP Apps UI extension.

core/mcp/extensions.ts:
- Add UI_EXTENSION_KEY ("io.modelcontextprotocol/ui") and MCP_APP_MIME_TYPE
  ("text/html;profile=mcp-app") — mirrors of ext-apps' EXTENSION_ID /
  RESOURCE_MIME_TYPE (hardcoded: the constants live on the package's /server
  subpath or an extensionless re-export that doesn't resolve under NodeNext).
- Registry entries gain an optional `advertisement` value; the builder stamps
  it (default {}). Add the `ui` entry, default-advertised with
  `{ mimeTypes: ["text/html;profile=mcp-app"] }` — the Inspector always renders
  App tools. No ext-apps cast needed: the extensions value type is `object`.

ConnectionInfoContent:
- Source server extensions from the negotiated `capabilities.extensions`
  (getServerCapabilities, era-transparent) instead of the modern-only
  discoverResult, so a LEGACY server advertising extensions renders them too.
- Add a two-column "Server Extensions" / "Advertised Extensions" section (the
  Inspector's own advertised map), mirroring the server/client capability
  columns. Drop the now-redundant Extensions row from the modern Discovery
  section (Supported versions stays).

Tests: extensions.test.ts covers the ui entry + advertisement value and the
per-extension enable/disable; ConnectionInfoContent.test.tsx covers legacy
server extensions (no discovery), the advertised-extensions column, and the
em-dash empty states. npm run ci green (the one InspectorView localStorage
failure is a known flake, passes on re-run).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XNdjEPKLG637X8YmhDiEk5

* Extensions: address Phase 3 review (mime drift-guard, story, exact em-dashes)

- extensions-mimetype.test.ts (integration/node): assert MCP_APP_MIME_TYPE
  equals ext-apps' real RESOURCE_MIME_TYPE (imported) and that the client
  advertises it — a genuine drift guard, replacing the tautological literal
  check in the unit test (which now just asserts the type).
- ConnectionInfoContent Modern story: carry the tasks extension on
  initializeResult.capabilities too, so the Server Extensions row demonstrates
  it (the row now reads negotiated server capabilities, not discoverResult).
- Tighten the em-dash assertions from ">= N" to exact counts (3/3/2) now that
  the two extension sections em-dash by default.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XNdjEPKLG637X8YmhDiEk5

* Extensions: drift-guard UI_EXTENSION_KEY against ext-apps EXTENSION_ID (review)

Address the Phase 3 re-review nit: the extension id was the other half of the
same drift risk (a server keys its Apps lookup on it). Import ext-apps'
EXTENSION_ID from the /server subpath in the node integration test (fine there;
the browser build is what avoids /server) and assert UI_EXTENSION_KEY === it.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XNdjEPKLG637X8YmhDiEk5

* Extensions: clone the registry advertisement so builds never alias it (review)

Address the Phase 3 re-review's optional defensive nit: buildClientExtensions
returned the same object reference as the ADVERTISABLE_EXTENSIONS entry for the
ui advertisement, so an in-place mutation of a stamped value (e.g.
extensions[ui].mimeTypes) could corrupt the registry for later connections.
structuredClone the advertisement on stamp; add a test asserting a mutated
build doesn't leak into the next one. Nothing mutates it today — purely
defensive.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XNdjEPKLG637X8YmhDiEk5

* Extensions: add #1740 Connection Info proof screenshots

End-to-end smoke test in the web client against the legacy
advertised-extensions-http.json server: the Connection Info modal shows the new
era-transparent Server Extensions / Advertised Extensions section, with
Advertised Extensions listing io.modelcontextprotocol/tasks + io.modelcontextprotocol/ui
(the new ui advertisement), and only ui after toggling Tasks off and reconnecting.

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:
Cliff Hall 2026-07-23 10:11:36 -04:00 • committed by GitHub
commit e2c75da5ce
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
2 changed files with 275 additions and 24 deletions

View file

@ -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]: {} });
});

View file

@ -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] });
});
});