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:
parent
fe295396af
commit
e2c75da5ce
9 changed files with 275 additions and 24 deletions
|
|
@ -50,6 +50,12 @@ export const ModernEra: Story = {
|
||||||
capabilities: {
|
capabilities: {
|
||||||
tools: { listChanged: true },
|
tools: { listChanged: true },
|
||||||
resources: { subscribe: 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,
|
clientCapabilities: fullClientCaps,
|
||||||
|
|
|
||||||
|
|
@ -57,7 +57,9 @@ describe("ConnectionInfoContent", () => {
|
||||||
transport="stdio"
|
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", () => {
|
it("renders server and client capability sections", () => {
|
||||||
|
|
@ -151,12 +153,9 @@ describe("ConnectionInfoContent", () => {
|
||||||
expect(screen.getByText("Sessionless")).toBeInTheDocument();
|
expect(screen.getByText("Sessionless")).toBeInTheDocument();
|
||||||
expect(screen.getByText("Discovery")).toBeInTheDocument();
|
expect(screen.getByText("Discovery")).toBeInTheDocument();
|
||||||
expect(screen.getByText("2026-07-28, 2025-11-25")).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(
|
renderWithMantine(
|
||||||
<ConnectionInfoContent
|
<ConnectionInfoContent
|
||||||
initializeResult={fullResult}
|
initializeResult={fullResult}
|
||||||
|
|
@ -171,9 +170,74 @@ describe("ConnectionInfoContent", () => {
|
||||||
/>,
|
/>,
|
||||||
);
|
);
|
||||||
expect(screen.getByText("Supported versions")).toBeInTheDocument();
|
expect(screen.getByText("Supported versions")).toBeInTheDocument();
|
||||||
expect(screen.getByText("Extensions")).toBeInTheDocument();
|
// Extensions moved out of Discovery into their own era-transparent section.
|
||||||
// Both the empty-versions and no-extensions values render as an em dash.
|
expect(screen.queryByText("Discovery")).toBeInTheDocument();
|
||||||
expect(screen.getAllByText("—").length).toBeGreaterThanOrEqual(2);
|
// 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(
|
||||||
|
<ConnectionInfoContent
|
||||||
|
initializeResult={{
|
||||||
|
...fullResult,
|
||||||
|
capabilities: {
|
||||||
|
...fullResult.capabilities,
|
||||||
|
extensions: { "io.modelcontextprotocol/tasks": {} },
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
clientCapabilities={fullClientCaps}
|
||||||
|
transport="streamable-http"
|
||||||
|
protocolEra="legacy"
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
// 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(
|
||||||
|
<ConnectionInfoContent
|
||||||
|
initializeResult={fullResult}
|
||||||
|
clientCapabilities={{
|
||||||
|
...fullClientCaps,
|
||||||
|
extensions: {
|
||||||
|
"io.modelcontextprotocol/tasks": {},
|
||||||
|
"io.modelcontextprotocol/ui": { mimeTypes: ["text/html"] },
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
transport="streamable-http"
|
||||||
|
protocolEra="legacy"
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
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(
|
||||||
|
<ConnectionInfoContent
|
||||||
|
initializeResult={fullResult}
|
||||||
|
clientCapabilities={fullClientCaps}
|
||||||
|
transport="streamable-http"
|
||||||
|
protocolEra="legacy"
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
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", () => {
|
it("renders client registration kind when provided", () => {
|
||||||
|
|
|
||||||
|
|
@ -128,11 +128,14 @@ function formatSession(
|
||||||
return isModernEra(era) ? "Sessionless" : "Session-based";
|
return isModernEra(era) ? "Sessionless" : "Session-based";
|
||||||
}
|
}
|
||||||
|
|
||||||
// The `extensions` map the server advertised in its `server/discover`
|
// Render an `extensions` capability map (SEP-2133) as a comma-separated list of
|
||||||
// capabilities (SEP-2133). Render the extension identifiers, or an em dash when
|
// its extension identifiers, or an em dash when none are present. Works for
|
||||||
// none were advertised.
|
// either side's map: the server's negotiated `capabilities.extensions` (present
|
||||||
function formatExtensions(discoverResult: DiscoverResult): string {
|
// on both eras via `getServerCapabilities()`) or the Inspector's own advertised
|
||||||
const extensions = discoverResult.capabilities.extensions;
|
// `clientCapabilities.extensions`. (#1740)
|
||||||
|
function formatExtensions(
|
||||||
|
extensions: Record<string, unknown> | undefined,
|
||||||
|
): string {
|
||||||
const keys = extensions ? Object.keys(extensions) : [];
|
const keys = extensions ? Object.keys(extensions) : [];
|
||||||
return keys.length > 0 ? keys.join(", ") : "—";
|
return keys.length > 0 ? keys.join(", ") : "—";
|
||||||
}
|
}
|
||||||
|
|
@ -220,9 +223,6 @@ export function ConnectionInfoContent({
|
||||||
? discoverResult.supportedVersions.join(", ")
|
? discoverResult.supportedVersions.join(", ")
|
||||||
: "—"}
|
: "—"}
|
||||||
</ValueText>
|
</ValueText>
|
||||||
|
|
||||||
<Text size="sm">Extensions</Text>
|
|
||||||
<ValueText>{formatExtensions(discoverResult)}</ValueText>
|
|
||||||
</SimpleGrid>
|
</SimpleGrid>
|
||||||
</Stack>
|
</Stack>
|
||||||
)}
|
)}
|
||||||
|
|
@ -250,6 +250,23 @@ export function ConnectionInfoContent({
|
||||||
</Stack>
|
</Stack>
|
||||||
</SimpleGrid>
|
</SimpleGrid>
|
||||||
|
|
||||||
|
{/* 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) */}
|
||||||
|
<SimpleGrid cols={2}>
|
||||||
|
<Stack gap="xs">
|
||||||
|
<SectionHeading>Server Extensions</SectionHeading>
|
||||||
|
<ValueText>{formatExtensions(capabilities.extensions)}</ValueText>
|
||||||
|
</Stack>
|
||||||
|
<Stack gap="xs">
|
||||||
|
<SectionHeading>Advertised Extensions</SectionHeading>
|
||||||
|
<ValueText>
|
||||||
|
{formatExtensions(clientCapabilities.extensions)}
|
||||||
|
</ValueText>
|
||||||
|
</Stack>
|
||||||
|
</SimpleGrid>
|
||||||
|
|
||||||
{instructions && (
|
{instructions && (
|
||||||
<Stack gap="xs">
|
<Stack gap="xs">
|
||||||
<SectionHeading>Server Instructions</SectionHeading>
|
<SectionHeading>Server Instructions</SectionHeading>
|
||||||
|
|
|
||||||
|
|
@ -2,11 +2,17 @@ import { describe, it, expect } from "vitest";
|
||||||
import {
|
import {
|
||||||
ADVERTISABLE_EXTENSIONS,
|
ADVERTISABLE_EXTENSIONS,
|
||||||
EMA_EXTENSION_KEY,
|
EMA_EXTENSION_KEY,
|
||||||
|
UI_EXTENSION_KEY,
|
||||||
|
MCP_APP_MIME_TYPE,
|
||||||
buildClientExtensions,
|
buildClientExtensions,
|
||||||
} from "@inspector/core/mcp/extensions.js";
|
} from "@inspector/core/mcp/extensions.js";
|
||||||
import { TASKS_EXTENSION_KEY } from "@inspector/core/mcp/modernTaskSchemas.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", () => {
|
describe("ADVERTISABLE_EXTENSIONS registry", () => {
|
||||||
it("lists the Tasks extension, advertised by default", () => {
|
it("lists the Tasks extension, advertised by default", () => {
|
||||||
const tasks = ADVERTISABLE_EXTENSIONS.find(
|
const tasks = ADVERTISABLE_EXTENSIONS.find(
|
||||||
|
|
@ -17,6 +23,20 @@ describe("extensions (#1738)", () => {
|
||||||
expect(tasks?.label).toContain("Tasks");
|
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)", () => {
|
it("does not list EMA (it follows the auth mode, not a toggle)", () => {
|
||||||
expect(
|
expect(
|
||||||
ADVERTISABLE_EXTENSIONS.some((e) => e.key === EMA_EXTENSION_KEY),
|
ADVERTISABLE_EXTENSIONS.some((e) => e.key === EMA_EXTENSION_KEY),
|
||||||
|
|
@ -33,15 +53,35 @@ describe("extensions (#1738)", () => {
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("buildClientExtensions", () => {
|
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 });
|
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", () => {
|
it("adds EMA when enterpriseManaged, alongside the registry defaults", () => {
|
||||||
const map = buildClientExtensions({ enterpriseManaged: true });
|
const map = buildClientExtensions({ enterpriseManaged: true });
|
||||||
expect(map).toEqual({
|
expect(map).toEqual({
|
||||||
[TASKS_EXTENSION_KEY]: {},
|
[TASKS_EXTENSION_KEY]: {},
|
||||||
|
[UI_EXTENSION_KEY]: UI_ADVERTISEMENT,
|
||||||
[EMA_EXTENSION_KEY]: {},
|
[EMA_EXTENSION_KEY]: {},
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
@ -54,15 +94,23 @@ describe("extensions (#1738)", () => {
|
||||||
it("honors a user override that disables a default-on extension", () => {
|
it("honors a user override that disables a default-on extension", () => {
|
||||||
const map = buildClientExtensions({
|
const map = buildClientExtensions({
|
||||||
enterpriseManaged: false,
|
enterpriseManaged: false,
|
||||||
advertised: { [TASKS_EXTENSION_KEY]: false },
|
advertised: { [TASKS_EXTENSION_KEY]: false, [UI_EXTENSION_KEY]: false },
|
||||||
});
|
});
|
||||||
expect(map).toEqual({});
|
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", () => {
|
it("honors a user override that keeps a default-on extension enabled", () => {
|
||||||
const map = buildClientExtensions({
|
const map = buildClientExtensions({
|
||||||
enterpriseManaged: false,
|
enterpriseManaged: false,
|
||||||
advertised: { [TASKS_EXTENSION_KEY]: true },
|
advertised: { [TASKS_EXTENSION_KEY]: true, [UI_EXTENSION_KEY]: false },
|
||||||
});
|
});
|
||||||
expect(map).toEqual({ [TASKS_EXTENSION_KEY]: {} });
|
expect(map).toEqual({ [TASKS_EXTENSION_KEY]: {} });
|
||||||
});
|
});
|
||||||
|
|
@ -83,13 +131,16 @@ describe("extensions (#1738)", () => {
|
||||||
enterpriseManaged: false,
|
enterpriseManaged: false,
|
||||||
advertised: { "io.example/unknown": true },
|
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", () => {
|
it("layers EMA on even when all registry entries are disabled", () => {
|
||||||
const map = buildClientExtensions({
|
const map = buildClientExtensions({
|
||||||
enterpriseManaged: true,
|
enterpriseManaged: true,
|
||||||
advertised: { [TASKS_EXTENSION_KEY]: false },
|
advertised: { [TASKS_EXTENSION_KEY]: false, [UI_EXTENSION_KEY]: false },
|
||||||
});
|
});
|
||||||
expect(map).toEqual({ [EMA_EXTENSION_KEY]: {} });
|
expect(map).toEqual({ [EMA_EXTENSION_KEY]: {} });
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -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] });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -10,6 +10,25 @@ import { TASKS_EXTENSION_KEY } from "./modernTaskSchemas.js";
|
||||||
export const EMA_EXTENSION_KEY =
|
export const EMA_EXTENSION_KEY =
|
||||||
"io.modelcontextprotocol/enterprise-managed-authorization";
|
"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
|
* The value the client stamps for each advertised extension. The wire shape is
|
||||||
* `{ [extensionId]: object }` (per `ClientCapabilities.extensions`); an empty
|
* `{ [extensionId]: object }` (per `ClientCapabilities.extensions`); an empty
|
||||||
|
|
@ -35,6 +54,12 @@ export interface AdvertisableExtension {
|
||||||
* no explicit preference (the toggle's default position).
|
* no explicit preference (the toggle's default position).
|
||||||
*/
|
*/
|
||||||
defaultAdvertised: boolean;
|
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).
|
// (server-directed task creation). Harmless on legacy (extensions ignored).
|
||||||
defaultAdvertised: true,
|
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 {
|
export interface BuildClientExtensionsInput {
|
||||||
|
|
@ -83,7 +117,13 @@ export function buildClientExtensions(
|
||||||
for (const ext of ADVERTISABLE_EXTENSIONS) {
|
for (const ext of ADVERTISABLE_EXTENSIONS) {
|
||||||
const advertised = input.advertised?.[ext.key] ?? ext.defaultAdvertised;
|
const advertised = input.advertised?.[ext.key] ?? ext.defaultAdvertised;
|
||||||
if (advertised) {
|
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) {
|
if (input.enterpriseManaged) {
|
||||||
|
|
|
||||||
|
|
@ -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.
|
||||||
|
|
||||||
|

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

|
||||||
|
|
||||||
|
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
|
# Advertised-extensions toggle (#1739) — proof screenshots
|
||||||
|
|
||||||
End-to-end verification of the advertised-extensions debugging knob against a
|
End-to-end verification of the advertised-extensions debugging knob against a
|
||||||
|
|
|
||||||
BIN
pr-screenshots/advertised-ext-conninfo-legacy.png
Normal file
BIN
pr-screenshots/advertised-ext-conninfo-legacy.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 100 KiB |
BIN
pr-screenshots/advertised-ext-conninfo-tasksoff.png
Normal file
BIN
pr-screenshots/advertised-ext-conninfo-tasksoff.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 97 KiB |
Loading…
Reference in a new issue