From 716916823bb6a663c6e9c1a2b1a9bfca85690eea Mon Sep 17 00:00:00 2001 From: cliffhall Date: Wed, 23 Sep 2026 23:36:29 -0400 Subject: [PATCH 1/3] fix(core): advertise MCP Apps UI extension only from a client that renders Apps (#2403) InspectorClient advertised io.modelcontextprotocol/ui by default in every client, so the CLI and TUI told servers they support MCP Apps although they cannot render one. The UI registry entry now requires an App renderer: its default applies only when the new `rendersApps` option is set (or an app-elicitation renderer is supplied). The web client sets it; the CLI and TUI no longer claim the extension. An explicit advertisedExtensions override still wins, and the CLI exposes it as `--advertise-apps` for probing servers that gate App tools on the advertisement. Co-Authored-By: Claude Opus 5.5 (1M context) Signed-off-by: cliffhall --- clients/cli/README.md | 7 ++ clients/cli/__tests__/app-info.test.ts | 56 +++++++++++- clients/cli/src/cli.ts | 14 +++ clients/cli/src/handlers/method-types.ts | 7 ++ .../web/src/hooks/useConnectionLifecycle.ts | 3 + .../web/src/test/core/mcp/extensions.test.ts | 88 +++++++++++++++++-- .../inspectorClient-app-elicitation.test.ts | 33 ++++++- .../mcp/extensions-mimetype.test.ts | 5 +- core/mcp/extensions.ts | 38 ++++++-- core/mcp/inspectorClient.ts | 6 ++ core/mcp/types.ts | 10 +++ 11 files changed, 247 insertions(+), 20 deletions(-) diff --git a/clients/cli/README.md b/clients/cli/README.md index 22ad3f7cde..bf9fe709bd 100644 --- a/clients/cli/README.md +++ b/clients/cli/README.md @@ -121,6 +121,7 @@ Options that specify the MCP server (catalog/config file, ad-hoc command/URL, en | `--tool-metadata ` | Tool-specific `_meta` entries for `tools/call`. Same JSON-parsed value handling as `--metadata`. | | `--connect-timeout ` | Connection timeout in ms. Defaults to `15000` for ad-hoc `--server-url`/target runs (so a black-holed host fails fast) and to the file-level `connectionTimeout` for `--catalog`/`--config` runs — `30000` when the file sets none. `0` disables the timeout. | | `--app-info` | Probe a tool's MCP App UI metadata without invoking it. With `--method tools/call --tool-name `: prints one JSON line (`hasApp`, `resourceUri`, `csp`, `permissions`, `domain`, …) and exits `0` if the tool has an app or `2` (`no_app`) if not. With `--method tools/list`: emits NDJSON — one app-info line per tool over a single connection. | +| `--advertise-apps` | Advertise the MCP Apps UI extension (`io.modelcontextprotocol/ui`) at `initialize`. Off by default, because the CLI cannot render an App and a server decides whether to return one from that advertisement. Set it when a server only exposes its App tools to a client that claims App support — typically alongside `--app-info`. | | `--strict` | With `--method tools/list`: report tool-schema portability problems in full (path, issue, suggested fix) on stderr, and exit `6` if any is error-severity. Without it, a one-line count is printed instead. See [Schema portability](#schema-portability---strict). | | `--verify` | With `--method skills/list` or `--method skills/get`: run the SEP-2640 conformance, digest and frontmatter checks over the skills returned, emit one JSON report per skill on stdout, and exit `7` if any fails. See [Skill verification](#skill-verification---verify). | | `--format ` | Output format. `text` (default) pretty-prints the result. `json` emits a single JSON object on stdout (`{ "result": … }`, plus `{ "appInfo": … }` as a sibling key for App tools) with no banners, so the whole output pipes cleanly into `jq`. | @@ -160,6 +161,12 @@ mcp-inspector --cli --method tools/call --tool-name my_tool --app-info mcp-inspector --cli --method tools/list --app-info | jq -c 'select(.hasApp)' ``` +The CLI does **not** advertise the MCP Apps UI extension by default, since it cannot render an App. A server that registers its App tools only for a client that advertises Apps support will therefore show no app to a bare probe; add `--advertise-apps` to claim that support for the probe: + +```bash +mcp-inspector --cli --method tools/list --app-info --advertise-apps +``` + Exit semantics: a tool that **has** an app exits `0`; one with **no** app exits `2` (`no_app`); a **missing** tool exits `5` (`tool_not_found`) — distinct so a typo isn't mistaken for "no app". A probe failure (an unreadable UI resource, or a malformed `_meta.ui.resourceUri`) is tolerated and reported in a `resourceError` field rather than aborting — so in `tools/list --app-info` one bad tool never kills the rest of the listing. `--format json` wraps any method's output in a single stdout envelope with no banners, so App tools and plain tools both pipe cleanly into `jq`: diff --git a/clients/cli/__tests__/app-info.test.ts b/clients/cli/__tests__/app-info.test.ts index 88a40c4688..90769501db 100644 --- a/clients/cli/__tests__/app-info.test.ts +++ b/clients/cli/__tests__/app-info.test.ts @@ -1,6 +1,11 @@ import { describe, it, expect } from "vitest"; import { runCli } from "./helpers/cli-runner.js"; -import { getTestMcpServerCommand } from "@modelcontextprotocol/inspector-test-server"; +import { + createEchoTool, + createTestServerHttp, + createTestServerInfo, + getTestMcpServerCommand, +} from "@modelcontextprotocol/inspector-test-server"; /** * The default stdio test server advertises exactly one MCP App tool @@ -153,3 +158,52 @@ describe("--app-info", () => { expect(result.output).not.toContain("isError"); }); }); + +/** + * The CLI cannot render an MCP App, so it must not claim the UI extension by + * default (#2403) — a server decides whether to expose its App tools from that + * advertisement. `--advertise-apps` is the explicit opt-in. Observed from the + * server side: a tool gated on `io.modelcontextprotocol/ui` is listed only when + * the client declared it at `initialize`. A fresh server per case, because the + * gate only ever enables the tool. + */ +describe("--advertise-apps (#2403)", () => { + const UI_EXTENSION = "io.modelcontextprotocol/ui"; + + async function listToolNames(extraArgs: string[]): Promise { + const server = createTestServerHttp({ + serverInfo: createTestServerInfo(), + tools: [createEchoTool()], + extensionGatedTools: { [UI_EXTENSION]: "echo" }, + }); + try { + await server.start(); + const result = await runCli([ + server.url, + "--cli", + "--method", + "tools/list", + "--transport", + "http", + "--format", + "json", + ...extraArgs, + ]); + expect(result.exitCode).toBe(0); + const parsed = JSON.parse(result.stdout) as { + result: { tools: { name: string }[] }; + }; + return parsed.result.tools.map((t) => t.name); + } finally { + await server.stop(); + } + } + + it("does not advertise the UI extension by default", async () => { + expect(await listToolNames([])).not.toContain("echo"); + }); + + it("advertises the UI extension with --advertise-apps", async () => { + expect(await listToolNames(["--advertise-apps"])).toContain("echo"); + }); +}); diff --git a/clients/cli/src/cli.ts b/clients/cli/src/cli.ts index 43a5c7da14..17c9b1f233 100644 --- a/clients/cli/src/cli.ts +++ b/clients/cli/src/cli.ts @@ -17,6 +17,7 @@ import { writeFormattedResult } from "./handlers/format-output.js"; import { clearStoredAuthForRelogin } from "./clear-stored-auth-for-relogin.js"; import { InspectorClient } from "@inspector/core/mcp/index.js"; import { cleanRoots } from "@inspector/core/mcp/serverList.js"; +import { UI_EXTENSION_KEY } from "@inspector/core/mcp/extensions.js"; import { createProxyFetch, createTransportNode, @@ -206,6 +207,13 @@ async function callMethod( ...(serverSettings?.protocolEra && { versionNegotiation: eraToVersionNegotiation(serverSettings.protocolEra), }), + // The CLI cannot render an MCP App, so it does not advertise the UI + // extension by default (#2403). `--advertise-apps` claims it explicitly, + // for a server that only exposes its App tools to a client that does — + // which is what an `--app-info` probe against such a server needs. + ...(args.advertiseApps && { + advertisedExtensions: { [UI_EXTENSION_KEY]: true }, + }), ...clientAuthOptions, }); @@ -756,6 +764,10 @@ async function parseArgs(argv?: string[]): Promise { "--app-info", "Probe the tool's MCP App UI metadata (resourceUri, csp, permissions, domain) and emit it as one JSON line; exit 2 when the tool has no app. Use with --method tools/call --tool-name (the tool itself is not invoked) or --method tools/list (one NDJSON line per tool).", ) + .option( + "--advertise-apps", + "Advertise the MCP Apps UI extension (io.modelcontextprotocol/ui) at initialize. Off by default because the CLI cannot render an App; set it when a server only exposes its App tools to a client that claims App support, e.g. for an --app-info probe.", + ) .option( "--strict", "Report tool-schema portability problems in full (path, issue, suggested fix) on stderr, and exit 6 if any is error-severity. Use with --method tools/list. Without it, a one-line count is printed instead.", @@ -873,6 +885,7 @@ async function parseArgs(argv?: string[]): Promise { serverUrl?: string; header?: Record; appInfo?: boolean; + advertiseApps?: boolean; strict?: boolean; verify?: boolean; cursor?: string; @@ -1190,6 +1203,7 @@ async function parseArgs(argv?: string[]): Promise { metadata: options.metadata, toolMeta: options.toolMetadata, appInfo: options.appInfo === true, + advertiseApps: options.advertiseApps === true, strict: options.strict === true, verify: options.verify === true, cursor: options.cursor, diff --git a/clients/cli/src/handlers/method-types.ts b/clients/cli/src/handlers/method-types.ts index 958552dcea..b886f40847 100644 --- a/clients/cli/src/handlers/method-types.ts +++ b/clients/cli/src/handlers/method-types.ts @@ -24,6 +24,13 @@ export type MethodArgs = { toolMeta?: RequestMetadata; metadata?: RequestMetadata; appInfo?: boolean; + /** + * `--advertise-apps`: advertise the MCP Apps UI extension + * (`io.modelcontextprotocol/ui`) at `initialize`. The CLI cannot render an + * App, so it does not claim the extension by default; this opts in for a + * server that only exposes its App tools to a client that does (#2403). + */ + advertiseApps?: boolean; /** * `--strict`: report tool-schema portability findings in full and exit * non-zero when any is error-severity (#1005). `tools/list` only. diff --git a/clients/web/src/hooks/useConnectionLifecycle.ts b/clients/web/src/hooks/useConnectionLifecycle.ts index 42447bc6cb..94a7e0563d 100644 --- a/clients/web/src/hooks/useConnectionLifecycle.ts +++ b/clients/web/src/hooks/useConnectionLifecycle.ts @@ -473,6 +473,9 @@ export function useConnectionLifecycle({ // Sampling / elicitation are on by default; keep the parameterized // options off until the UI grows the surface to render them. elicit: { form: true, url: true }, + // The web client renders MCP Apps, so it claims the UI extension by + // default; the CLI and TUI share InspectorClient but cannot (#2403). + rendersApps: true, // Web only, and only when the sandbox renderer is actually available: // supplying this advertises the nested MCP Apps `elicitation` // capability, and a client that cannot host an app must not claim it diff --git a/clients/web/src/test/core/mcp/extensions.test.ts b/clients/web/src/test/core/mcp/extensions.test.ts index 5f25108d8c..f1c7ec1007 100644 --- a/clients/web/src/test/core/mcp/extensions.test.ts +++ b/clients/web/src/test/core/mcp/extensions.test.ts @@ -21,7 +21,7 @@ const ALL_REGISTRY_OFF = { [SKILLS_EXTENSION_KEY]: false, }; -describe("extensions (#1738, #1740, #2373)", () => { +describe("extensions (#1738, #1740, #2373, #2403)", () => { describe("ADVERTISABLE_EXTENSIONS registry", () => { it("lists the Tasks extension, advertised by default", () => { const tasks = ADVERTISABLE_EXTENSIONS.find( @@ -38,6 +38,8 @@ describe("extensions (#1738, #1740, #2373)", () => { ); expect(ui).toBeDefined(); expect(ui?.defaultAdvertised).toBe(true); + // ...but only for a client that can render Apps (#2403). + expect(ui?.requiresAppRenderer).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 @@ -76,7 +78,10 @@ describe("extensions (#1738, #1740, #2373)", () => { describe("buildClientExtensions", () => { it("advertises registry defaults with no overrides (tasks + ui + skills)", () => { - const map = buildClientExtensions({ enterpriseManaged: false }); + const map = buildClientExtensions({ + enterpriseManaged: false, + rendersApps: true, + }); expect(map).toEqual({ [TASKS_EXTENSION_KEY]: {}, [UI_EXTENSION_KEY]: UI_ADVERTISEMENT, @@ -85,23 +90,35 @@ describe("extensions (#1738, #1740, #2373)", () => { }); it("stamps the UI extension's mimeTypes advertisement value (#1740)", () => { - const map = buildClientExtensions({ enterpriseManaged: false }); + const map = buildClientExtensions({ + enterpriseManaged: false, + rendersApps: true, + }); 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 }); + const first = buildClientExtensions({ + enterpriseManaged: false, + rendersApps: true, + }); (first[UI_EXTENSION_KEY] as { mimeTypes: string[] }).mimeTypes.push( "text/evil", ); - const second = buildClientExtensions({ enterpriseManaged: false }); + const second = buildClientExtensions({ + enterpriseManaged: false, + rendersApps: true, + }); expect(second[UI_EXTENSION_KEY]).toEqual(UI_ADVERTISEMENT); }); it("adds EMA when enterpriseManaged, alongside the registry defaults", () => { - const map = buildClientExtensions({ enterpriseManaged: true }); + const map = buildClientExtensions({ + enterpriseManaged: true, + rendersApps: true, + }); expect(map).toEqual({ [TASKS_EXTENSION_KEY]: {}, [UI_EXTENSION_KEY]: UI_ADVERTISEMENT, @@ -111,13 +128,17 @@ describe("extensions (#1738, #1740, #2373)", () => { }); it("omits EMA when not enterpriseManaged", () => { - const map = buildClientExtensions({ enterpriseManaged: false }); + const map = buildClientExtensions({ + enterpriseManaged: false, + rendersApps: true, + }); expect(map).not.toHaveProperty(EMA_EXTENSION_KEY); }); it("honors a user override that disables a default-on extension", () => { const map = buildClientExtensions({ enterpriseManaged: false, + rendersApps: true, advertised: ALL_REGISTRY_OFF, }); expect(map).toEqual({}); @@ -126,6 +147,7 @@ describe("extensions (#1738, #1740, #2373)", () => { it("can disable just the UI extension, keeping the others (#1740)", () => { const map = buildClientExtensions({ enterpriseManaged: false, + rendersApps: true, advertised: { [UI_EXTENSION_KEY]: false }, }); expect(map).toEqual({ @@ -139,6 +161,7 @@ describe("extensions (#1738, #1740, #2373)", () => { // server refuses `skills/*` to a client that did not declare it. const map = buildClientExtensions({ enterpriseManaged: false, + rendersApps: true, advertised: { [SKILLS_EXTENSION_KEY]: false }, }); expect(map).toEqual({ @@ -150,6 +173,7 @@ describe("extensions (#1738, #1740, #2373)", () => { it("honors a user override that keeps a default-on extension enabled", () => { const map = buildClientExtensions({ enterpriseManaged: false, + rendersApps: true, advertised: { ...ALL_REGISTRY_OFF, [TASKS_EXTENSION_KEY]: true }, }); expect(map).toEqual({ [TASKS_EXTENSION_KEY]: {} }); @@ -161,6 +185,7 @@ describe("extensions (#1738, #1740, #2373)", () => { // against someone mistakenly adding EMA to ADVERTISABLE_EXTENSIONS. const map = buildClientExtensions({ enterpriseManaged: false, + rendersApps: true, advertised: { [EMA_EXTENSION_KEY]: true }, }); expect(map).not.toHaveProperty(EMA_EXTENSION_KEY); @@ -169,6 +194,7 @@ describe("extensions (#1738, #1740, #2373)", () => { it("ignores override keys that are not in the registry", () => { const map = buildClientExtensions({ enterpriseManaged: false, + rendersApps: true, advertised: { "io.example/unknown": true }, }); expect(map).toEqual({ @@ -181,6 +207,7 @@ describe("extensions (#1738, #1740, #2373)", () => { it("layers EMA on even when all registry entries are disabled", () => { const map = buildClientExtensions({ enterpriseManaged: true, + rendersApps: true, advertised: ALL_REGISTRY_OFF, }); expect(map).toEqual({ [EMA_EXTENSION_KEY]: {} }); @@ -189,13 +216,17 @@ describe("extensions (#1738, #1740, #2373)", () => { describe("app-rendered elicitation opt-in (#1854)", () => { it("does not advertise the nested elicitation setting by default", () => { - const map = buildClientExtensions({ enterpriseManaged: false }); + const map = buildClientExtensions({ + enterpriseManaged: false, + rendersApps: true, + }); expect(map[UI_EXTENSION_KEY]).toEqual(UI_ADVERTISEMENT); }); it("nests `elicitation` inside the UI extension when opted in", () => { const map = buildClientExtensions({ enterpriseManaged: false, + rendersApps: true, appElicitation: true, }); expect(map[UI_EXTENSION_KEY]).toEqual({ @@ -214,6 +245,7 @@ describe("extensions (#1738, #1740, #2373)", () => { it("advertises nothing when the UI extension itself is turned off", () => { const map = buildClientExtensions({ enterpriseManaged: false, + rendersApps: true, appElicitation: true, advertised: { [UI_EXTENSION_KEY]: false }, }); @@ -221,11 +253,49 @@ describe("extensions (#1738, #1740, #2373)", () => { }); it("does not mutate the shared registry advertisement", () => { - buildClientExtensions({ enterpriseManaged: false, appElicitation: true }); + buildClientExtensions({ + enterpriseManaged: false, + rendersApps: true, + appElicitation: true, + }); const ui = ADVERTISABLE_EXTENSIONS.find( (e) => e.key === UI_EXTENSION_KEY, ); expect(ui?.advertisement).toEqual(UI_ADVERTISEMENT); }); }); + + describe("clients that cannot render Apps (#2403)", () => { + // The CLI and TUI share InspectorClient but have no App renderer, so they + // must not tell a server they support Apps unless explicitly asked to. + it("omits the UI extension by default when rendersApps is absent", () => { + const map = buildClientExtensions({ enterpriseManaged: false }); + expect(map).toEqual({ + [TASKS_EXTENSION_KEY]: {}, + [SKILLS_EXTENSION_KEY]: {}, + }); + }); + + it("omits the UI extension by default when rendersApps is false", () => { + const map = buildClientExtensions({ + enterpriseManaged: false, + rendersApps: false, + }); + expect(map).not.toHaveProperty(UI_EXTENSION_KEY); + }); + + it("advertises the UI extension on an explicit override", () => { + const map = buildClientExtensions({ + enterpriseManaged: false, + advertised: { [UI_EXTENSION_KEY]: true }, + }); + expect(map[UI_EXTENSION_KEY]).toEqual(UI_ADVERTISEMENT); + }); + + it("does not gate extensions that need no renderer", () => { + const map = buildClientExtensions({ enterpriseManaged: false }); + expect(map).toHaveProperty(TASKS_EXTENSION_KEY); + expect(map).toHaveProperty(SKILLS_EXTENSION_KEY); + }); + }); }); diff --git a/clients/web/src/test/core/mcp/inspectorClient-app-elicitation.test.ts b/clients/web/src/test/core/mcp/inspectorClient-app-elicitation.test.ts index b8b2a6c8b5..5cec52e525 100644 --- a/clients/web/src/test/core/mcp/inspectorClient-app-elicitation.test.ts +++ b/clients/web/src/test/core/mcp/inspectorClient-app-elicitation.test.ts @@ -162,6 +162,8 @@ async function connectClient(options: { transport: ElicitTransport; appElicitation?: AppElicitationRenderer; elicit?: boolean | { form?: boolean; url?: boolean }; + rendersApps?: boolean; + advertisedExtensions?: Record; }) { const client = new InspectorClient( { type: "stdio", command: "noop", args: [] }, @@ -169,6 +171,12 @@ async function connectClient(options: { environment: { transport: () => ({ transport: options.transport }) }, elicit: options.elicit ?? { form: true }, ...(options.appElicitation && { appElicitation: options.appElicitation }), + ...(options.rendersApps !== undefined && { + rendersApps: options.rendersApps, + }), + ...(options.advertisedExtensions && { + advertisedExtensions: options.advertisedExtensions, + }), }, ); await client.connect(); @@ -199,10 +207,29 @@ describe("app-rendered elicitation routing (#1854)", () => { await client.disconnect(); }); - it("does not advertise it on a client with no renderer (CLI/TUI)", async () => { - // The MIME type alone is what CLI and TUI advertise, and it must stay - // that way: they know the type but cannot host an app. + it("advertises no UI extension at all on a client that cannot render Apps (CLI/TUI, #2403)", async () => { + // A server decides whether to return an App from this advertisement, so + // a client with no renderer must not claim the extension by default. const client = await connectClient({ transport: new ElicitTransport() }); + expect(advertisedUi(client)).toBeUndefined(); + await client.disconnect(); + }); + + it("advertises the MIME type alone on an App-rendering client with no elicitation renderer (#2403)", async () => { + const client = await connectClient({ + transport: new ElicitTransport(), + rendersApps: true, + }); + expect(advertisedUi(client)).toEqual({ mimeTypes: [MCP_APP_MIME_TYPE] }); + await client.disconnect(); + }); + + it("advertises the UI extension on a non-rendering client only when explicitly opted in (#2403)", async () => { + // The CLI's `--advertise-apps` takes this path. + const client = await connectClient({ + transport: new ElicitTransport(), + advertisedExtensions: { [UI_EXTENSION_KEY]: true }, + }); expect(advertisedUi(client)).toEqual({ mimeTypes: [MCP_APP_MIME_TYPE] }); await client.disconnect(); }); diff --git a/clients/web/src/test/integration/mcp/extensions-mimetype.test.ts b/clients/web/src/test/integration/mcp/extensions-mimetype.test.ts index ef486658d6..f5d08d83b0 100644 --- a/clients/web/src/test/integration/mcp/extensions-mimetype.test.ts +++ b/clients/web/src/test/integration/mcp/extensions-mimetype.test.ts @@ -37,7 +37,10 @@ describe("MCP Apps UI extension constants (#1740)", () => { }); it("is the value the client actually advertises for the ui extension", () => { - const map = buildClientExtensions({ enterpriseManaged: false }); + const map = buildClientExtensions({ + enterpriseManaged: false, + rendersApps: true, + }); expect(map[UI_EXTENSION_KEY]).toEqual({ mimeTypes: [RESOURCE_MIME_TYPE] }); }); }); diff --git a/core/mcp/extensions.ts b/core/mcp/extensions.ts index eab7ae53d7..21021ef160 100644 --- a/core/mcp/extensions.ts +++ b/core/mcp/extensions.ts @@ -19,8 +19,8 @@ export const EMA_EXTENSION_KEY = * package's `/server` subpath, which would pull server-only code (and the * optional `@modelcontextprotocol/server` peer) into the browser bundle. The * node integration test `extensions-mimetype.test.ts` pins the two together. - * The Inspector always renders MCP Apps, so this is advertised by default - * (#1740). + * Advertised by default only by a client that can render MCP Apps (the web + * client) — see {@link BuildClientExtensionsInput.rendersApps} (#1740, #2403). */ export const UI_EXTENSION_KEY = "io.modelcontextprotocol/ui"; @@ -65,6 +65,15 @@ export interface AdvertisableExtension { * carries settings — e.g. the UI extension's `mimeTypes` — sets its own shape. */ advertisement?: ExtensionAdvertisement; + /** + * True when advertising this extension claims the client can render MCP + * Apps. Its `defaultAdvertised` then applies only to a client that sets + * {@link BuildClientExtensionsInput.rendersApps}; any other client leaves it + * off unless the user explicitly overrides it on. A server uses the + * advertisement to decide whether to return an App, so a client that cannot + * render one must not claim it by default (#2403). + */ + requiresAppRenderer?: boolean; } /** @@ -86,10 +95,13 @@ export const ADVERTISABLE_EXTENSIONS: readonly AdvertisableExtension[] = [ { 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. + // The MCP Apps UI extension, advertised with the App resource MIME type the + // Inspector renders — a conforming server checks the `mimeTypes` before + // serving a UI resource. Default-on only where Apps can actually be + // rendered (the web client); the CLI and TUI cannot, so they leave it off + // unless explicitly overridden (#2403). defaultAdvertised: true, + requiresAppRenderer: true, advertisement: { mimeTypes: [MCP_APP_MIME_TYPE] }, }, { @@ -115,6 +127,14 @@ export interface BuildClientExtensionsInput { * over the registry's `defaultAdvertised`; an absent key falls back to it. */ advertised?: Record; + /** + * True when this client can render MCP Apps. Gates the registry default of + * every entry marked `requiresAppRenderer` (today the UI extension): without + * it such an entry is advertised only on an explicit override. Defaults to + * false, so the CLI and TUI — which share `InspectorClient` but have no + * renderer — do not misrepresent themselves to servers (#2403). + */ + rendersApps?: boolean; /** * True when this client can render an MCP App and resolve an * `elicitation/create` request through its bridge (#1854). Adds the nested @@ -137,6 +157,9 @@ export interface BuildClientExtensionsInput { * a registry-default fallback; EMA is layered on top as an auth-mode-driven * built-in. * + * An entry marked `requiresAppRenderer` defaults to advertised only when + * `rendersApps` is set (#2403). + * * With the Tasks entry defaulting to advertised, the map is non-empty for a * default config, so `capabilities.extensions` is always attached. */ @@ -145,7 +168,10 @@ export function buildClientExtensions( ): Record { const map: Record = {}; for (const ext of ADVERTISABLE_EXTENSIONS) { - const advertised = input.advertised?.[ext.key] ?? ext.defaultAdvertised; + const defaultAdvertised = + ext.defaultAdvertised && + (!ext.requiresAppRenderer || input.rendersApps === true); + const advertised = input.advertised?.[ext.key] ?? 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 diff --git a/core/mcp/inspectorClient.ts b/core/mcp/inspectorClient.ts index 04293238a7..a0325c40f2 100644 --- a/core/mcp/inspectorClient.ts +++ b/core/mcp/inspectorClient.ts @@ -1012,6 +1012,12 @@ export class InspectorClient extends InspectorClientEventTarget { const advertisedExtensions = buildClientExtensions({ enterpriseManaged: options.oauth?.enterpriseManaged ?? false, advertised: this.advertisedExtensions, + // Only a client that can render Apps claims the UI extension by default + // (#2403). An app-elicitation renderer is itself a claim to host an App, + // so supplying one implies it. + rendersApps: + options.rendersApps === true || + this.appElicitationRenderer !== undefined, // Read off the built `capabilities.elicitation.form` rather than // re-deriving from `options.elicit`: the nested MCP Apps `elicitation` // setting must never be advertised without the core form capability it diff --git a/core/mcp/types.ts b/core/mcp/types.ts index d954b8a212..2e89d2f7b0 100644 --- a/core/mcp/types.ts +++ b/core/mcp/types.ts @@ -1262,6 +1262,16 @@ export interface InspectorClientOptions { */ advertisedExtensions?: Record; + /** + * Whether this client can render MCP Apps. When true, the MCP Apps UI + * extension (`io.modelcontextprotocol/ui`) is advertised by default; when + * false or absent it is advertised only if `advertisedExtensions` turns it + * on explicitly. Servers decide whether to return an App from this + * advertisement, so only a client with a renderer (today: the web client) + * should set it. Supplying `appElicitation` implies it (#2403). + */ + rendersApps?: boolean; + /** * Renders an app-rendered form elicitation (#1854) and resolves with the * app's standard `ElicitResult`. From d60a7d18249770b331a93e90ca6863c0dd3f7f31 Mon Sep 17 00:00:00 2001 From: cliffhall Date: Thu, 24 Sep 2026 00:15:34 -0400 Subject: [PATCH 2/3] fix(web): claim MCP Apps rendering only when the sandbox is available (#2471 review) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Web set rendersApps unconditionally, so with no sandbox URL — where the Apps screen reports that MCP Apps cannot run — it still advertised the UI extension. Gate it on the confirmed sandbox URL, as appElicitation already is. A Server Settings override can still force the extension on. Co-Authored-By: Claude Opus 5.5 (1M context) Signed-off-by: cliffhall --- clients/web/src/hooks/useConnectionLifecycle.test.tsx | 9 +++++++++ clients/web/src/hooks/useConnectionLifecycle.ts | 9 ++++++--- 2 files changed, 15 insertions(+), 3 deletions(-) diff --git a/clients/web/src/hooks/useConnectionLifecycle.test.tsx b/clients/web/src/hooks/useConnectionLifecycle.test.tsx index 1acf79ce26..46fbe31dca 100644 --- a/clients/web/src/hooks/useConnectionLifecycle.test.tsx +++ b/clients/web/src/hooks/useConnectionLifecycle.test.tsx @@ -1,6 +1,7 @@ import { describe, it, expect, beforeEach, afterEach, vi } from "vitest"; import { useLayoutEffect, useRef } from "react"; import { InspectorClient } from "@inspector/core/mcp/index.js"; +import { UI_EXTENSION_KEY } from "@inspector/core/mcp/extensions.js"; import type { ConnectionStatus, InspectorServerSettings, @@ -418,6 +419,10 @@ describe("useConnectionLifecycle", () => { // The sandbox is present, so the nested MCP Apps elicitation session is // opened and the capability may be advertised (#1854). expect(h.spies.newAppElicitationSession).toHaveBeenCalled(); + // ...and the client claims it can render MCP Apps (#2403). + expect( + client.getClientCapabilities().extensions?.[UI_EXTENSION_KEY], + ).toBeDefined(); }); it("falls back to the entry's own settings and the default log size", () => { @@ -433,6 +438,10 @@ describe("useConnectionLifecycle", () => { ); // No sandbox URL — the client must not claim app-rendered elicitation. expect(h.spies.newAppElicitationSession).not.toHaveBeenCalled(); + // Nor MCP Apps rendering at all — it has no renderer (#2403). + expect( + client.getClientCapabilities().extensions?.[UI_EXTENSION_KEY], + ).toBeUndefined(); }); it("waits for the config gate, then reads the sandbox URL as of then", async () => { diff --git a/clients/web/src/hooks/useConnectionLifecycle.ts b/clients/web/src/hooks/useConnectionLifecycle.ts index 94a7e0563d..b20831933b 100644 --- a/clients/web/src/hooks/useConnectionLifecycle.ts +++ b/clients/web/src/hooks/useConnectionLifecycle.ts @@ -473,9 +473,12 @@ export function useConnectionLifecycle({ // Sampling / elicitation are on by default; keep the parameterized // options off until the UI grows the surface to render them. elicit: { form: true, url: true }, - // The web client renders MCP Apps, so it claims the UI extension by - // default; the CLI and TUI share InspectorClient but cannot (#2403). - rendersApps: true, + // The web client renders MCP Apps only when the sandbox renderer is + // available, so only then does it claim the UI extension by default; + // the CLI and TUI share InspectorClient but never can (#2403). As + // below, `sandboxUrl` here is confirmed, not "not known yet". A Server + // Settings override can still force the extension on. + rendersApps: sandboxUrlRef.current !== undefined, // Web only, and only when the sandbox renderer is actually available: // supplying this advertises the nested MCP Apps `elicitation` // capability, and a client that cannot host an app must not claim it From 2ebadf4fae489df3775bf289a456863d112e42d5 Mon Sep 17 00:00:00 2001 From: cliffhall Date: Thu, 24 Sep 2026 00:49:56 -0400 Subject: [PATCH 3/3] fix: renderer-aware Server Settings toggle; reject inert --advertise-apps (#2471 review) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Server Settings form resolved the UI extension's checkbox from the raw registry default, so with no sandbox it showed "advertised" although the client no longer declares it — and the modal's reconverge-to-default logic made a true override impossible to save. Both now use the shared isAdvertisedByDefault(ext, rendersApps), fed from App's sandbox URL. The CLI rejected other connection-only flags ahead of its short-circuit paths but accepted --advertise-apps there and ignored it; it is now rejected on --list-stored-auth, --print-handoff and servers/list|show. Co-Authored-By: Claude Opus 5.5 (1M context) Signed-off-by: cliffhall --- clients/cli/__tests__/app-info.test.ts | 15 +++++++ clients/cli/src/cli.ts | 15 +++++++ clients/web/src/App.tsx | 3 ++ .../ServerSettingsForm.test.tsx | 43 +++++++++++++++++++ .../ServerSettingsForm/ServerSettingsForm.tsx | 34 ++++++++++----- .../ServerSettingsModal.test.tsx | 31 +++++++++++++ .../ServerSettingsModal.tsx | 19 +++++++- .../web/src/test/core/mcp/extensions.test.ts | 10 +++++ core/mcp/extensions.ts | 21 +++++++-- 9 files changed, 174 insertions(+), 17 deletions(-) diff --git a/clients/cli/__tests__/app-info.test.ts b/clients/cli/__tests__/app-info.test.ts index 90769501db..4d6d336d41 100644 --- a/clients/cli/__tests__/app-info.test.ts +++ b/clients/cli/__tests__/app-info.test.ts @@ -1,5 +1,6 @@ import { describe, it, expect } from "vitest"; import { runCli } from "./helpers/cli-runner.js"; +import { runCli as runCliInProcess } from "../src/cli.js"; import { createEchoTool, createTestServerHttp, @@ -206,4 +207,18 @@ describe("--advertise-apps (#2403)", () => { it("advertises the UI extension with --advertise-apps", async () => { expect(await listToolNames(["--advertise-apps"])).toContain("echo"); }); + + it.each([ + ["servers/list", ["--method", "servers/list"]], + ["servers/show", ["--method", "servers/show", "--server", "x"]], + ["--list-stored-auth", ["--method", "servers/list", "--list-stored-auth"]], + ["--print-handoff", ["--method", "servers/list", "--print-handoff"]], + ])( + "is rejected on the %s short-circuit path, which never connects", + async (_label, extra) => { + await expect( + runCliInProcess(["node", "cli", "--cli", "--advertise-apps", ...extra]), + ).rejects.toThrow("--advertise-apps requires a command that connects"); + }, + ); }); diff --git a/clients/cli/src/cli.ts b/clients/cli/src/cli.ts index 17c9b1f233..8edcfaeff0 100644 --- a/clients/cli/src/cli.ts +++ b/clients/cli/src/cli.ts @@ -973,6 +973,21 @@ async function parseArgs(argv?: string[]): Promise { } } + // `--advertise-apps` is checked here for the same reason: it shapes the + // `initialize` handshake, and the short-circuit paths below never open an + // MCP connection, so accepting it there would silently ignore it. + if ( + options.advertiseApps && + (options.listStoredAuth || + options.printHandoff || + options.method === "servers/list" || + options.method === "servers/show") + ) { + throw new Error( + "--advertise-apps requires a command that connects to a server; it has no effect with --list-stored-auth, --print-handoff, or --method servers/list / servers/show.", + ); + } + // State-path precedence (getStateFilePath): MCP_INSPECTOR_OAUTH_STATE_PATH → // /oauth.json → ~/.mcp-inspector/storage/oauth.json — the // same file the web backend writes, so tokens are shared across surfaces. diff --git a/clients/web/src/App.tsx b/clients/web/src/App.tsx index 6295977575..601ae9c9e3 100644 --- a/clients/web/src/App.tsx +++ b/clients/web/src/App.tsx @@ -2000,6 +2000,9 @@ function App() { ? protocolEra : undefined } + // Apps render only with a sandbox, and the client claims the UI + // extension by default only then (#2403); the toggle must agree. + rendersApps={sandboxUrl !== undefined} onClose={onSettingsModalClose} onSettingsChange={onSettingsChange} onClearStoredOAuth={ diff --git a/clients/web/src/components/groups/ServerSettingsForm/ServerSettingsForm.test.tsx b/clients/web/src/components/groups/ServerSettingsForm/ServerSettingsForm.test.tsx index d57fa36570..fe5c9330cf 100644 --- a/clients/web/src/components/groups/ServerSettingsForm/ServerSettingsForm.test.tsx +++ b/clients/web/src/components/groups/ServerSettingsForm/ServerSettingsForm.test.tsx @@ -552,6 +552,49 @@ describe("ServerSettingsForm", () => { expect(tasks).toBeChecked(); }); + it.each([ + [true, true], + [false, false], + ])( + "with rendersApps=%s, shows the MCP Apps UI toggle checked=%s by default (#2403)", + (rendersApps, checked) => { + renderWithMantine( + , + ); + const ui = screen.getByRole("checkbox", { + name: /MCP Apps UI \(io\.modelcontextprotocol\/ui\)/, + }); + // With no App renderer the client does not declare the extension, so + // the toggle must not claim it does. + if (checked) expect(ui).toBeChecked(); + else expect(ui).not.toBeChecked(); + }, + ); + + it("shows an explicit UI override as checked even without a renderer (#2403)", () => { + renderWithMantine( + , + ); + expect( + screen.getByRole("checkbox", { + name: /MCP Apps UI \(io\.modelcontextprotocol\/ui\)/, + }), + ).toBeChecked(); + }); + it("reflects an advertisedExtensions override that disables Tasks", () => { renderWithMantine( void; + /** + * Whether this web session can render MCP Apps — true when the backend + * supplied a sandbox URL. Decides the default position of any extension that + * requires an App renderer (the MCP Apps UI extension), so the toggle shows + * what the client will actually declare (#2403). Defaults to true. + */ + rendersApps?: boolean; onMaxFetchRequestsChange: (value: number) => void; /** * Set one skills verification budget limit. Only ever called with a positive @@ -483,6 +498,7 @@ export function ServerSettingsForm({ onPaginatedListsChange, onSuppressNotificationStreamChange, onAdvertisedExtensionChange, + rendersApps = true, onMaxFetchRequestsChange, onSkillCatalogLimitChange, onProtocolEraChange, @@ -764,11 +780,7 @@ export function ServerSettingsForm({ onAdvertisedExtensionChange(ext.key, e.currentTarget.checked) } diff --git a/clients/web/src/components/groups/ServerSettingsModal/ServerSettingsModal.test.tsx b/clients/web/src/components/groups/ServerSettingsModal/ServerSettingsModal.test.tsx index f317e59226..3a3ea25f43 100644 --- a/clients/web/src/components/groups/ServerSettingsModal/ServerSettingsModal.test.tsx +++ b/clients/web/src/components/groups/ServerSettingsModal/ServerSettingsModal.test.tsx @@ -336,6 +336,37 @@ describe("ServerSettingsModal", () => { ); }); + it("persists a true UI override when checked without an App renderer (#2403)", async () => { + // With no sandbox the UI extension defaults OFF, so checking it is a real + // override — it must be written, not dropped as "back to the default". + const user = userEvent.setup(); + const onSettingsChange = vi.fn(); + renderWithMantine( + , + ); + await user.click( + screen.getByRole("button", { name: "Advertised Extensions" }), + ); + await user.click( + screen.getByRole("checkbox", { + name: /MCP Apps UI \(io\.modelcontextprotocol\/ui\)/, + }), + ); + expect(onSettingsChange).toHaveBeenCalledWith( + expect.objectContaining({ + advertisedExtensions: { "io.modelcontextprotocol/ui": true }, + }), + ); + }); + it("hides the modern log-level control when this server negotiated legacy under 'auto' (#1629)", () => { renderWithMantine( void; onSettingsChange: (settings: InspectorServerSettings) => void; onClearStoredOAuth?: () => void; @@ -93,6 +104,7 @@ export function ServerSettingsModal({ serverType, isStdio, negotiatedEra, + rendersApps = true, onClose, onSettingsChange, onClearStoredOAuth, @@ -221,7 +233,9 @@ export function ServerSettingsModal({ // default, so the on-disk map (and its byte-stable round-trip) stays minimal // — matching the omit-when-default policy used for the other settings. Only // a value that actually differs from the default is persisted. - if (ext && checked === ext.defaultAdvertised) { + // The default is renderer-aware (#2403): with no App renderer the UI + // extension defaults off, so checking it is a real `true` override. + if (ext && checked === isAdvertisedByDefault(ext, rendersApps)) { delete next[key]; } else { next[key] = checked; @@ -318,6 +332,7 @@ export function ServerSettingsModal({ handleSuppressNotificationStreamChange } onAdvertisedExtensionChange={handleAdvertisedExtensionChange} + rendersApps={rendersApps} onMaxFetchRequestsChange={handleMaxFetchRequestsChange} onSkillCatalogLimitChange={handleSkillCatalogLimitChange} onProtocolEraChange={handleProtocolEraChange} diff --git a/clients/web/src/test/core/mcp/extensions.test.ts b/clients/web/src/test/core/mcp/extensions.test.ts index f1c7ec1007..1695aaca3a 100644 --- a/clients/web/src/test/core/mcp/extensions.test.ts +++ b/clients/web/src/test/core/mcp/extensions.test.ts @@ -5,6 +5,7 @@ import { UI_EXTENSION_KEY, MCP_APP_MIME_TYPE, buildClientExtensions, + isAdvertisedByDefault, } from "@inspector/core/mcp/extensions.js"; import { TASKS_EXTENSION_KEY } from "@inspector/core/mcp/modernTaskSchemas.js"; import { SKILLS_EXTENSION_KEY } from "@inspector/core/mcp/skillsSchemas.js"; @@ -292,6 +293,15 @@ describe("extensions (#1738, #1740, #2373, #2403)", () => { expect(map[UI_EXTENSION_KEY]).toEqual(UI_ADVERTISEMENT); }); + it("isAdvertisedByDefault gates only renderer-requiring entries", () => { + for (const ext of ADVERTISABLE_EXTENSIONS) { + expect(isAdvertisedByDefault(ext, true)).toBe(ext.defaultAdvertised); + expect(isAdvertisedByDefault(ext, false)).toBe( + ext.defaultAdvertised && !ext.requiresAppRenderer, + ); + } + }); + it("does not gate extensions that need no renderer", () => { const map = buildClientExtensions({ enterpriseManaged: false }); expect(map).toHaveProperty(TASKS_EXTENSION_KEY); diff --git a/core/mcp/extensions.ts b/core/mcp/extensions.ts index 21021ef160..24ba650fae 100644 --- a/core/mcp/extensions.ts +++ b/core/mcp/extensions.ts @@ -118,6 +118,20 @@ export const ADVERTISABLE_EXTENSIONS: readonly AdvertisableExtension[] = [ }, ]; +/** + * Whether `ext` is advertised when the user has set no override for it: its + * registry `defaultAdvertised`, except that an entry marked + * `requiresAppRenderer` defaults off on a client that cannot render Apps + * (#2403). Shared by {@link buildClientExtensions} and the Server Settings + * form, so the toggle shows exactly what the client will declare. + */ +export function isAdvertisedByDefault( + ext: AdvertisableExtension, + rendersApps: boolean, +): boolean { + return ext.defaultAdvertised && (!ext.requiresAppRenderer || rendersApps); +} + export interface BuildClientExtensionsInput { /** True when the connection routes through the enterprise IdP (EMA). */ enterpriseManaged: boolean; @@ -168,10 +182,9 @@ export function buildClientExtensions( ): Record { const map: Record = {}; for (const ext of ADVERTISABLE_EXTENSIONS) { - const defaultAdvertised = - ext.defaultAdvertised && - (!ext.requiresAppRenderer || input.rendersApps === true); - const advertised = input.advertised?.[ext.key] ?? defaultAdvertised; + const advertised = + input.advertised?.[ext.key] ?? + isAdvertisedByDefault(ext, input.rendersApps === true); if (advertised) { // Clone the registry advertisement so the returned map never aliases the // shared `ADVERTISABLE_EXTENSIONS` entry — a later in-place mutation of a