Let a server hand a form `elicitation/create` to an MCP App and return the app's ordinary `ElicitResult`, with the native elicitation form as the fallback on every failure. No second extension, no custom method, and no custom result shape: the only new wire surface is a nested `elicitation` flag on the existing `io.modelcontextprotocol/ui` extension on each side, plus `_meta.ui.resourceUri` on the request. App rendering is selected only when all four gates hold — client `elicitation.form`, client MCP Apps MIME type, the nested `elicitation` setting on BOTH peers, and a valid absolute `ui://` URI on the request. Only the web client advertises the nested client-side setting, and only because it has a sandbox renderer: supplying `InspectorClientOptions.appElicitation` is what opts a client in, so CLI and TUI keep advertising the MIME type without ever claiming they can resolve an elicitation through an app. Routing lives in the one funnel both entry points already use (`enqueuePendingElicitation`), so the inbound handler and the MRTR driver cannot diverge. Ownership is request-scoped — a per-request id keys the renderer, iframe and bridge — so two concurrent elicitations can never resolve through each other's bridges. An explicit `decline`/`cancel` is a completed elicitation and goes back to the server; everything else (absent or malformed metadata, resource/sandbox/bridge failure, an app with no elicitation capability, a timeout, an invalid result or one that fails the requested schema) falls back. The Inspector speaks the ext-apps#733 / SEP-3118 wire protocol but cannot yet consume its helpers — the released `@modelcontextprotocol/ext-apps` (1.7.5) predates that PR. `core/mcp/appElicitation.ts` and `AppRenderer/requestAppElicitation.ts` mirror it exactly and are marked for deletion once a release containing it ships. One consequence needed its own seam: ext-apps 1.7.5 parses the view's `ui/initialize` through a schema that strips `elicitation`, so an app that correctly advertises it would look like one that did not, silently turning every negotiated elicitation into a fallback. `AppRenderer/appCapabilities.ts` records the raw frame instead, and prefers the bridge's own value once it carries the key. `AppRenderer` now takes an `AppRenderSource` union rather than a `Tool`, so the same renderer and bridge factory serve an App tool and an elicitation without either faking the other's shape. Adds the public fixture (`app_choose_option` + a self-contained `ui://demo/choose-option.html` app covering accept/decline/cancel), its two showcase configs, and `smoke:web:elicit`, which drives the negotiated path end to end in headless Chromium and then the same tool against a server that never advertised the capability, asserting the native form takes it. Closes #1854 Signed-off-by: cliffhall <cliff@futurescale.com>
154 lines
6.8 KiB
TypeScript
154 lines
6.8 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>;
|
|
/**
|
|
* True when this client can render an MCP App and resolve an
|
|
* `elicitation/create` request through its bridge (#1854). Adds the nested
|
|
* `elicitation` setting to the UI extension's advertisement, which is half of
|
|
* the negotiation a server checks before attaching an App to an elicitation.
|
|
*
|
|
* Deliberately an input rather than a registry default: the shared
|
|
* `InspectorClient` knowing the MCP Apps MIME type says nothing about whether
|
|
* the *client* has a sandbox renderer, so CLI and TUI must never advertise it.
|
|
* Ignored when the UI extension itself is not advertised — a nested setting on
|
|
* an extension we did not declare would be meaningless.
|
|
*/
|
|
appElicitation?: 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)
|
|
: {};
|
|
}
|
|
}
|
|
// Nested app-rendered-elicitation opt-in (#1854), layered onto the UI
|
|
// extension's own advertisement rather than added as a second extension.
|
|
// Guarded on the UI entry actually being present so turning the Apps
|
|
// extension off in Server Settings also turns this off.
|
|
const uiAdvertisement = map[UI_EXTENSION_KEY];
|
|
if (input.appElicitation && uiAdvertisement) {
|
|
map[UI_EXTENSION_KEY] = { ...uiAdvertisement, elicitation: {} };
|
|
}
|
|
if (input.enterpriseManaged) {
|
|
map[EMA_EXTENSION_KEY] = {};
|
|
}
|
|
return map;
|
|
}
|