inspector/core/mcp/extensions.ts
Cliff Hall e2c75da5ce
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>
2026-07-23 10:11:36 -04:00

133 lines
5.6 KiB
TypeScript

import type { ClientCapabilities } from "@modelcontextprotocol/client";
import { TASKS_EXTENSION_KEY } from "./modernTaskSchemas.js";
/**
* Extension identifier for SEP-2350 enterprise-managed authorization. Advertised
* when the connection routes through the enterprise IdP (EMA), which is a
* property of the auth mode rather than a free debugging toggle — so it is a
* conditional built-in in {@link buildClientExtensions}, not a registry entry.
*/
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
* object is the standard "declared, no sub-options" advertisement.
*/
export type ExtensionAdvertisement = NonNullable<
ClientCapabilities["extensions"]
>[string];
/**
* A single Inspector-advertisable extension. The registry of these is the shared
* source of truth for both the capability builder here and the Server Settings
* toggle UI (#1739), so the two never drift on which extensions exist or what
* they are called.
*/
export interface AdvertisableExtension {
/** Extension identifier stamped into `capabilities.extensions`. */
key: string;
/** Human-readable label for the Server Settings toggle. */
label: string;
/**
* Whether the Inspector advertises this extension when the user has expressed
* 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;
}
/**
* Catalog of extensions the Inspector can advertise and the user can toggle.
* EMA is deliberately absent — it is driven by the auth mode (see
* {@link EMA_EXTENSION_KEY}), not a standalone toggle. The `io.modelcontextprotocol/ui`
* Apps extension is added here in Phase 3 (#1740).
*/
export const ADVERTISABLE_EXTENSIONS: readonly AdvertisableExtension[] = [
{
key: TASKS_EXTENSION_KEY,
label: "Tasks (io.modelcontextprotocol/tasks)",
// The modern Tasks extension (SEP-2663). Advertised by default so the SDK
// stamps it into every modern request envelope — the per-request
// declaration a server requires before it may return a `CreateTaskResult`
// (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 {
/** True when the connection routes through the enterprise IdP (EMA). */
enterpriseManaged: boolean;
/**
* Per-extension advertise overrides keyed by extension id, from
* {@link InspectorClientOptions.advertisedExtensions}. A key present here wins
* over the registry's `defaultAdvertised`; an absent key falls back to it.
*/
advertised?: Record<string, boolean>;
}
/**
* Assemble the `capabilities.extensions` map advertised at construction — the
* single source of truth that replaces the previously ad-hoc, per-extension
* spreads. Registry entries resolve to advertised/not via the user override with
* a registry-default fallback; EMA is layered on top as an auth-mode-driven
* built-in.
*
* With the Tasks entry defaulting to advertised, the map is non-empty for a
* default config, so `capabilities.extensions` is always attached.
*/
export function buildClientExtensions(
input: BuildClientExtensionsInput,
): Record<string, ExtensionAdvertisement> {
const map: Record<string, ExtensionAdvertisement> = {};
for (const ext of ADVERTISABLE_EXTENSIONS) {
const advertised = input.advertised?.[ext.key] ?? ext.defaultAdvertised;
if (advertised) {
// 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) {
map[EMA_EXTENSION_KEY] = {};
}
return map;
}