diff --git a/README.md b/README.md index 5d73f12fa..74c2b2e36 100644 --- a/README.md +++ b/README.md @@ -59,6 +59,12 @@ Requires Node `>=22.19.0`. npm install # root install; postinstall cascades into every client ``` +This reference branch pins `@modelcontextprotocol/ext-apps` to commit +`89ab2bc` from +[ext-apps#733](https://github.com/modelcontextprotocol/ext-apps/pull/733). +No private package or registry credentials are required. Replace the GitHub +dependency with the first published ext-apps release that includes that PR. + - **Fresh clone:** run `npm install` at the repo root. - **After a pull that changes a client's dependencies:** re-run `npm install` at the root to re-sync every client. @@ -131,6 +137,7 @@ Each config below is a ready-made server for exercising one feature by hand. Loa | Config | Demonstrates | Issue | | ----------------------------------------- | -------------------------------------------------- | ---------------------------------------------------------------------- | | `modern-mrtr-http.json` | A single MRTR round-trip | — | +| `modern-app-elicitation-http.json` | Generic app-rendered MRTR confirmation | [#1854](https://github.com/modelcontextprotocol/inspector/issues/1854) | | `mrtr-showcase-http.json` | Every MRTR preset in one server | — | | `modern-network-http.json` | Network tab: `Mcp-*` headers + error taxonomy | [#1628](https://github.com/modelcontextprotocol/inspector/issues/1628) | | `xmcpheader-modern-http.json` | Tools tab: `x-mcp-header` mirroring and exclusions | [#1632](https://github.com/modelcontextprotocol/inspector/issues/1632) | @@ -159,6 +166,68 @@ The Inspector drives MRTR manually (`inputRequired: { autoFulfill: false }`), so > The legacy `collect_elicitation` preset calls `server.elicitInput`, which errors on the 2026-07-28 leg — server→client requests aren't allowed there. MRTR is the modern replacement. +#### Generic app-rendered elicitation experiment + +`modern-app-elicitation-http.json` is the smallest reusable, server-agnostic +SEP-3118 test path. It combines: + +- `mrtr_app_confirm` — a modern tool whose first call returns `input_required` + with a complete native boolean schema, opaque `requestState`, and + `_meta.ui.resourceUri`. +- `mcp_app_elicitation_demo` — a self-contained + `text/html;profile=mcp-app` resource that advertises top-level + `appCapabilities.elicitation` and renders **Accept**, **Decline**, and + **Cancel** actions. + +Start the fixture and Inspector in separate terminals: + +```bash +cd clients/web +npm run test-servers:build +node ../../test-servers/build/server-composable.js \ + --config ../../test-servers/configs/modern-app-elicitation-http.json + +# Repository root, after npm run build +MCP_INSPECTOR_API_TOKEN=local-token MCP_AUTO_OPEN_ENABLED=false npm run web +``` + +In Inspector, add `http://localhost:3102/mcp`, set **Protocol Era** to +**Modern (2026-07-28, sessionless)**, then disconnect and reconnect. Run +`mrtr_confirm` from **Tools** with: + +```json +{ "action": "publish demo" } +``` + +The App Elicitation modal should render the generic confirmation App. Choosing +an action sends a standard elicitation result; the Inspector retries the same +`tools/call` with unchanged arguments, a new JSON-RPC id, the echoed +`requestState`, and `inputResponses.confirm`. **Accept** includes +`content: { "confirm": true }`; **Decline** and **Cancel** send only their +action. The final tool result prints the response received by the server. + +For protocol verification, the client capability envelope must include core +`elicitation.form` and +`extensions["io.modelcontextprotocol/ui"].elicitation`. The server must +advertise the matching nested MCP Apps capability. No second extension is used. +A legacy connection starts with `initialize`; modern requests carry the same +capabilities in their request-scoped envelope. + +Interop is defined by the wire shapes, not by which package produced them: + +| Request / bridge shape | Expected behavior | +| --- | --- | +| Both peers advertise the nested MCP Apps `elicitation` capability, the client also advertises core `elicitation.form`, the request has a valid `_meta.ui.resourceUri`, and the app/host advertise `elicitation` during `ui/initialize` | Render the App and bridge the standard elicitation result. | +| Either peer omits the nested MCP Apps elicitation capability | Use the complete native form. | +| The request omits a valid absolute `ui://` resource URI | Use the complete native form. | +| App-elicitation bridge missing either first-class App or host `elicitation` capability | Initialization or negotiation fails and the unchanged native form remains the fallback. | + +Resource selection, request forwarding, bridge negotiation, result validation, +and fallback use only the standard elicitation request/result, the +`2026-07-28` MRTR retry fields, and the existing MCP Apps extension. The Apps +bridge still negotiates its own `2026-01-26` protocol version; that value is +independent of the core MCP protocol revision. + #### Network tab — standardized headers and error taxonomy `modern-network-http.json` covers SEP-2243 / SEP-2575. It serves a `get_weather` tool whose `city` argument carries an `x-mcp-header: "City"` annotation, so a modern client mirrors it to `Mcp-Param-City`. diff --git a/clients/web/README.md b/clients/web/README.md index c835aaf27..a3bedc92d 100644 --- a/clients/web/README.md +++ b/clients/web/README.md @@ -87,6 +87,29 @@ The Apps screen exposes a small, stable set of `data-testid` / `data-*` attribut The renderer lifecycle itself is `AppRendererStatus` (`loading` | `ready` | `error`) reported via `AppRenderer`'s `onAppStatusChange`; the screen maps it to `data-app-status`. Resource-read failures (malformed/404 UI resource) are surfaced as a toast via the bridge factory's `onResourceError`; because the app never reaches `ready` in that case, a driver times out on `data-app-status` and reads the toast. +## MCP Apps-rendered elicitations + +Form elicitations carrying `_meta.ui.resourceUri: "ui://..."` are routed through +the same Apps sandbox and bridge stack as the Apps screen. The +`WebAppElicitationHost` queue binds each iframe session to the originating SDK +client, complete `elicitation/create` request, and resource URI; the app receives +that unchanged request over the bridge. The resulting standard elicitation +response is schema-validated before the Inspector retries MRTR or answers a +legacy request. Resource loading, initialization, capability negotiation, +bridge, or result-validation failures close the app session and surface the +unchanged complete request in the native elicitation modal. + +This reference branch uses the first-class APIs from +[ext-apps#733](https://github.com/modelcontextprotocol/ext-apps/pull/733). +Inspector advertises the nested `io.modelcontextprotocol/ui.elicitation` +capability only when the web sandbox host is available, verifies the matching +server capability, and calls `AppBridge.requestElicitation` on the bridge bound +to the originating request. CLI and TUI clients do not advertise app-rendered +elicitation support. + +The active modal exposes `data-app-elicitation-status="loading|ready|error"` on +its content stack for browser automation. + ## Deep-link auto-connect A driver (launcher, CLI `--print-handoff`, CI review harness) can reach a **connected** inspector with a single navigate by encoding the target in the URL query string. Parsing + security gating live in `src/utils/deepLink.ts` (`parseDeepLink`), and a returned `DeepLink` is proof the link passed validation. diff --git a/clients/web/package-lock.json b/clients/web/package-lock.json index 810270fe5..251ac5438 100644 --- a/clients/web/package-lock.json +++ b/clients/web/package-lock.json @@ -18,7 +18,7 @@ "@mantine/notifications": "^8.3.17", "@modelcontextprotocol/client": "2.0.0-beta.5", "@modelcontextprotocol/core": "2.0.0-beta.5", - "@modelcontextprotocol/ext-apps": "^1.7.4", + "@modelcontextprotocol/ext-apps": "github:krubenok/ext-apps#89ab2bc", "@modelcontextprotocol/server": "2.0.0-beta.5", "@modelcontextprotocol/server-legacy": "2.0.0-beta.5", "@napi-rs/keyring": "^1.3.0", @@ -1471,9 +1471,8 @@ } }, "node_modules/@modelcontextprotocol/ext-apps": { - "version": "1.7.4", - "resolved": "https://registry.npmjs.org/@modelcontextprotocol/ext-apps/-/ext-apps-1.7.4.tgz", - "integrity": "sha512-QQqysE549cf/Y0VabBmAACXhj92EhB3t8yVct2BHbkWiPTFA1S91EqTVjYXXcZEefXU0pmHcdObhsNMcomJIOQ==", + "version": "1.7.5", + "resolved": "git+ssh://git@github.com/krubenok/ext-apps.git#89ab2bcbf066ca21f1bd38cb115949f857950b7a", "license": "MIT", "workspaces": [ "examples/*" diff --git a/clients/web/package.json b/clients/web/package.json index 81a8d4af3..20577c81c 100644 --- a/clients/web/package.json +++ b/clients/web/package.json @@ -43,7 +43,7 @@ "@mantine/notifications": "^8.3.17", "@modelcontextprotocol/client": "2.0.0-beta.5", "@modelcontextprotocol/core": "2.0.0-beta.5", - "@modelcontextprotocol/ext-apps": "^1.7.4", + "@modelcontextprotocol/ext-apps": "github:krubenok/ext-apps#89ab2bc", "@modelcontextprotocol/server": "2.0.0-beta.5", "@modelcontextprotocol/server-legacy": "2.0.0-beta.5", "@napi-rs/keyring": "^1.3.0", diff --git a/clients/web/src/App.tsx b/clients/web/src/App.tsx index 6d162c7bd..1320f9ba5 100644 --- a/clients/web/src/App.tsx +++ b/clients/web/src/App.tsx @@ -160,6 +160,11 @@ import { PendingClientRequestModal, type PendingClientRequestContent, } from "./components/groups/PendingClientRequestModal/PendingClientRequestModal"; +import { AppElicitationModal } from "./components/groups/AppElicitationModal/AppElicitationModal"; +import { + WebAppElicitationHost, + type PendingAppElicitation, +} from "./lib/appElicitationHost"; import { buildExportFilename, downloadJsonFile } from "./lib/downloadFile"; import { INSPECTOR_SERVERS_TAB } from "./utils/inspectorTabs"; import { enrichProtocolEntries } from "./utils/correlateTransportErrors"; @@ -720,13 +725,35 @@ function App() { // next switch happens (or when the component unmounts). const [inspectorClient, setInspectorClient] = useState(null); + const appRendererRef = useRef(null); + const appsScreenBridgeActiveRef = useRef(false); + const [appElicitationHost] = useState( + () => + new WebAppElicitationHost( + undefined, + () => !appsScreenBridgeActiveRef.current, + ), + ); + const [pendingAppElicitations, setPendingAppElicitations] = useState< + readonly PendingAppElicitation[] + >([]); + + useEffect(() => { + const update = () => + setPendingAppElicitations([...appElicitationHost.getPending()]); + update(); + appElicitationHost.addEventListener("change", update); + return () => { + appElicitationHost.removeEventListener("change", update); + appElicitationHost.clear("Inspector closed"); + }; + }, [appElicitationHost]); // MCP Apps runtime wiring. `sandboxUrl` is the inspector's sandbox-proxy page // (the trusted outer iframe); `appRendererRef` lets the app handlers push tool // input/result into the running app and tear it down. The bridge factory wraps // the active client's underlying SDK client so the running view can call the // server, and reads the tool's UI resource into the sandbox on handshake. - const appRendererRef = useRef(null); const configBaseUrl = typeof window !== "undefined" ? window.location.origin : "http://localhost"; // One `GET /api/config` fetch recovers every static payload field the app @@ -763,31 +790,54 @@ function App() { }); }, [configBaseUrl]); - const sandboxBridgeFactory = useMemo( - () => - createAppBridgeFactory({ - getClient: () => inspectorClient?.getAppRendererClient() ?? null, - readResource: async (uri) => { - if (!inspectorClient) throw new Error("No MCP client connected."); - const invocation = await inspectorClient.readResource(uri); - return invocation.result; - }, - // The bridge's sandboxready handler reads + posts the UI resource - // inside a detached async block; without this hook a 404 / malformed - // resource is console.error-only and the user stares at a blank - // frame. Surface it as a toast. The renderer separately drives - // `data-app-status` so an automated driver can time out on - // never-reaching-"ready" and read the toast. - onResourceError: (err) => { - notifications.show({ - title: "App resource failed to load", - message: err.message, - color: "red", - }); - }, - }), - [inspectorClient], - ); + const sandboxBridgeFactory = useMemo(() => { + const createBridge = createAppBridgeFactory({ + getClient: () => inspectorClient?.getAppRendererClient() ?? null, + readResource: async (uri) => { + if (!inspectorClient) throw new Error("No MCP client connected."); + const invocation = await inspectorClient.readResource(uri); + return invocation.result; + }, + // The bridge's sandboxready handler reads + posts the UI resource + // inside a detached async block; without this hook a 404 / malformed + // resource is console.error-only and the user stares at a blank + // frame. Surface it as a toast. The renderer separately drives + // `data-app-status` so an automated driver can time out on + // never-reaching-"ready" and read the toast. + onResourceError: (err) => { + notifications.show({ + title: "App resource failed to load", + message: err.message, + color: "red", + }); + }, + }); + return async (...args: Parameters) => { + if (appElicitationHost.getPending().length > 0) { + throw new Error( + "An MCP App elicitation is already running on this connection", + ); + } + appsScreenBridgeActiveRef.current = true; + let bridge: Awaited>; + try { + bridge = await createBridge(...args); + } catch (error) { + appsScreenBridgeActiveRef.current = false; + throw error; + } + const closable = bridge as typeof bridge & { close(): Promise }; + const close = closable.close.bind(bridge); + closable.close = async () => { + try { + await close(); + } finally { + appsScreenBridgeActiveRef.current = false; + } + }; + return bridge; + }; + }, [appElicitationHost, inspectorClient]); const [managedToolsState, setManagedToolsState] = useState(null); @@ -1275,6 +1325,7 @@ function App() { useEffect(() => { if (!inspectorClient) return; const onDisconnect = () => { + appElicitationHost.clear("MCP connection disconnected"); setActiveServerId(undefined); // Drop the open flag too — without this the modal would pop back the // next time `initializeResult` re-becomes truthy (e.g. reconnect). @@ -1285,7 +1336,7 @@ function App() { return () => { inspectorClient.removeEventListener("disconnect", onDisconnect); }; - }, [inspectorClient, resetSessionScopedUiState]); + }, [appElicitationHost, inspectorClient, resetSessionScopedUiState]); // Surface incoming `notifications/progress` as toasts so the user can watch a // long-running tool's progress while staying on the tool view — the v2 @@ -2320,6 +2371,7 @@ function App() { ...(activeCimdUrl && { clientMetadataUrl: activeCimdUrl }), } : undefined; + appElicitationHost.clear("Inspector switched MCP connections"); const client = new InspectorClient(server.config, { environment, // The Tasks tab needs the receiver-task pipeline; the @@ -2328,6 +2380,19 @@ function App() { // 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 }, + appElicitation: { + host: appElicitationHost, + onError: (error) => { + const message = + error instanceof Error ? error.message : String(error); + notifications.show({ + title: "MCP App elicitation fell back to the native form", + message, + color: "yellow", + autoClose: false, + }); + }, + }, // Always advertise the roots capability (even with no configured // roots) so the server can issue roots/list and receive // roots/list_changed; the configured roots are the answer to @@ -2429,6 +2494,7 @@ function App() { sessionStorageAdapter, onBeforeOAuthRedirect, clientConfig, + appElicitationHost, ], ); @@ -4615,6 +4681,10 @@ function App() { onSamplingReject={onSamplingReject} onElicitationRespond={onElicitationRespond} /> + ; addEventListener: ReturnType; removeEventListener: ReturnType; + getAppCapabilities: ReturnType; + _initializedReceived: boolean; onrequestdisplaymode?: (params: { mode: "inline" | "fullscreen" | "pip"; }) => Promise<{ mode: "inline" | "fullscreen" | "pip" }>; @@ -39,7 +41,7 @@ interface MockBridge { function createMockBridge(): MockBridge { const listeners: Record void)[]> = {}; - return { + const bridge: MockBridge = { sendToolInput: vi.fn().mockResolvedValue(undefined), sendToolInputPartial: vi.fn().mockResolvedValue(undefined), sendToolResult: vi.fn().mockResolvedValue(undefined), @@ -57,10 +59,18 @@ function createMockBridge(): MockBridge { ); }, ), + getAppCapabilities: vi.fn(() => + bridge._initializedReceived ? {} : undefined, + ), + _initializedReceived: false, emit: (event: string, payload?: unknown) => { + if (event === "initialized") { + bridge._initializedReceived = true; + } (listeners[event] ?? []).forEach((h) => h(payload)); }, }; + return bridge; } function asBridge(mock: MockBridge): AppBridge { @@ -122,6 +132,27 @@ describe("AppRenderer", () => { expect(factory.mock.calls[0]?.[1]).toBe(tool); }); + it("recognizes a view that initialized before the bridge factory returned", async () => { + const bridge = createMockBridge(); + bridge._initializedReceived = true; + const onAppStatusChange = vi.fn(); + renderWithMantine( + asBridge(bridge)} + onAppStatusChange={onAppStatusChange} + />, + ); + + await flushAsync(); + + expect(onAppStatusChange).toHaveBeenCalledWith("loading"); + expect(onAppStatusChange).toHaveBeenCalledWith("ready"); + bridge.emit("initialized"); + expect(onAppStatusChange).toHaveBeenCalledTimes(2); + }); + it("forwards sendToolInput through the bridge once initialized", async () => { const bridge = createMockBridge(); const ref = createRef(); diff --git a/clients/web/src/components/elements/AppRenderer/AppRenderer.tsx b/clients/web/src/components/elements/AppRenderer/AppRenderer.tsx index 19eb85838..166a0a25d 100644 --- a/clients/web/src/components/elements/AppRenderer/AppRenderer.tsx +++ b/clients/web/src/components/elements/AppRenderer/AppRenderer.tsx @@ -126,7 +126,10 @@ async function disposeBridge(bridge: AppBridge): Promise { /* swallow — closing transport below is the load-bearing step */ } try { - await bridge.close(); + // AppBridge inherits close() at runtime, but ext-apps@1.7.4 omits it from + // the public declaration. Keep the cast at this compatibility boundary. + const closable = bridge as AppBridge & { close(): Promise }; + await closable.close(); } catch { /* swallow — already disposing */ } @@ -325,10 +328,8 @@ export function AppRenderer({ return; } bridgeRef.current = bridge; - // Registered before the inner app can finish loading (which only - // happens after the sandbox-resource-ready round-trip the factory - // drives), so the view's `initialized` signal is never missed. - bridge.addEventListener("initialized", () => { + const markInitialized = () => { + if (initializedRef.current) return; initializedRef.current = true; onAppStatusChangeRef.current?.("ready"); // The factory already seeded theme/styles/displayMode into the @@ -344,7 +345,15 @@ export function AppRenderer({ void bridge.sendHostContextChange({ containerDimensions }); } flushPending(); - }); + }; + // Registered before the inner app can finish loading (which only + // happens after the sandbox-resource-ready round-trip the factory + // drives). The capability check also covers a custom/test factory whose + // bridge completed initialization before its promise resolved. + bridge.addEventListener("initialized", markInitialized); + if (bridge.getAppCapabilities?.() !== undefined) { + markInitialized(); + } // Forward the view's content-size reports (ui/notifications/size-changed) // so the host can resize the iframe container to fit the rendered widget. bridge.addEventListener("sizechange", (size) => { diff --git a/clients/web/src/components/elements/AppRenderer/createAppBridgeFactory.test.ts b/clients/web/src/components/elements/AppRenderer/createAppBridgeFactory.test.ts index 13fa9f552..8c6749a69 100644 --- a/clients/web/src/components/elements/AppRenderer/createAppBridgeFactory.test.ts +++ b/clients/web/src/components/elements/AppRenderer/createAppBridgeFactory.test.ts @@ -133,13 +133,17 @@ describe("createAppBridgeFactory", () => { const factory = createAppBridgeFactory({ getClient: () => fakeClient, readResource: vi.fn().mockResolvedValue(uiResource("

hi

")), + enableElicitation: true, }); await factory(makeIframe(), tool); expect(bridgeInstances).toHaveLength(1); const bridge = bridgeInstances[0]; expect(bridge.ctorArgs[0]).toBe(fakeClient); expect(bridge.ctorArgs[1]).toMatchObject({ name: "MCP Inspector" }); - expect(bridge.ctorArgs[2]).toMatchObject({ serverTools: {} }); + expect(bridge.ctorArgs[2]).toMatchObject({ + serverTools: {}, + elicitation: {}, + }); // hostContext is the full snapshot: theme (from the DOM attribute), // the inline display mode, and the host's available display modes. // styles/containerDimensions are omitted for the bare test iframe. @@ -156,6 +160,16 @@ describe("createAppBridgeFactory", () => { } }); + it("does not advertise host elicitation for an ordinary Apps-screen bridge", async () => { + const factory = createAppBridgeFactory({ + getClient: () => fakeClient, + readResource: vi.fn(), + }); + await factory(makeIframe(), tool); + + expect(bridgeInstances[0].ctorArgs[2]).not.toHaveProperty("elicitation"); + }); + it("on sandboxready, reads the UI resource, wraps the html with the per-app CSP, and echoes the approved sandbox config", async () => { const readResource = vi.fn().mockResolvedValue( uiResource("

weather

", { diff --git a/clients/web/src/components/elements/AppRenderer/createAppBridgeFactory.ts b/clients/web/src/components/elements/AppRenderer/createAppBridgeFactory.ts index 9294c5146..95c5287b6 100644 --- a/clients/web/src/components/elements/AppRenderer/createAppBridgeFactory.ts +++ b/clients/web/src/components/elements/AppRenderer/createAppBridgeFactory.ts @@ -41,8 +41,9 @@ export const HOST_INFO: Implementation = { * Capabilities the inspector host offers a running MCP App. Constructed WITH an * MCP client (see {@link createAppBridgeFactory}), so the bridge auto-forwards * tools/resources/prompts to the view; we only declare the host-side features - * we actually back: external links and file downloads (both handled below), - * tool/resource list-change forwarding, and logging passthrough. + * we actually back for every App: external links and file downloads (both + * handled below), tool/resource list-change forwarding, and logging + * passthrough. App-elicitation sessions add `elicitation` per bridge below. */ export const HOST_CAPABILITIES: McpUiHostCapabilities = { openLinks: {}, @@ -74,6 +75,8 @@ export interface AppBridgeFactoryDeps { * frame; the error is also always console.error'd. */ onResourceError?: (err: Error) => void; + /** Advertise app-rendered elicitation support to this exact MCP App. */ + enableElicitation?: boolean; } /** First text content block of a UI resource, plus its `_meta` (sandbox hints). */ @@ -211,7 +214,10 @@ export function createAppBridgeFactory( // Per-app copy so the approved-sandbox echo (set on sandboxready below) // never mutates the shared HOST_CAPABILITIES constant — each app may // declare its own csp/permissions. - const hostCapabilities: McpUiHostCapabilities = { ...HOST_CAPABILITIES }; + const hostCapabilities: McpUiHostCapabilities = { + ...HOST_CAPABILITIES, + ...(deps.enableElicitation ? { elicitation: {} } : {}), + }; // ext-apps' `AppBridge` peers on SDK v1's `Client`/`Implementation`; both // are runtime-compatible with v2's. Cast at this single construction // boundary. TODO: drop when ext-apps#702 ships a v2 peer release. @@ -223,7 +229,6 @@ export function createAppBridgeFactory( hostContext: snapshotHostContext(iframe, HOST_AVAILABLE_DISPLAY_MODES), }, ); - // The double-iframe proxy posts `sandboxready` once it can receive content. // Read the tool's UI resource and hand its HTML (plus any sandbox/permission // hints from the resource _meta) to the inner sandboxed iframe. A failure diff --git a/clients/web/src/components/groups/AppElicitationModal/AppElicitationModal.test.tsx b/clients/web/src/components/groups/AppElicitationModal/AppElicitationModal.test.tsx new file mode 100644 index 000000000..c23dab35c --- /dev/null +++ b/clients/web/src/components/groups/AppElicitationModal/AppElicitationModal.test.tsx @@ -0,0 +1,213 @@ +import { useEffect } from "react"; +import { beforeEach, describe, expect, it, vi } from "vitest"; +import type { AppBridge } from "@modelcontextprotocol/ext-apps/app-bridge"; +import type { PendingAppElicitation } from "../../../lib/appElicitationHost"; +import { + fireEvent, + renderWithMantine, + screen, + waitFor, +} from "../../../test/renderWithMantine"; +import { AppElicitationModal } from "./AppElicitationModal"; + +const { bridge, bridgeFactory, factoryDeps, clientRequest } = vi.hoisted(() => { + const bridgeValue = Object.assign(Object.create(null), { + getAppCapabilities: () => ({ elicitation: {} }), + request: vi.fn(), + close: vi.fn(), + }) as AppBridge; + return { + bridge: bridgeValue, + bridgeFactory: vi.fn().mockResolvedValue(bridgeValue), + factoryDeps: { + current: null as null | { + getClient: () => unknown; + readResource: (uri: string) => Promise; + onResourceError?: (error: Error) => void; + }, + }, + clientRequest: vi.fn().mockResolvedValue({ contents: [] }), + }; +}); + +vi.mock("../../elements/AppRenderer/createAppBridgeFactory", () => ({ + createAppBridgeFactory: vi.fn( + (deps: { + getClient: () => unknown; + readResource: (uri: string) => Promise; + onResourceError?: (error: Error) => void; + }) => { + factoryDeps.current = deps; + return async (iframe: HTMLIFrameElement, tool: unknown) => { + deps.getClient(); + await deps.readResource("ui://demo/confirmation"); + return bridgeFactory(iframe, tool); + }; + }, + ), +})); + +vi.mock("../../elements/AppRenderer/AppRenderer", () => ({ + AppRenderer: ({ + bridgeFactory: factory, + tool, + onAppStatusChange, + onError, + }: { + bridgeFactory: ( + iframe: HTMLIFrameElement, + tool: unknown, + ) => Promise; + tool: unknown; + onAppStatusChange?: (status: "ready" | "error") => void; + onError?: (error: Error) => void; + }) => { + useEffect(() => { + void factory(document.createElement("iframe"), tool).then( + () => onAppStatusChange?.("ready"), + (error: Error) => onError?.(error), + ); + }, [factory, onAppStatusChange, onError, tool]); + return ( + <> +
+ + + + ); + }, +})); + +function createPending(): PendingAppElicitation { + return { + id: "elicitation-1", + client: Object.assign(Object.create(null), { request: clientRequest }), + rendererClient: Object.create(null), + request: { + method: "elicitation/create", + params: { + mode: "form", + message: "Review the requested action", + requestedSchema: { + type: "object", + properties: { approved: { type: "boolean" } }, + }, + }, + }, + resourceUri: "ui://demo/confirmation", + attachBridge: vi.fn(), + markReady: vi.fn(), + fail: vi.fn(), + }; +} + +describe("AppElicitationModal", () => { + beforeEach(() => { + bridgeFactory.mockReset().mockResolvedValue(bridge); + clientRequest.mockClear(); + factoryDeps.current = null; + }); + + it("loads the bound app and marks it ready after initialization", async () => { + const pending = createPending(); + + renderWithMantine( + , + ); + + expect(screen.getByText("Review the requested action")).toBeInTheDocument(); + expect(screen.getByTestId("app-renderer")).toBeInTheDocument(); + await waitFor(() => { + expect(pending.attachBridge).toHaveBeenCalledWith(bridge); + expect(pending.markReady).toHaveBeenCalledOnce(); + expect(clientRequest).toHaveBeenCalledWith( + { + method: "resources/read", + params: { uri: "ui://demo/confirmation" }, + }, + expect.anything(), + ); + }); + }); + + it("reports renderer status, bridge, and resource failures", async () => { + const pending = createPending(); + renderWithMantine( + , + ); + await waitFor(() => expect(pending.markReady).toHaveBeenCalledOnce()); + + fireEvent.click( + screen.getByRole("button", { name: "Report status error" }), + ); + await waitFor(() => + expect( + document.querySelector("[data-app-elicitation-status='error']"), + ).toBeInTheDocument(), + ); + + fireEvent.click( + screen.getByRole("button", { name: "Report renderer error" }), + ); + expect(pending.fail).toHaveBeenCalledWith( + expect.objectContaining({ message: "renderer failed" }), + ); + + factoryDeps.current?.onResourceError?.(new Error("resource failed")); + expect(pending.fail).toHaveBeenCalledWith( + expect.objectContaining({ message: "resource failed" }), + ); + }); + + it("reports a bridge factory rejection", async () => { + bridgeFactory.mockRejectedValueOnce(new Error("bridge failed")); + const pending = createPending(); + + renderWithMantine( + , + ); + + await waitFor(() => + expect(pending.fail).toHaveBeenCalledWith( + expect.objectContaining({ message: "bridge failed" }), + ), + ); + }); + + it("fails the app route when the sandbox is unavailable", async () => { + const pending = createPending(); + + renderWithMantine( + , + ); + + await waitFor(() => + expect(pending.fail).toHaveBeenCalledWith( + expect.objectContaining({ message: "MCP Apps sandbox is unavailable" }), + ), + ); + }); + + it("stays closed when no app elicitation is pending", () => { + renderWithMantine( + , + ); + + expect( + screen.queryByText("App Elicitation Request"), + ).not.toBeInTheDocument(); + }); +}); diff --git a/clients/web/src/components/groups/AppElicitationModal/AppElicitationModal.tsx b/clients/web/src/components/groups/AppElicitationModal/AppElicitationModal.tsx new file mode 100644 index 000000000..b9b1cac9e --- /dev/null +++ b/clients/web/src/components/groups/AppElicitationModal/AppElicitationModal.tsx @@ -0,0 +1,127 @@ +import { useEffect, useMemo, useState } from "react"; +import { Modal, Paper, Stack, Text } from "@mantine/core"; +import type { AppBridge } from "@modelcontextprotocol/ext-apps/app-bridge"; +import type { Tool } from "@modelcontextprotocol/client"; +import { ReadResourceResultSchema } from "@modelcontextprotocol/core"; +import type { PendingAppElicitation } from "../../../lib/appElicitationHost"; +import { + AppRenderer, + type AppRendererStatus, + type BridgeFactory, +} from "../../elements/AppRenderer/AppRenderer"; +import { createAppBridgeFactory } from "../../elements/AppRenderer/createAppBridgeFactory"; + +export interface AppElicitationModalProps { + request: PendingAppElicitation | null; + sandboxPath?: string; +} + +const ElicitationModal = Modal.withProps({ + withCloseButton: false, + closeOnClickOutside: false, + closeOnEscape: false, + size: "xl", +}); + +const ModalContent = Stack.withProps({ + gap: "md", + h: "min(75vh, 720px)", +}); + +const AppFrame = Paper.withProps({ + withBorder: true, + radius: "md", + flex: 1, + mih: 0, +}); + +/* v8 ignore next -- every modal dismissal mechanism is disabled; Mantine + requires an onClose callback even though the user cannot invoke it. */ +function ignoreClose(): void {} + +function buildSyntheticTool(request: PendingAppElicitation): Tool { + return { + name: request.id, + title: "App-rendered elicitation", + description: request.request.params.message, + inputSchema: { type: "object", properties: {} }, + _meta: { ui: { resourceUri: request.resourceUri } }, + }; +} + +function buildBridgeFactory(request: PendingAppElicitation): BridgeFactory { + const baseFactory = createAppBridgeFactory({ + getClient: () => request.rendererClient, + readResource: (uri) => + request.client.request( + { method: "resources/read", params: { uri } }, + ReadResourceResultSchema, + ), + onResourceError: (error) => request.fail(error), + enableElicitation: true, + }); + return async (iframe, tool): Promise => { + const bridge = await baseFactory(iframe, tool); + request.attachBridge(bridge); + return bridge; + }; +} + +function AppElicitationBody({ + request, + sandboxPath, +}: { + request: PendingAppElicitation; + sandboxPath: string; +}) { + const [status, setStatus] = useState("loading"); + const tool = useMemo(() => buildSyntheticTool(request), [request]); + const bridgeFactory = useMemo(() => buildBridgeFactory(request), [request]); + + const handleStatusChange = (next: AppRendererStatus): void => { + setStatus(next); + if (next === "ready") request.markReady(); + }; + + return ( + + {request.request.params.message} + + request.fail(error)} + /> + + + ); +} + +export function AppElicitationModal({ + request, + sandboxPath, +}: AppElicitationModalProps) { + useEffect(() => { + if (request && !sandboxPath) { + request.fail(new Error("MCP Apps sandbox is unavailable")); + } + }, [request, sandboxPath]); + + return ( + + {request && sandboxPath && ( + + )} + + ); +} diff --git a/clients/web/src/components/groups/SchemaForm/SchemaForm.test.tsx b/clients/web/src/components/groups/SchemaForm/SchemaForm.test.tsx index c534fb29f..080afb224 100644 --- a/clients/web/src/components/groups/SchemaForm/SchemaForm.test.tsx +++ b/clients/web/src/components/groups/SchemaForm/SchemaForm.test.tsx @@ -1,7 +1,12 @@ +import { useState } from "react"; import { describe, it, expect, vi } from "vitest"; import userEvent from "@testing-library/user-event"; import type { InspectorFormSchema } from "../../../utils/jsonUtils"; -import { renderWithMantine, screen } from "../../../test/renderWithMantine"; +import { + fireEvent, + renderWithMantine, + screen, +} from "../../../test/renderWithMantine"; import { SchemaForm } from "./SchemaForm"; describe("SchemaForm", () => { @@ -500,7 +505,7 @@ describe("SchemaForm", () => { expect(lastCall.config).toEqual([1, 2]); }); - it("falls back to passing raw string to onChange when JSON is invalid in JsonInput", async () => { + it("keeps invalid JSON as an editor draft instead of publishing an escaped string", async () => { const user = userEvent.setup(); const onChange = vi.fn(); const schema: InspectorFormSchema = { @@ -514,9 +519,88 @@ describe("SchemaForm", () => { ); const jsonInput = screen.getByLabelText(/Config/) as HTMLTextAreaElement; await user.type(jsonInput, "x"); - expect(onChange).toHaveBeenCalled(); - const lastCall = onChange.mock.calls[onChange.mock.calls.length - 1][0]; - expect(typeof lastCall.config).toBe("string"); + expect(jsonInput.value).toBe("x"); + expect(onChange).not.toHaveBeenCalled(); + }); + + it("does not escape or take over an array field during character-by-character editing", () => { + const schema: InspectorFormSchema = { + type: "object", + properties: { + messageIds: { type: "array", title: "Message IDs" }, + }, + }; + + function ControlledForm() { + const [values, setValues] = useState>({}); + return ( + + ); + } + + renderWithMantine(); + const jsonInput = screen.getByLabelText( + /Message IDs/, + ) as HTMLTextAreaElement; + let draft = ""; + for (const character of '["m1","m2"]') { + draft += character; + fireEvent.change(jsonInput, { target: { value: draft } }); + } + + expect(jsonInput.value).toBe('["m1","m2"]'); + }); + + it("clears a prior parsed value while replacement JSON is incomplete", async () => { + const user = userEvent.setup(); + const onChange = vi.fn(); + const schema: InspectorFormSchema = { + type: "object", + properties: { + config: { type: "array", title: "Config" }, + }, + }; + renderWithMantine( + , + ); + const jsonInput = screen.getByLabelText(/Config/) as HTMLTextAreaElement; + await user.clear(jsonInput); + fireEvent.change(jsonInput, { target: { value: "[" } }); + + expect(jsonInput.value).toBe("["); + expect(onChange).toHaveBeenCalledWith({ config: undefined }); + }); + + it("preserves an invalid replacement draft instead of restoring the schema default", () => { + const schema: InspectorFormSchema = { + type: "object", + properties: { + config: { + type: "array", + title: "Config", + default: ["default"], + }, + }, + }; + + function ControlledForm() { + const [values, setValues] = useState>({ + config: ["edited"], + }); + return ( + + ); + } + + renderWithMantine(); + const jsonInput = screen.getByLabelText(/Config/) as HTMLTextAreaElement; + fireEvent.change(jsonInput, { target: { value: "[" } }); + + expect(jsonInput.value).toBe("["); }); it("uses default values when value is undefined", () => { diff --git a/clients/web/src/components/groups/SchemaForm/SchemaForm.tsx b/clients/web/src/components/groups/SchemaForm/SchemaForm.tsx index 30e39795e..e7143484d 100644 --- a/clients/web/src/components/groups/SchemaForm/SchemaForm.tsx +++ b/clients/web/src/components/groups/SchemaForm/SchemaForm.tsx @@ -8,6 +8,7 @@ import { Text, TextInput, } from "@mantine/core"; +import { useState } from "react"; import { ClearButton } from "../../elements/ClearButton/ClearButton"; import type { InspectorFormSchema } from "../../../utils/jsonUtils"; @@ -30,7 +31,99 @@ const SchemaJsonInput = JsonInput.withProps({ }); function serializeJson(value: unknown): string { - return JSON.stringify(value, null, 2); + return value === undefined ? "" : (JSON.stringify(value, null, 2) ?? ""); +} + +interface ComplexJsonFieldProps { + label: string; + description?: string; + required: boolean; + disabled: boolean; + value: unknown; + onChange: (value: unknown) => void; +} + +interface JsonEditorState { + draft: string; + externalText: string; + publishedText: string | null; + invalid: boolean; +} + +/** + * Keeps the editor's raw text separate from parsed form state. JSON is usually + * invalid between keystrokes; publishing that partial text to the parent would + * make the controlled field stringify it as a JSON string on the next render. + */ +function ComplexJsonField({ + label, + description, + required, + disabled, + value, + onChange, +}: ComplexJsonFieldProps) { + const externalText = serializeJson(value); + const [editor, setEditor] = useState({ + draft: externalText, + externalText, + publishedText: null, + invalid: false, + }); + + if (externalText !== editor.externalText) { + if (editor.publishedText === externalText) { + setEditor({ + ...editor, + externalText, + publishedText: null, + }); + } else { + setEditor({ + draft: externalText, + externalText, + publishedText: null, + invalid: false, + }); + } + } + + const handleChange = (nextDraft: string): void => { + try { + const parsed = JSON.parse(nextDraft); + setEditor({ + ...editor, + draft: nextDraft, + publishedText: serializeJson(parsed), + invalid: false, + }); + onChange(parsed); + } catch { + // Clear a previously-valid value once, but keep the partial editor text + // local so subsequent keystrokes are not escaped or reformatted. + const shouldClear = !editor.invalid && value !== undefined; + setEditor({ + ...editor, + draft: nextDraft, + publishedText: shouldClear ? "" : editor.publishedText, + invalid: true, + }); + if (shouldClear) { + onChange(undefined); + } + } + }; + + return ( + + ); } /** @@ -90,6 +183,7 @@ export function SchemaForm({ const isRequired = requiredFields.includes(fieldName); const label = fieldSchema.title ?? fieldName; const description = fieldSchema.description; + const hasExplicitValue = Object.hasOwn(values, fieldName); const rawValue = resolveValue(values[fieldName], fieldSchema); // string with enum @@ -251,20 +345,14 @@ export function SchemaForm({ // fallback: JsonInput for complex schemas return ( - { - try { - handleFieldChange(fieldName, JSON.parse(val)); - } catch { - handleFieldChange(fieldName, val); - } - }} + value={hasExplicitValue ? values[fieldName] : fieldSchema.default} + onChange={(value) => handleFieldChange(fieldName, value)} /> ); } diff --git a/clients/web/src/components/screens/AppsScreen/AppsScreen.stories.tsx b/clients/web/src/components/screens/AppsScreen/AppsScreen.stories.tsx index 84061cad6..7f2eb9c51 100644 --- a/clients/web/src/components/screens/AppsScreen/AppsScreen.stories.tsx +++ b/clients/web/src/components/screens/AppsScreen/AppsScreen.stories.tsx @@ -39,6 +39,7 @@ function createMockBridge(): AppBridge { close: async () => {}, addEventListener: () => {}, removeEventListener: () => {}, + _initializedReceived: false, // Partial mock: implements only the `AppBridge` members the screen // exercises; the double cast bridges the deliberately-incomplete shape. } as unknown as AppBridge; @@ -201,6 +202,7 @@ export const EchoRunning: Story = { const bridgeFactory: BridgeFactory = useCallback(() => { let onInitialized: (() => void) | undefined; const bridge = { + _initializedReceived: false, addEventListener: (event: string, handler: () => void) => { if (event === "initialized") onInitialized = handler; }, @@ -229,7 +231,10 @@ export const EchoRunning: Story = { }; // Simulate the view finishing initialization shortly after AppRenderer // registers its `initialized` listener; sendToolInput then flushes. - setTimeout(() => onInitialized?.(), 20); + setTimeout(() => { + bridge._initializedReceived = true; + onInitialized?.(); + }, 20); // Partial mock (only the members this story drives); the double cast // bridges the deliberately-incomplete shape. return bridge as unknown as AppBridge; diff --git a/clients/web/src/components/screens/AppsScreen/AppsScreen.test.tsx b/clients/web/src/components/screens/AppsScreen/AppsScreen.test.tsx index 1525d508a..8dba015d6 100644 --- a/clients/web/src/components/screens/AppsScreen/AppsScreen.test.tsx +++ b/clients/web/src/components/screens/AppsScreen/AppsScreen.test.tsx @@ -61,6 +61,7 @@ const okBridgeFactory: BridgeFactory = () => sendHostContextChange: async () => {}, addEventListener: () => {}, removeEventListener: () => {}, + _initializedReceived: false, teardownResource: async () => ({}), close: async () => {}, }) as unknown as AppBridge; @@ -90,6 +91,7 @@ function createEventBridgeFactory(): { (listeners[event] ??= []).push(handler); }, removeEventListener: () => {}, + _initializedReceived: false, } as unknown as AppBridge; bridges.push(bridge); return bridge; @@ -97,8 +99,14 @@ function createEventBridgeFactory(): { return { factory, bridges, - emit: (event, payload) => - (listeners[event] ?? []).forEach((h) => h(payload)), + emit: (event, payload) => { + if (event === "initialized") { + for (const bridge of bridges) { + Reflect.set(bridge, "_initializedReceived", true); + } + } + (listeners[event] ?? []).forEach((h) => h(payload)); + }, }; } diff --git a/clients/web/src/lib/appElicitationHost.test.ts b/clients/web/src/lib/appElicitationHost.test.ts new file mode 100644 index 000000000..f174ac9e6 --- /dev/null +++ b/clients/web/src/lib/appElicitationHost.test.ts @@ -0,0 +1,282 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; +import type { AppBridge } from "@modelcontextprotocol/ext-apps/app-bridge"; +import { Client, type ElicitRequest } from "@modelcontextprotocol/client"; +import { WebAppElicitationHost } from "./appElicitationHost"; + +const request: ElicitRequest = { + method: "elicitation/create", + params: { + mode: "form", + message: "Approve this action", + requestedSchema: { + type: "object", + properties: { approved: { type: "boolean" } }, + required: ["approved"], + }, + _meta: { ui: { resourceUri: "ui://demo/confirmation" } }, + }, +}; + +function createBridge() { + const requestFn = vi.fn().mockResolvedValue({ + action: "accept", + content: { approved: true }, + }); + const close = vi.fn().mockResolvedValue(undefined); + const bridge = Object.assign(Object.create(null), { + getAppCapabilities: () => ({ elicitation: {} }), + requestElicitation: requestFn, + close, + }) as AppBridge; + return { bridge, requestFn, close }; +} + +afterEach(() => { + vi.useRealTimers(); +}); + +describe("WebAppElicitationHost", () => { + it("binds a session to the originating client, request, and resource", async () => { + const host = new WebAppElicitationHost(); + const client = new Client({ name: "test", version: "1" }); + const controller = new AbortController(); + const sessionPromise = host.open({ + client, + request, + resourceUri: "ui://demo/confirmation", + signal: controller.signal, + }); + const pending = host.getPending()[0]; + const { bridge, requestFn, close } = createBridge(); + + expect(pending).toMatchObject({ + client, + request, + resourceUri: "ui://demo/confirmation", + }); + expect(pending?.rendererClient).not.toBe(client); + + pending?.attachBridge(bridge); + pending?.markReady(); + const session = await sessionPromise; + await expect( + session.requestElicitation(request.params, { + signal: controller.signal, + }), + ).resolves.toEqual({ + action: "accept", + content: { approved: true }, + }); + expect(requestFn).toHaveBeenCalledWith( + request.params, + expect.objectContaining({ signal: controller.signal }), + ); + + await session.close(); + expect(close).toHaveBeenCalledOnce(); + expect(host.getPending()).toHaveLength(0); + }); + + it("serializes concurrent app sessions and activates the next after cleanup", async () => { + vi.useFakeTimers(); + const host = new WebAppElicitationHost(100); + const client = new Client({ name: "test", version: "1" }); + const first = host.open({ + client, + request, + resourceUri: "ui://first", + signal: new AbortController().signal, + }); + const second = host.open({ + client, + request, + resourceUri: "ui://second", + signal: new AbortController().signal, + }); + const secondRejection = expect(second).rejects.toThrow( + "did not initialize within 100ms", + ); + const [firstPending, secondPending] = host.getPending(); + const firstBridge = createBridge(); + + await vi.advanceTimersByTimeAsync(99); + firstPending?.attachBridge(firstBridge.bridge); + firstPending?.markReady(); + const firstSession = await first; + await firstSession.close(); + + await vi.advanceTimersByTimeAsync(100); + await secondRejection; + expect(secondPending?.resourceUri).toBe("ui://second"); + expect(host.getPending()).toHaveLength(0); + }); + + it("rejects and removes an aborted pending session", async () => { + const host = new WebAppElicitationHost(); + const controller = new AbortController(); + const session = host.open({ + client: new Client({ name: "test", version: "1" }), + request, + resourceUri: "ui://demo/confirmation", + signal: controller.signal, + }); + + controller.abort(new Error("tool call cancelled")); + + await expect(session).rejects.toThrow("tool call cancelled"); + expect(host.getPending()).toHaveLength(0); + }); + + it("clears every queued session on connection teardown", async () => { + const host = new WebAppElicitationHost(); + const client = new Client({ name: "test", version: "1" }); + const sessions = ["ui://one", "ui://two"].map((resourceUri) => + host.open({ + client, + request, + resourceUri, + signal: new AbortController().signal, + }), + ); + + host.clear("disconnected"); + + await expect(Promise.all(sessions)).rejects.toThrow("disconnected"); + expect(host.getPending()).toHaveLength(0); + }); + + it("cancels a ready bridge request and removes it on connection teardown", async () => { + const host = new WebAppElicitationHost(); + const client = new Client({ name: "test", version: "1" }); + const sessionPromise = host.open({ + client, + request, + resourceUri: "ui://demo/confirmation", + signal: new AbortController().signal, + }); + const pending = host.getPending()[0]; + const close = vi.fn().mockResolvedValue(undefined); + const bridge = Object.assign(Object.create(null), { + getAppCapabilities: () => ({ elicitation: {} }), + requestElicitation: vi.fn(() => new Promise(() => {})), + close, + }) as AppBridge; + pending?.attachBridge(bridge); + pending?.markReady(); + const session = await sessionPromise; + const response = session.requestElicitation(request.params, { + signal: new AbortController().signal, + }); + + host.clear("disconnected"); + + await expect(response).rejects.toThrow("disconnected"); + expect(close).toHaveBeenCalledOnce(); + expect(host.getPending()).toHaveLength(0); + }); + + it("rejects app rendering while another bridge owns the connection", async () => { + const host = new WebAppElicitationHost(100, () => false); + + await expect( + host.open({ + client: new Client({ name: "test", version: "1" }), + request, + resourceUri: "ui://demo/confirmation", + signal: new AbortController().signal, + }), + ).rejects.toThrow("Another MCP App is already running"); + expect(host.getPending()).toHaveLength(0); + }); + + it("ignores duplicate lifecycle signals and closes a late bridge", async () => { + const host = new WebAppElicitationHost(); + const session = host.open({ + client: new Client({ name: "test", version: "1" }), + request, + resourceUri: "ui://demo/confirmation", + signal: new AbortController().signal, + }); + const pending = host.getPending()[0]; + pending?.markReady(); + host.clear(); + pending?.fail("already removed"); + const close = vi.fn().mockRejectedValue(new Error("already closed")); + const lateBridge = Object.assign(Object.create(null), { + close, + }) as AppBridge; + + pending?.attachBridge(lateBridge); + + await expect(session).rejects.toThrow("MCP connection closed"); + await vi.waitFor(() => expect(close).toHaveBeenCalledOnce()); + }); + + it("removes the binding when normal session close fails", async () => { + const host = new WebAppElicitationHost(); + const sessionPromise = host.open({ + client: new Client({ name: "test", version: "1" }), + request, + resourceUri: "ui://demo/confirmation", + signal: new AbortController().signal, + }); + const pending = host.getPending()[0]; + const bridge = Object.assign(Object.create(null), { + getAppCapabilities: () => ({ elicitation: {} }), + requestElicitation: vi.fn(), + close: vi.fn().mockRejectedValue(new Error("close failed")), + }) as AppBridge; + pending?.attachBridge(bridge); + pending?.markReady(); + pending?.markReady(); + const session = await sessionPromise; + + await expect(session.close()).rejects.toThrow("close failed"); + await expect(session.close()).resolves.toBeUndefined(); + expect(host.getPending()).toHaveLength(0); + }); + + it("absorbs a synchronous late-bridge close failure", async () => { + const host = new WebAppElicitationHost(); + const session = host.open({ + client: new Client({ name: "test", version: "1" }), + request, + resourceUri: "ui://demo/confirmation", + signal: new AbortController().signal, + }); + const pending = host.getPending()[0]; + host.clear("gone"); + const bridge = Object.assign(Object.create(null), { + close: vi.fn(() => { + throw new Error("sync close"); + }), + }) as AppBridge; + + expect(() => pending?.attachBridge(bridge)).not.toThrow(); + pending?.attachBridge(bridge); + await expect(session).rejects.toThrow("gone"); + }); + + it("normalizes non-Error failures and an abort without a reason", async () => { + const host = new WebAppElicitationHost(); + const first = host.open({ + client: new Client({ name: "test", version: "1" }), + request, + resourceUri: "ui://demo/confirmation", + signal: new AbortController().signal, + }); + host.getPending()[0]?.fail("string failure"); + await expect(first).rejects.toThrow("string failure"); + + const controller = new AbortController(); + Object.defineProperty(controller.signal, "reason", { value: undefined }); + const second = host.open({ + client: new Client({ name: "test", version: "1" }), + request, + resourceUri: "ui://demo/confirmation", + signal: controller.signal, + }); + controller.abort(); + await expect(second).rejects.toThrow("App elicitation cancelled"); + }); +}); diff --git a/clients/web/src/lib/appElicitationHost.ts b/clients/web/src/lib/appElicitationHost.ts new file mode 100644 index 000000000..be157ac49 --- /dev/null +++ b/clients/web/src/lib/appElicitationHost.ts @@ -0,0 +1,240 @@ +import type { AppBridge } from "@modelcontextprotocol/ext-apps/app-bridge"; +import type { + Client, + ElicitRequest, + ElicitResult, +} from "@modelcontextprotocol/client"; +import type { + AppElicitationBridgeSession, + AppElicitationHost, + OpenAppElicitationOptions, +} from "@inspector/core/mcp/appElicitation.js"; +import { AppElicitationCancelledError } from "@inspector/core/mcp/appElicitation.js"; +import { createAppRendererClientProxy } from "@inspector/core/mcp/appRendererClient.js"; + +const DEFAULT_INITIALIZATION_TIMEOUT_MS = 15_000; + +function toError(error: unknown): Error { + return error instanceof Error ? error : new Error(String(error)); +} + +export interface PendingAppElicitation { + readonly id: string; + readonly client: Client; + readonly rendererClient: Client; + readonly request: ElicitRequest; + readonly resourceUri: string; + attachBridge(bridge: AppBridge): void; + markReady(): void; + fail(error: unknown): void; +} + +class AppElicitationBinding implements PendingAppElicitation { + readonly id = `app-elicitation-${crypto.randomUUID()}`; + readonly rendererClient: Client; + private bridge: AppBridge | undefined; + private initializationTimer: ReturnType | undefined; + private ready = false; + private removed = false; + private bridgeCloseStarted = false; + private readonly sessionPromise: Promise; + private readonly cancellationPromise: Promise; + private resolveSession!: (session: AppElicitationBridgeSession) => void; + private rejectSession!: (error: Error) => void; + private rejectCancellation!: (error: Error) => void; + readonly client: Client; + readonly request: ElicitRequest; + readonly resourceUri: string; + private readonly signal: AbortSignal; + private readonly initializationTimeoutMs: number; + private readonly onRemove: (id: string) => void; + + constructor( + client: Client, + request: ElicitRequest, + resourceUri: string, + signal: AbortSignal, + initializationTimeoutMs: number, + onRemove: (id: string) => void, + ) { + this.client = client; + this.request = request; + this.resourceUri = resourceUri; + this.signal = signal; + this.initializationTimeoutMs = initializationTimeoutMs; + this.onRemove = onRemove; + this.rendererClient = createAppRendererClientProxy(client); + this.sessionPromise = new Promise((resolve, reject) => { + this.resolveSession = resolve; + this.rejectSession = reject; + }); + this.cancellationPromise = new Promise((_, reject) => { + this.rejectCancellation = reject; + }); + signal.addEventListener("abort", this.handleAbort, { once: true }); + } + + get session(): Promise { + return this.sessionPromise; + } + + activate(): void { + if (this.removed || this.initializationTimer) return; + this.initializationTimer = setTimeout(() => { + this.fail( + new Error( + `MCP App elicitation did not initialize within ${this.initializationTimeoutMs}ms`, + ), + ); + }, this.initializationTimeoutMs); + } + + attachBridge(bridge: AppBridge): void { + if (this.removed) { + this.closeBridgeBestEffort(bridge); + return; + } + this.bridge = bridge; + } + + markReady(): void { + if (this.ready || this.removed || !this.bridge) return; + this.ready = true; + this.clearInitialization(); + const bridge = this.bridge; + this.resolveSession({ + getAppCapabilities: () => bridge.getAppCapabilities(), + requestElicitation: (params, options) => + Promise.race([ + // ext-apps currently peers on SDK v1 while Inspector uses SDK v2. + // The wire shapes are identical; keep the compatibility cast at this + // single bridge boundary until ext-apps moves to the v2 SDK. + bridge.requestElicitation( + params as unknown as Parameters[0], + options as Parameters[1], + ) as unknown as Promise, + this.cancellationPromise, + ]), + close: () => this.closeSession(bridge), + }); + } + + fail(error: unknown): void { + if (this.removed) return; + const reason = toError(error); + this.clearInitialization(); + if (this.ready) { + this.rejectCancellation(reason); + } else { + this.rejectSession(reason); + } + if (this.bridge) this.closeBridgeBestEffort(this.bridge); + this.remove(); + } + + private readonly handleAbort = (): void => { + this.fail(this.signal.reason ?? new Error("App elicitation cancelled")); + }; + + private clearInitialization(): void { + if (this.initializationTimer) { + clearTimeout(this.initializationTimer); + this.initializationTimer = undefined; + } + this.signal.removeEventListener("abort", this.handleAbort); + } + + private async closeSession(bridge: AppBridge): Promise { + if (this.bridgeCloseStarted) { + this.remove(); + return; + } + this.bridgeCloseStarted = true; + try { + await bridge.close(); + } finally { + this.remove(); + } + } + + private closeBridgeBestEffort(bridge: AppBridge): void { + if (this.bridgeCloseStarted) return; + this.bridgeCloseStarted = true; + const closable = bridge as AppBridge & { close(): Promise }; + try { + void closable.close().catch(() => {}); + } catch { + // The request is already failed/cancelled; removal is the remaining step. + } + } + + private remove(): void { + if (this.removed) return; + this.removed = true; + this.onRemove(this.id); + } +} + +/** + * Queue of explicitly bound SEP-3118 app sessions. Only the head is activated + * and rendered, so concurrent server requests cannot share or steal a bridge. + */ +export class WebAppElicitationHost + extends EventTarget + implements AppElicitationHost +{ + private readonly pending: AppElicitationBinding[] = []; + private readonly initializationTimeoutMs: number; + private readonly canOpen: () => boolean; + + constructor( + initializationTimeoutMs = DEFAULT_INITIALIZATION_TIMEOUT_MS, + canOpen: () => boolean = () => true, + ) { + super(); + this.initializationTimeoutMs = initializationTimeoutMs; + this.canOpen = canOpen; + } + + open( + options: OpenAppElicitationOptions, + ): Promise { + if (!this.canOpen()) { + return Promise.reject( + new Error( + "Another MCP App is already running on this connection; close it to use the app-rendered elicitation", + ), + ); + } + const binding = new AppElicitationBinding( + options.client, + options.request, + options.resourceUri, + options.signal, + this.initializationTimeoutMs, + (id) => this.remove(id), + ); + this.pending.push(binding); + if (this.pending.length === 1) binding.activate(); + this.dispatchEvent(new Event("change")); + return binding.session; + } + + getPending(): readonly PendingAppElicitation[] { + return this.pending; + } + + clear(reason = "MCP connection closed"): void { + for (const binding of [...this.pending]) { + binding.fail(new AppElicitationCancelledError(reason)); + } + } + + private remove(id: string): void { + const index = this.pending.findIndex((binding) => binding.id === id); + if (index === -1) return; + this.pending.splice(index, 1); + this.pending[0]?.activate(); + this.dispatchEvent(new Event("change")); + } +} diff --git a/clients/web/src/test/core/mcp/appElicitation.test.ts b/clients/web/src/test/core/mcp/appElicitation.test.ts new file mode 100644 index 000000000..4f830f89d --- /dev/null +++ b/clients/web/src/test/core/mcp/appElicitation.test.ts @@ -0,0 +1,222 @@ +import type { + ClientCapabilities, + ElicitRequest, + ServerCapabilities, +} from "@modelcontextprotocol/client"; +import { describe, expect, it } from "vitest"; +import { + AppElicitationCancelledError, + MCP_APP_RESOURCE_MIME_TYPE, + MCP_APPS_EXTENSION_ID, + getElicitationUiResourceUri, + supportsAppElicitation, + withAppElicitationClientCapabilities, +} from "@inspector/core/mcp/appElicitation.js"; + +const negotiatedClient: ClientCapabilities = { + elicitation: { form: {} }, + extensions: { + [MCP_APPS_EXTENSION_ID]: { + mimeTypes: [MCP_APP_RESOURCE_MIME_TYPE], + elicitation: {}, + }, + }, +}; + +const negotiatedServer: ServerCapabilities = { + extensions: { + [MCP_APPS_EXTENSION_ID]: { + elicitation: {}, + }, + }, +}; + +function uncheckedCapability(value: unknown): T { + return value as T; +} + +function elicitationParams(resourceUri?: unknown): ElicitRequest["params"] { + return { + mode: "form", + message: "Review this request", + requestedSchema: { + type: "object", + properties: {}, + }, + ...(resourceUri === undefined + ? {} + : { + _meta: { + ui: { resourceUri }, + }, + }), + } as ElicitRequest["params"]; +} + +describe("app elicitation capability helpers", () => { + it("adds the MCP Apps MIME type and elicitation capability", () => { + expect(withAppElicitationClientCapabilities({})).toEqual({ + extensions: { + [MCP_APPS_EXTENSION_ID]: { + mimeTypes: [MCP_APP_RESOURCE_MIME_TYPE], + elicitation: {}, + }, + }, + }); + }); + + it("preserves extensions and valid MIME types while removing duplicates", () => { + const capabilities = { + sampling: {}, + extensions: { + "example.test/other": { enabled: true }, + [MCP_APPS_EXTENSION_ID]: { + futureField: "preserved", + mimeTypes: [ + "text/example", + 42, + MCP_APP_RESOURCE_MIME_TYPE, + MCP_APP_RESOURCE_MIME_TYPE, + ], + }, + }, + } as ClientCapabilities; + + expect(withAppElicitationClientCapabilities(capabilities)).toEqual({ + sampling: {}, + extensions: { + "example.test/other": { enabled: true }, + [MCP_APPS_EXTENSION_ID]: { + futureField: "preserved", + mimeTypes: ["text/example", MCP_APP_RESOURCE_MIME_TYPE], + elicitation: {}, + }, + }, + }); + }); + + it("replaces malformed MCP Apps extension fields safely", () => { + const capabilities = uncheckedCapability({ + extensions: { + [MCP_APPS_EXTENSION_ID]: ["not", "an", "object"], + }, + }); + + expect(withAppElicitationClientCapabilities(capabilities)).toEqual({ + extensions: { + [MCP_APPS_EXTENSION_ID]: { + mimeTypes: [MCP_APP_RESOURCE_MIME_TYPE], + elicitation: {}, + }, + }, + }); + }); + + it("requires the complete two-sided negotiation", () => { + expect(supportsAppElicitation(negotiatedClient, negotiatedServer)).toBe( + true, + ); + + const negativeCases: [ + ClientCapabilities | null | undefined, + ServerCapabilities | null | undefined, + ][] = [ + [undefined, negotiatedServer], + [negotiatedClient, null], + [{ extensions: negotiatedClient.extensions }, negotiatedServer], + [ + { + elicitation: { form: {} }, + extensions: { + [MCP_APPS_EXTENSION_ID]: { + mimeTypes: ["text/example"], + elicitation: {}, + }, + }, + }, + negotiatedServer, + ], + [ + { + elicitation: { form: {} }, + extensions: { + [MCP_APPS_EXTENSION_ID]: { + mimeTypes: [MCP_APP_RESOURCE_MIME_TYPE], + }, + }, + }, + negotiatedServer, + ], + [ + negotiatedClient, + { + extensions: { + [MCP_APPS_EXTENSION_ID]: {}, + }, + }, + ], + [ + uncheckedCapability({ + elicitation: { form: {} }, + extensions: { + [MCP_APPS_EXTENSION_ID]: [], + }, + }), + negotiatedServer, + ], + [ + negotiatedClient, + uncheckedCapability({ + extensions: { + [MCP_APPS_EXTENSION_ID]: [], + }, + }), + ], + ]; + + for (const [client, server] of negativeCases) { + expect(supportsAppElicitation(client, server)).toBe(false); + } + }); +}); + +describe("elicitation UI resource metadata", () => { + it("returns undefined when no app resource is requested", () => { + expect(getElicitationUiResourceUri(elicitationParams())).toBeUndefined(); + }); + + it("returns an absolute ui URI", () => { + expect( + getElicitationUiResourceUri( + elicitationParams("ui://examples/review-request"), + ), + ).toBe("ui://examples/review-request"); + }); + + it("rejects non-string, relative, and non-ui resource URIs", () => { + expect(() => getElicitationUiResourceUri(elicitationParams(42))).toThrow( + "Elicitation UI resourceUri must be a string", + ); + expect(() => + getElicitationUiResourceUri(elicitationParams("review-request")), + ).toThrow("Elicitation UI resourceUri must be an absolute ui:// URI"); + expect(() => + getElicitationUiResourceUri( + elicitationParams("https://example.com/review-request"), + ), + ).toThrow("Elicitation UI resourceUri must be an absolute ui:// URI"); + }); +}); + +describe("AppElicitationCancelledError", () => { + it("has a stable name and default or caller-provided message", () => { + const defaultError = new AppElicitationCancelledError(); + expect(defaultError).toBeInstanceOf(Error); + expect(defaultError.name).toBe("AppElicitationCancelledError"); + expect(defaultError.message).toBe("MCP App elicitation cancelled"); + + expect(new AppElicitationCancelledError("disconnected").message).toBe( + "disconnected", + ); + }); +}); diff --git a/clients/web/src/test/core/mcp/appRendererClient.test.ts b/clients/web/src/test/core/mcp/appRendererClient.test.ts new file mode 100644 index 000000000..c2ae16037 --- /dev/null +++ b/clients/web/src/test/core/mcp/appRendererClient.test.ts @@ -0,0 +1,44 @@ +import { describe, expect, it, vi } from "vitest"; +import { Client } from "@modelcontextprotocol/client"; +import { createAppRendererClientProxy } from "@inspector/core/mcp/appRendererClient.js"; + +describe("createAppRendererClientProxy", () => { + it("translates schema-first notification registration to a method string", () => { + const target = new Client({ name: "test", version: "1" }); + const setNotificationHandler = vi + .spyOn(target, "setNotificationHandler") + .mockImplementation(() => {}); + const proxy = createAppRendererClientProxy(target); + const handler = vi.fn(); + + Reflect.apply(proxy.setNotificationHandler, proxy, [ + { shape: { method: { value: "notifications/tools/list_changed" } } }, + handler, + ]); + + expect(setNotificationHandler).toHaveBeenCalledWith( + "notifications/tools/list_changed", + handler, + ); + }); + + it("preserves native method-string registration and other properties", () => { + const target = new Client({ name: "test", version: "1" }); + const setNotificationHandler = vi + .spyOn(target, "setNotificationHandler") + .mockImplementation(() => {}); + const proxy = createAppRendererClientProxy(target); + const handler = vi.fn(); + + proxy.setNotificationHandler( + "notifications/resources/list_changed", + handler, + ); + + expect(setNotificationHandler).toHaveBeenCalledWith( + "notifications/resources/list_changed", + handler, + ); + expect(proxy.getServerCapabilities()).toBeUndefined(); + }); +}); diff --git a/clients/web/src/test/core/mcp/inspectorClientAppElicitation.test.ts b/clients/web/src/test/core/mcp/inspectorClientAppElicitation.test.ts new file mode 100644 index 000000000..76b167b08 --- /dev/null +++ b/clients/web/src/test/core/mcp/inspectorClientAppElicitation.test.ts @@ -0,0 +1,333 @@ +import { describe, expect, it, vi } from "vitest"; +import type { + ElicitRequest, + JSONRPCMessage, + Transport, +} from "@modelcontextprotocol/client"; +import { InspectorClient } from "@inspector/core/mcp/inspectorClient.js"; +import type { + AppElicitationBridgeSession, + AppElicitationHost, +} from "@inspector/core/mcp/appElicitation.js"; +import { AppElicitationCancelledError } from "@inspector/core/mcp/appElicitation.js"; + +const SERVER_CAPABILITIES = { + extensions: { + "io.modelcontextprotocol/ui": { elicitation: {} }, + }, +}; + +const appRequest: ElicitRequest = { + method: "elicitation/create", + params: { + mode: "form", + message: "Approve the operation", + requestedSchema: { + type: "object", + properties: { approved: { type: "boolean" } }, + required: ["approved"], + }, + _meta: { ui: { resourceUri: "ui://demo/confirmation" } }, + }, +}; + +class PeerRequestTransport implements Transport { + onmessage?: (message: JSONRPCMessage) => void; + onclose?: () => void; + onerror?: (error: Error) => void; + initializeCapabilities: unknown; + private readonly responseWaiters = new Map< + number, + (message: JSONRPCMessage) => void + >(); + private readonly serverCapabilities: Record; + + constructor( + serverCapabilities: Record = SERVER_CAPABILITIES, + ) { + this.serverCapabilities = serverCapabilities; + } + + async start(): Promise {} + async close(): Promise {} + + async send(message: JSONRPCMessage): Promise { + if ( + "method" in message && + message.method === "initialize" && + "id" in message + ) { + this.initializeCapabilities = ( + message.params as { capabilities: unknown } + ).capabilities; + this.onmessage?.({ + jsonrpc: "2.0", + id: message.id, + result: { + protocolVersion: "2025-11-25", + capabilities: this.serverCapabilities, + serverInfo: { name: "app-server", version: "1" }, + }, + }); + return; + } + if ( + "id" in message && + typeof message.id === "number" && + ("result" in message || "error" in message) + ) { + this.responseWaiters.get(message.id)?.(message); + this.responseWaiters.delete(message.id); + } + } + + inject(request: ElicitRequest, id = 42): Promise { + const response = new Promise((resolve) => { + this.responseWaiters.set(id, resolve); + }); + this.onmessage?.({ jsonrpc: "2.0", id, ...request }); + return response; + } +} + +function createSession( + result: unknown, + appCapabilities: ReturnType< + AppElicitationBridgeSession["getAppCapabilities"] + > = { elicitation: {} }, +): { + session: AppElicitationBridgeSession; + requestElicitation: ReturnType; + close: ReturnType; +} { + const requestElicitation = vi.fn().mockResolvedValue(result); + const close = vi.fn().mockResolvedValue(undefined); + return { + session: { + getAppCapabilities: () => appCapabilities, + requestElicitation, + close, + }, + requestElicitation, + close, + }; +} + +async function createConnectedClient( + host: AppElicitationHost | undefined, + onError = vi.fn(), + serverCapabilities: Record = SERVER_CAPABILITIES, + elicit: { form?: boolean; url?: boolean } = { form: true, url: true }, +) { + const transport = new PeerRequestTransport(serverCapabilities); + const client = new InspectorClient( + { type: "stdio", command: "noop", args: [] }, + { + environment: { transport: () => ({ transport }) }, + elicit, + ...(host ? { appElicitation: { host, onError } } : {}), + }, + ); + await client.connect(); + return { client, transport, onError }; +} + +describe("InspectorClient app-rendered elicitation", () => { + it("does not advertise app-rendered elicitation without a renderer host", async () => { + const { client } = await createConnectedClient(undefined); + + expect( + client.getClientCapabilities().extensions?.["io.modelcontextprotocol/ui"], + ).not.toHaveProperty("elicitation"); + await client.disconnect(); + }); + + it("does not advertise app-rendered elicitation when form mode is disabled", async () => { + const host = { open: vi.fn() }; + const { client } = await createConnectedClient( + host, + vi.fn(), + SERVER_CAPABILITIES, + { url: true }, + ); + + expect( + client.getClientCapabilities().extensions?.["io.modelcontextprotocol/ui"], + ).not.toHaveProperty("elicitation"); + await client.disconnect(); + }); + + it("advertises the MCP Apps capability and forwards the request params to the bound app", async () => { + const app = createSession({ + action: "accept", + content: { approved: true }, + }); + const host = { open: vi.fn().mockResolvedValue(app.session) }; + const { client, transport } = await createConnectedClient(host); + + const response = await transport.inject(appRequest); + + expect(client.getClientCapabilities()).toMatchObject({ + extensions: { + "io.modelcontextprotocol/ui": { + mimeTypes: ["text/html;profile=mcp-app"], + elicitation: {}, + }, + }, + }); + expect(transport.initializeCapabilities).toMatchObject({ + elicitation: { form: {} }, + extensions: { + "io.modelcontextprotocol/ui": { + mimeTypes: ["text/html;profile=mcp-app"], + elicitation: {}, + }, + }, + }); + expect(host.open).toHaveBeenCalledWith( + expect.objectContaining({ + request: appRequest, + resourceUri: "ui://demo/confirmation", + }), + ); + expect(app.requestElicitation).toHaveBeenCalledWith( + appRequest.params, + expect.objectContaining({ signal: expect.any(AbortSignal) }), + ); + expect(response).toMatchObject({ + result: { action: "accept", content: { approved: true } }, + }); + expect(app.close).toHaveBeenCalledOnce(); + await client.disconnect(); + }); + + it.each(["decline", "cancel"] as const)( + "returns an app %s result and cleans up the session", + async (action) => { + const app = createSession({ action }); + const host = { open: vi.fn().mockResolvedValue(app.session) }; + const { client, transport } = await createConnectedClient(host); + + await expect(transport.inject(appRequest)).resolves.toMatchObject({ + result: { action }, + }); + expect(app.close).toHaveBeenCalledOnce(); + await client.disconnect(); + }, + ); + + it("falls back to the unchanged native request when app result validation fails", async () => { + const app = createSession({ + action: "accept", + content: { approved: "not-a-boolean" }, + }); + const host = { open: vi.fn().mockResolvedValue(app.session) }; + const { client, transport, onError } = await createConnectedClient(host); + const response = transport.inject(appRequest); + + await vi.waitFor(() => { + expect(client.getPendingElicitations()).toHaveLength(1); + }); + const pending = client.getPendingElicitations()[0]; + expect(pending?.request).toEqual(appRequest); + await pending?.respond({ + action: "accept", + content: { approved: true }, + }); + + await expect(response).resolves.toMatchObject({ + result: { action: "accept", content: { approved: true } }, + }); + expect(onError).toHaveBeenCalledOnce(); + expect(app.close).toHaveBeenCalledOnce(); + await client.disconnect(); + }); + + it("falls back to the unchanged native request when a base App omits elicitation capability", async () => { + const app = createSession( + { action: "accept", content: { approved: true } }, + {}, + ); + const host = { open: vi.fn().mockResolvedValue(app.session) }; + const { client, transport, onError } = await createConnectedClient(host); + const response = transport.inject(appRequest); + + await vi.waitFor(() => { + expect(client.getPendingElicitations()).toHaveLength(1); + }); + expect(app.requestElicitation).not.toHaveBeenCalled(); + await client.getPendingElicitations()[0]?.respond({ action: "decline" }); + + await expect(response).resolves.toMatchObject({ + result: { action: "decline" }, + }); + expect(onError).toHaveBeenCalledOnce(); + expect(app.close).toHaveBeenCalledOnce(); + await client.disconnect(); + }); + + it("uses native elicitation without opening an app for an invalid resource URI", async () => { + const host = { open: vi.fn() }; + const { client, transport } = await createConnectedClient(host); + const nativeRequest: ElicitRequest = { + ...appRequest, + params: { + ...appRequest.params, + _meta: { ui: { resourceUri: "https://example.com/not-an-app" } }, + }, + }; + const response = transport.inject(nativeRequest); + + await vi.waitFor(() => { + expect(client.getPendingElicitations()).toHaveLength(1); + }); + expect(host.open).not.toHaveBeenCalled(); + await client.getPendingElicitations()[0]?.respond({ action: "decline" }); + + await expect(response).resolves.toMatchObject({ + result: { action: "decline" }, + }); + await client.disconnect(); + }); + + it("uses native elicitation when the server did not negotiate the MCP Apps capability", async () => { + const app = createSession({ + action: "accept", + content: { approved: true }, + }); + const host = { open: vi.fn().mockResolvedValue(app.session) }; + const { client, transport } = await createConnectedClient( + host, + vi.fn(), + {}, + ); + const response = transport.inject(appRequest); + + await vi.waitFor(() => { + expect(client.getPendingElicitations()).toHaveLength(1); + }); + expect(host.open).not.toHaveBeenCalled(); + await client.getPendingElicitations()[0]?.respond({ action: "decline" }); + + await expect(response).resolves.toMatchObject({ + result: { action: "decline" }, + }); + await client.disconnect(); + }); + + it("does not create a native fallback when the bound app is cancelled by teardown", async () => { + const host = { + open: vi + .fn() + .mockRejectedValue(new AppElicitationCancelledError("disconnected")), + }; + const { client, transport, onError } = await createConnectedClient(host); + + await expect(transport.inject(appRequest)).resolves.toMatchObject({ + error: expect.objectContaining({ message: "disconnected" }), + }); + expect(client.getPendingElicitations()).toHaveLength(0); + expect(onError).not.toHaveBeenCalled(); + await client.disconnect(); + }); +}); diff --git a/clients/web/src/test/integration/mcp/inspectorClient-modern-era.test.ts b/clients/web/src/test/integration/mcp/inspectorClient-modern-era.test.ts index 6251340d5..c0d7588f2 100644 --- a/clients/web/src/test/integration/mcp/inspectorClient-modern-era.test.ts +++ b/clients/web/src/test/integration/mcp/inspectorClient-modern-era.test.ts @@ -1,4 +1,4 @@ -import { describe, it, expect, afterEach } from "vitest"; +import { describe, it, expect, afterEach, vi } from "vitest"; import { InspectorClient } from "@inspector/core/mcp/inspectorClient.js"; import { createTransportNode } from "@inspector/core/mcp/node/transport.js"; import { ToolCallCancelledError } from "@inspector/core/mcp/toolCallCancelledError.js"; @@ -13,6 +13,9 @@ import { createEchoTool, createSendNotificationTool, createMrtrTool, + createMrtrAppTool, + createMcpAppElicitationDemoResource, + MCP_APP_ELICITATION_DEMO_URI, createMrtrMultiRoundTool, createMrtrRootsTool, createMrtrSamplingTool, @@ -25,7 +28,9 @@ import type { JSONRPCRequest, } from "@modelcontextprotocol/client"; import { LOG_LEVEL_META_KEY } from "@modelcontextprotocol/client"; +import { ReadResourceResultSchema } from "@modelcontextprotocol/core"; import type { MessageEntry } from "@inspector/core/mcp/types.js"; +import type { InspectorClientOptions } from "@inspector/core/mcp/types.js"; /** * Live coverage of the modern (2026-07-28) connection path (#1700). The bundled @@ -74,12 +79,14 @@ describe("modern-era negotiation (2026-07-28)", () => { async function connectWithEra( url: string, era: "legacy" | "auto" | "modern", + appElicitation?: InspectorClientOptions["appElicitation"], ): Promise { const connected = new InspectorClient( { type: "streamable-http", url }, { environment: { transport: createTransportNode }, versionNegotiation: eraToVersionNegotiation(era), + ...(appElicitation ? { appElicitation } : {}), }, ); await connected.connect(); @@ -229,6 +236,134 @@ describe("modern-era negotiation (2026-07-28)", () => { expect(retryParams.requestState).toMatch(/^mrtr:deploy:/); }); + it.each([ + { + action: "accept" as const, + response: { action: "accept" as const, content: { confirm: true } }, + }, + { action: "decline" as const, response: { action: "decline" as const } }, + { action: "cancel" as const, response: { action: "cancel" as const } }, + ])( + "fulfils a resource-bound MCP App elicitation with $action and preserves the MRTR retry", + async ({ response }) => { + const started = createTestServerHttp({ + serverInfo: createTestServerInfo( + "modern-app-elicitation-test", + "1.0.0", + ), + tools: [createMrtrAppTool()], + resources: [createMcpAppElicitationDemoResource()], + extensions: { + "io.modelcontextprotocol/ui": { elicitation: {} }, + }, + modern: {}, + }); + await started.start(); + server = started; + + let loadedResourceText = ""; + const requestElicitation = vi.fn().mockResolvedValue(response); + const close = vi.fn().mockResolvedValue(undefined); + const open = vi.fn( + async ({ + client: sourceClient, + resourceUri, + }: Parameters< + NonNullable["host"]["open"] + >[0]) => { + const resource = await sourceClient.request( + { method: "resources/read", params: { uri: resourceUri } }, + ReadResourceResultSchema, + ); + loadedResourceText = + resource.contents[0] && "text" in resource.contents[0] + ? String(resource.contents[0].text) + : ""; + return { + getAppCapabilities: () => ({ elicitation: {} }), + requestElicitation, + close, + }; + }, + ); + const connected = await connectWithEra(started.url, "modern", { + host: { open }, + }); + const toolCallFrames = collectToolCallRequests(connected); + const nativePending = vi.fn(); + connected.addEventListener("newPendingElicitation", nativePending); + + const { tools } = await connected.listTools(); + const mrtr = tools.find((tool) => tool.name === "mrtr_confirm"); + const originalArgs = { action: "publish demo" }; + const result = await connected.callTool(mrtr!, originalArgs); + + expect(result.success).toBe(true); + expect(nativePending).not.toHaveBeenCalled(); + expect(loadedResourceText).toContain("MCP App elicitation demo"); + expect(open).toHaveBeenCalledWith( + expect.objectContaining({ + resourceUri: MCP_APP_ELICITATION_DEMO_URI, + request: { + method: "elicitation/create", + params: expect.objectContaining({ + message: "Confirm: publish demo?", + requestedSchema: { + type: "object", + properties: { + confirm: { type: "boolean", title: "Confirm" }, + }, + required: ["confirm"], + }, + _meta: { + ui: { resourceUri: MCP_APP_ELICITATION_DEMO_URI }, + }, + }), + }, + }), + ); + expect(requestElicitation).toHaveBeenCalledWith( + expect.objectContaining({ + _meta: { + ui: { resourceUri: MCP_APP_ELICITATION_DEMO_URI }, + }, + }), + expect.objectContaining({ signal: expect.any(AbortSignal) }), + ); + expect(close).toHaveBeenCalledOnce(); + expect(toolCallFrames).toHaveLength(2); + const [original, retry] = toolCallFrames; + const originalParams = original.params as { + arguments?: Record; + _meta?: Record; + }; + const retryParams = retry.params as { + arguments?: Record; + inputResponses?: Record; + requestState?: unknown; + _meta?: Record; + }; + expect(originalParams.arguments).toEqual(originalArgs); + expect(retryParams.arguments).toEqual(originalArgs); + expect(retry.id).not.toBe(original.id); + expect(retryParams.requestState).toMatch(/^mrtr:publish demo:/); + expect(retryParams.inputResponses?.confirm).toEqual(response); + for (const params of [originalParams, retryParams]) { + expect( + params._meta?.["io.modelcontextprotocol/clientCapabilities"], + ).toMatchObject({ + elicitation: { form: {} }, + extensions: { + "io.modelcontextprotocol/ui": { + mimeTypes: ["text/html;profile=mcp-app"], + elicitation: {}, + }, + }, + }); + } + }, + ); + it("drives a multi-round MRTR (two embedded elicitations in sequence, then completes)", async () => { const started = await startMrtrServer(createMrtrMultiRoundTool()); const connected = await connectWithEra(started.url, "modern"); diff --git a/core/mcp/appElicitation.ts b/core/mcp/appElicitation.ts new file mode 100644 index 000000000..65afbef8f --- /dev/null +++ b/core/mcp/appElicitation.ts @@ -0,0 +1,140 @@ +import type { + Client, + ClientCapabilities, + ElicitRequest, + ElicitResult, + ServerCapabilities, +} from "@modelcontextprotocol/client"; + +export const MCP_APPS_EXTENSION_ID = "io.modelcontextprotocol/ui"; +export const MCP_APP_RESOURCE_MIME_TYPE = "text/html;profile=mcp-app"; + +interface McpAppsClientCapabilities { + mimeTypes?: string[]; + elicitation?: Record; +} + +interface McpAppsServerCapabilities { + elicitation?: Record; +} + +interface McpAppCapabilities { + elicitation?: Record; +} + +export interface AppElicitationBridgeSession { + getAppCapabilities(): McpAppCapabilities | undefined; + requestElicitation( + params: ElicitRequest["params"], + options?: { signal?: AbortSignal }, + ): Promise; + close(): Promise; +} + +export interface OpenAppElicitationOptions { + client: Client; + request: ElicitRequest; + resourceUri: string; + signal: AbortSignal; +} + +export interface AppElicitationHost { + open( + options: OpenAppElicitationOptions, + ): Promise; +} + +function asRecord(value: unknown): Record | undefined { + return typeof value === "object" && value !== null && !Array.isArray(value) + ? (value as Record) + : undefined; +} + +/** + * Advertise the client half of the app-rendered elicitation contract from + * ext-apps#733. This is enabled only by clients with a real MCP App renderer; + * the CLI and TUI never pass an app host and therefore do not advertise it. + */ +export function withAppElicitationClientCapabilities( + capabilities: ClientCapabilities, +): ClientCapabilities { + const extensions = capabilities.extensions ?? {}; + const existingUi = asRecord(extensions[MCP_APPS_EXTENSION_ID]) ?? {}; + const existingMimeTypes = Array.isArray(existingUi.mimeTypes) + ? existingUi.mimeTypes.filter( + (value): value is string => typeof value === "string", + ) + : []; + + return { + ...capabilities, + extensions: { + ...extensions, + [MCP_APPS_EXTENSION_ID]: { + ...existingUi, + mimeTypes: [ + ...new Set([...existingMimeTypes, MCP_APP_RESOURCE_MIME_TYPE]), + ], + elicitation: {}, + }, + }, + }; +} + +/** + * Require both peers to opt into app-rendered form elicitation through the + * existing MCP Apps extension. A MIME-type match alone is not sufficient. + */ +export function supportsAppElicitation( + clientCapabilities: ClientCapabilities | null | undefined, + serverCapabilities: ServerCapabilities | null | undefined, +): boolean { + const clientUi = asRecord( + clientCapabilities?.extensions?.[MCP_APPS_EXTENSION_ID], + ) as McpAppsClientCapabilities | undefined; + const serverUi = asRecord( + serverCapabilities?.extensions?.[MCP_APPS_EXTENSION_ID], + ) as McpAppsServerCapabilities | undefined; + + return Boolean( + clientCapabilities?.elicitation?.form && + clientUi?.mimeTypes?.includes(MCP_APP_RESOURCE_MIME_TYPE) && + clientUi.elicitation && + serverUi?.elicitation, + ); +} + +/** + * Read the absolute ui:// resource associated with a standard elicitation. + * Missing metadata is not an error; malformed metadata is, so the caller can + * report it before falling back to the unchanged native form. + */ +export function getElicitationUiResourceUri( + params: ElicitRequest["params"], +): string | undefined { + const ui = asRecord(params._meta?.ui); + const resourceUri = ui?.resourceUri; + if (resourceUri === undefined) return undefined; + if (typeof resourceUri !== "string") { + throw new Error("Elicitation UI resourceUri must be a string"); + } + + let parsed: URL; + try { + parsed = new URL(resourceUri); + } catch { + throw new Error("Elicitation UI resourceUri must be an absolute ui:// URI"); + } + if (parsed.protocol !== "ui:") { + throw new Error("Elicitation UI resourceUri must be an absolute ui:// URI"); + } + return resourceUri; +} + +/** Cancellation used when the Inspector tears down an app-bound request. */ +export class AppElicitationCancelledError extends Error { + constructor(message = "MCP App elicitation cancelled") { + super(message); + this.name = "AppElicitationCancelledError"; + } +} diff --git a/core/mcp/appRendererClient.ts b/core/mcp/appRendererClient.ts new file mode 100644 index 000000000..4136cd591 --- /dev/null +++ b/core/mcp/appRendererClient.ts @@ -0,0 +1,42 @@ +import type { Client } from "@modelcontextprotocol/client"; + +/** + * Extract the method literal from an MCP notification Zod schema. ext-apps + * still uses the SDK v1 schema-first notification registration API while the + * Inspector uses the SDK v2 method-string API. + */ +function notificationMethodFromSchema(schema: unknown): string | undefined { + if (schema !== null && typeof schema === "object") { + const literal = (schema as { shape?: { method?: { value?: unknown } } }) + .shape?.method?.value; + if (typeof literal === "string") return literal; + } + return undefined; +} + +/** + * Create the connection-preserving client adapter consumed by AppBridge. + * Every operation still targets the supplied SDK client; only the legacy + * schema-first notification registration call is translated. + */ +export function createAppRendererClientProxy(client: Client): Client { + return new Proxy(client, { + get(target, prop, receiver) { + const value = Reflect.get(target, prop, receiver); + if (prop === "setNotificationHandler" && typeof value === "function") { + return (schemaOrMethod: unknown, ...rest: unknown[]) => { + const method = + typeof schemaOrMethod === "string" + ? schemaOrMethod + : (notificationMethodFromSchema(schemaOrMethod) ?? + schemaOrMethod); + return (value as (...args: unknown[]) => unknown).apply(target, [ + method, + ...rest, + ]); + }; + } + return value; + }, + }); +} diff --git a/core/mcp/inspectorClient.ts b/core/mcp/inspectorClient.ts index 527219ad9..41b53b450 100644 --- a/core/mcp/inspectorClient.ts +++ b/core/mcp/inspectorClient.ts @@ -1,4 +1,11 @@ import { Client } from "@modelcontextprotocol/client"; +import { + AppElicitationCancelledError, + getElicitationUiResourceUri, + supportsAppElicitation, + withAppElicitationClientCapabilities, + type AppElicitationBridgeSession, +} from "./appElicitation.js"; import type { MCPServerConfig, StderrLogEntry, @@ -76,6 +83,7 @@ import type { CreateMessageResult, CreateTaskResult, ElicitRequest, + ElicitRequestFormParams, ElicitResult, ElicitRequestURLParams, CallToolResult, @@ -105,6 +113,7 @@ import type { StandardSchemaV1, McpSubscription, SubscriptionFilter, + JsonSchemaType, } from "@modelcontextprotocol/client"; import { ProtocolError, ProtocolErrorCode } from "@modelcontextprotocol/client"; import { @@ -156,6 +165,7 @@ import { ListResourcesResultSchema, ListResourceTemplatesResultSchema, ListPromptsResultSchema, + ElicitResultSchema, } from "@modelcontextprotocol/core"; import type { ClientResult } from "@modelcontextprotocol/client"; import { AjvJsonSchemaValidator } from "@modelcontextprotocol/client/validators/ajv"; @@ -173,6 +183,7 @@ import { } from "./inspectorClientEventTarget.js"; import { SamplingCreateMessage } from "./samplingCreateMessage.js"; import { ElicitationCreateMessage } from "./elicitationCreateMessage.js"; +import { createAppRendererClientProxy } from "./appRendererClient.js"; import { getUrlElicitationsFromError, UrlElicitationLoopError, @@ -290,24 +301,6 @@ async function closeSubscriptionBestEffort( } } -/** - * Extract the method literal from an MCP notification Zod schema (e.g. - * `ToolListChangedNotificationSchema`), or `undefined` if the shape isn't - * recognized. Used by the App-renderer client proxy to translate the SDK-v1 - * schema-first `setNotificationHandler` API — which `@modelcontextprotocol/ext-apps` - * still uses — into SDK v2's method-string form. Reads the `method` literal off - * the notification schema's `shape` (the shape both the v1 SDK and v2 core - * schemas expose). - */ -function notificationMethodFromSchema(schema: unknown): string | undefined { - if (schema !== null && typeof schema === "object") { - const literal = (schema as { shape?: { method?: { value?: unknown } } }) - .shape?.method?.value; - if (typeof literal === "string") return literal; - } - return undefined; -} - /** * The descriptor for a single tools/call, threaded through the retry loop and * each attempt. Bundled into one object so `callToolWithRetries`/`attemptToolCall` @@ -522,6 +515,7 @@ export class InspectorClient extends InspectorClientEventTarget { // Per-extension advertise overrides (#1738); undefined key falls back to the // registry default in ADVERTISABLE_EXTENSIONS. private readonly advertisedExtensions?: Record; + private readonly appElicitation?: InspectorClientOptions["appElicitation"]; private receiverTaskTtlMs: number | (() => number); private receiverTaskRecords: Map = new Map(); // OAuth support (config owned by oauthManager; client delegates and uses !!oauthManager for "is OAuth configured") @@ -576,6 +570,7 @@ export class InspectorClient extends InspectorClientEventTarget { this.elicit = options.elicit ?? true; this.receiverTasks = options.receiverTasks ?? false; this.advertisedExtensions = options.advertisedExtensions; + this.appElicitation = options.appElicitation; this.receiverTaskTtlMs = options.receiverTaskTtlMs ?? 60_000; this.progress = options.progress ?? true; this.resetTimeoutOnProgress = options.resetTimeoutOnProgress ?? true; @@ -666,7 +661,7 @@ export class InspectorClient extends InspectorClientEventTarget { // at construction time, so gating here is impossible anyway. inputRequired: { autoFulfill: false }, }; - const capabilities: ClientCapabilities = {}; + let capabilities: ClientCapabilities = {}; if (this.sample) { capabilities.sampling = {}; } @@ -736,8 +731,13 @@ export class InspectorClient extends InspectorClientEventTarget { ...advertisedExtensions, }; } + if (this.appElicitation && capabilities.elicitation?.form) { + capabilities = withAppElicitationClientCapabilities(capabilities); + } clientOptions.capabilities = capabilities; - this.clientCapabilities = capabilities; + // Keep an independent effective-capability snapshot for legacy initialize, + // UI display, and modern 2026-07-28 per-request envelopes. + this.clientCapabilities = structuredClone(capabilities); // Read off the built capability object rather than re-deriving from // `options.roots`: the gate and the advertisement must agree, and two // independent derivations of the same fact can drift (a `readonly` field is @@ -1316,36 +1316,33 @@ export class InspectorClient extends InspectorClientEventTarget { initialStatus: "input_required", statusMessage: "Awaiting user input", }); - void (async () => { - const elicitationRequest = new ElicitationCreateMessage( - request, - (result) => { - record.resolvePayload(result); - const now = new Date().toISOString(); - const updated: Task = { - ...record.task, - status: "completed", - lastUpdatedAt: now, - }; - record.task = updated; - this.upsertReceiverTask(updated); - }, - (id) => this.removePendingElicitation(id), - (error) => { - record.rejectPayload(error); - const now = new Date().toISOString(); - const updated: Task = { - ...record.task, - status: "failed", - lastUpdatedAt: now, - statusMessage: error.message, - }; - record.task = updated; - this.upsertReceiverTask(updated); - }, - ); - this.addPendingElicitation(elicitationRequest); - })(); + void this.handleElicitationRequest(request, "server-request").then( + (result) => { + record.resolvePayload(result); + const now = new Date().toISOString(); + const updated: Task = { + ...record.task, + status: "completed", + lastUpdatedAt: now, + }; + record.task = updated; + this.upsertReceiverTask(updated); + }, + (error: unknown) => { + const reason = + error instanceof Error ? error : new Error(String(error)); + record.rejectPayload(reason); + const now = new Date().toISOString(); + const updated: Task = { + ...record.task, + status: "failed", + lastUpdatedAt: now, + statusMessage: reason.message, + }; + record.task = updated; + this.upsertReceiverTask(updated); + }, + ); // Task-augmented (2025-11-25) response — see the sampling handler // above. Reply with a `CreateTaskResult` (`{ task }`), routed around // the v2 Client's result validation by @@ -1357,7 +1354,7 @@ export class InspectorClient extends InspectorClientEventTarget { const taskResult: CreateTaskResult = { task: record.task }; return Promise.resolve(taskResult as unknown as ElicitResult); } - return this.enqueuePendingElicitation(request, "server-request"); + return this.handleElicitationRequest(request, "server-request"); }; this.client.setRequestHandler("elicitation/create", elicitHandler); // Registration, like the `setRequestHandler` above it — and the whole @@ -2176,35 +2173,7 @@ export class InspectorClient extends InspectorClientEventTarget { if (!this.client || this.status !== "connected") return null; if (this.appRendererClientProxy !== null) return this.appRendererClientProxy; - const target = this.client; - this.appRendererClientProxy = new Proxy(this.client, { - get(proxyTarget, prop, receiver) { - const value = Reflect.get(proxyTarget, prop, receiver); - if (prop === "setNotificationHandler" && typeof value === "function") { - return (schemaOrMethod: unknown, ...rest: unknown[]) => { - // `@modelcontextprotocol/ext-apps` still peers on SDK v1 and - // subscribes to list-changed notifications with the v1 schema-first - // API `setNotificationHandler(NotificationSchema, handler)`. SDK v2 - // requires a method STRING as the first argument and throws - // "'[object Object]' is not a spec notification method" on a schema — - // which broke App rendering during the initial connect handshake. - // Translate a schema-first call to the method-string form; native - // string-first calls (ours) pass through untouched. Remove when - // ext-apps#702 ships a v2 peer. - const method = - typeof schemaOrMethod === "string" - ? schemaOrMethod - : (notificationMethodFromSchema(schemaOrMethod) ?? - schemaOrMethod); - return (value as (...a: unknown[]) => unknown).apply(target, [ - method, - ...rest, - ]); - }; - } - return value; - }, - }) as AppRendererClient; + this.appRendererClientProxy = createAppRendererClientProxy(this.client); return this.appRendererClientProxy; } @@ -2796,6 +2765,122 @@ export class InspectorClient extends InspectorClientEventTarget { return responses; } + /** + * Route one elicitation through a resource-bound MCP App when SEP-3118 + * metadata and a web host are available. The unchanged request is used for + * both the app call and native fallback. + */ + private async handleElicitationRequest( + request: ElicitRequest, + origin: PendingRequestOrigin, + signal?: AbortSignal, + ): Promise { + const appElicitation = this.appElicitation; + const formParams = + "requestedSchema" in request.params ? request.params : undefined; + if ( + !appElicitation || + !formParams || + !this.client || + !supportsAppElicitation(this.clientCapabilities, this.capabilities) + ) { + return this.enqueuePendingElicitation(request, origin, signal); + } + + let resourceUri: string | undefined; + try { + resourceUri = getElicitationUiResourceUri(formParams); + } catch (error) { + this.reportAppElicitationError(error, request); + return this.enqueuePendingElicitation(request, origin, signal); + } + if (!resourceUri) { + return this.enqueuePendingElicitation(request, origin, signal); + } + + const appSignal = signal ?? new AbortController().signal; + let session: AppElicitationBridgeSession | undefined; + try { + session = await appElicitation.host.open({ + client: this.client, + request, + resourceUri, + signal: appSignal, + }); + const elicitation = session.getAppCapabilities()?.elicitation; + if ( + elicitation === null || + typeof elicitation !== "object" || + Array.isArray(elicitation) + ) { + throw new TypeError( + "The bound MCP App did not negotiate appCapabilities.elicitation", + ); + } + const result = await session.requestElicitation(formParams, { + signal: appSignal, + }); + return this.validateAppElicitationResult( + result, + formParams.requestedSchema, + ); + } catch (error) { + if (appSignal.aborted || error instanceof AppElicitationCancelledError) { + throw appSignal.aborted ? appSignal.reason : error; + } + this.reportAppElicitationError(error, request); + return this.enqueuePendingElicitation(request, origin, signal); + } finally { + try { + await session?.close(); + } catch (error) { + this.reportAppElicitationError(error, request); + } + } + } + + /** + * Validate the ordinary ElicitResult returned by an MCP App. The protocol + * shape is checked first; accepted content is then checked against the exact + * requested form schema before it is returned directly on a legacy request + * or placed into the matching MRTR `inputResponses` entry. + */ + private validateAppElicitationResult( + result: unknown, + requestedSchema: ElicitRequestFormParams["requestedSchema"], + ): ElicitResult { + const parsed = ElicitResultSchema.parse(result); + if (parsed.action !== "accept") return parsed; + + this.outputValidator ??= new AjvJsonSchemaValidator(); + const validate = this.outputValidator.getValidator( + requestedSchema as JsonSchemaType, + ); + const validation = validate(parsed.content); + if (!validation.valid) { + throw new Error( + validation.errorMessage ?? + "MCP App returned content that does not match requestedSchema", + ); + } + return parsed; + } + + private reportAppElicitationError( + error: unknown, + request: ElicitRequest, + ): void { + this.logger.error({ error }, "MCP App elicitation failed"); + try { + this.appElicitation?.onError?.(error, request); + } catch (callbackError) { + this.logger.error( + { error: callbackError }, + "MCP App elicitation error callback failed", + ); + } + } + /** * Fulfil a single embedded input request. `roots/list` is auto-answered from * the configured roots (consistent with the legacy `roots/list` handler — no @@ -2813,7 +2898,7 @@ export class InspectorClient extends InspectorClientEventTarget { case "roots/list": return { roots: this.roots ?? [] }; case "elicitation/create": - return this.enqueuePendingElicitation(request, origin, signal); + return this.handleElicitationRequest(request, origin, signal); case "sampling/createMessage": return this.enqueuePendingSample(request, origin, signal); /* v8 ignore next 6 -- defensive: an SDK server rejects an unknown embedded diff --git a/core/mcp/types.ts b/core/mcp/types.ts index b95c65a73..f541b1dfb 100644 --- a/core/mcp/types.ts +++ b/core/mcp/types.ts @@ -2,6 +2,7 @@ import type { CallToolResult, ClientNotification, ClientRequest, + ElicitRequest, GetPromptResult, Implementation, JSONRPCErrorResponse, @@ -33,6 +34,7 @@ import type { RedirectUrlProvider, } from "../auth/providers.js"; import type { OAuthStorage } from "../auth/storage.js"; +import type { AppElicitationHost } from "./appElicitation.js"; // Stdio transport config export interface StdioServerConfig { @@ -931,6 +933,17 @@ export interface InspectorClientOptions { url?: boolean; }; + /** + * Optional SEP-3118 MCP Apps renderer. When present, app-bound form + * elicitations are offered to this host first and fall back to the ordinary + * native elicitation queue on any app load, negotiation, bridge, or + * validation failure. + */ + appElicitation?: { + host: AppElicitationHost; + onError?: (error: unknown, request: ElicitRequest) => void; + }; + /** * Initial roots to configure. If provided (even if empty array), the client will * advertise roots capability and handle roots/list requests from the server. diff --git a/package-lock.json b/package-lock.json index b7f5a1ac7..6eb52dc44 100644 --- a/package-lock.json +++ b/package-lock.json @@ -13,7 +13,7 @@ "@hono/node-server": "^2.0.12", "@modelcontextprotocol/client": "2.0.0-beta.5", "@modelcontextprotocol/core": "2.0.0-beta.5", - "@modelcontextprotocol/ext-apps": "^1.7.4", + "@modelcontextprotocol/ext-apps": "github:krubenok/ext-apps#89ab2bc", "@modelcontextprotocol/server": "2.0.0-beta.5", "@modelcontextprotocol/server-legacy": "2.0.0-beta.5", "@napi-rs/keyring": "^1.3.0", @@ -383,9 +383,8 @@ } }, "node_modules/@modelcontextprotocol/ext-apps": { - "version": "1.7.4", - "resolved": "https://registry.npmjs.org/@modelcontextprotocol/ext-apps/-/ext-apps-1.7.4.tgz", - "integrity": "sha512-QQqysE549cf/Y0VabBmAACXhj92EhB3t8yVct2BHbkWiPTFA1S91EqTVjYXXcZEefXU0pmHcdObhsNMcomJIOQ==", + "version": "1.7.5", + "resolved": "git+ssh://git@github.com/krubenok/ext-apps.git#89ab2bcbf066ca21f1bd38cb115949f857950b7a", "license": "MIT", "workspaces": [ "examples/*" diff --git a/package.json b/package.json index 2adc0d5f2..9b149fa13 100644 --- a/package.json +++ b/package.json @@ -77,7 +77,7 @@ "@hono/node-server": "^2.0.12", "@modelcontextprotocol/client": "2.0.0-beta.5", "@modelcontextprotocol/core": "2.0.0-beta.5", - "@modelcontextprotocol/ext-apps": "^1.7.4", + "@modelcontextprotocol/ext-apps": "github:krubenok/ext-apps#89ab2bc", "@modelcontextprotocol/server": "2.0.0-beta.5", "@modelcontextprotocol/server-legacy": "2.0.0-beta.5", "@napi-rs/keyring": "^1.3.0", diff --git a/test-servers/configs/modern-app-elicitation-http.json b/test-servers/configs/modern-app-elicitation-http.json new file mode 100644 index 000000000..dabb71f1a --- /dev/null +++ b/test-servers/configs/modern-app-elicitation-http.json @@ -0,0 +1,18 @@ +{ + "serverInfo": { + "name": "composable-modern-app-elicitation", + "version": "1.0.0" + }, + "extensions": { + "io.modelcontextprotocol/ui": { + "elicitation": {} + } + }, + "tools": [{ "preset": "mrtr_app_confirm" }], + "resources": [{ "preset": "mcp_app_elicitation_demo" }], + "transport": { + "type": "streamable-http", + "port": 3102, + "modern": true + } +} diff --git a/test-servers/src/composable-test-server.ts b/test-servers/src/composable-test-server.ts index 4c836e4b9..c6e3b2416 100644 --- a/test-servers/src/composable-test-server.ts +++ b/test-servers/src/composable-test-server.ts @@ -435,6 +435,8 @@ export interface ResourceTemplateDefinition { */ export interface ServerConfig { serverInfo: Implementation; // Server metadata (name, version, etc.) - required + /** Extension capabilities advertised by this fixture server. */ + extensions?: Record; tools?: (ToolDefinition | TaskToolDefinition)[]; // Tools to register (optional, empty array means no tools, but tools capability is still advertised) resources?: ResourceDefinition[]; // Resources to register (optional, empty array means no resources, but resources capability is still advertised) resourceTemplates?: ResourceTemplateDefinition[]; // Resource templates to register (optional, empty array means no templates, but resources capability is still advertised) @@ -652,6 +654,10 @@ export function createMcpServer(config: ServerConfig): McpServer { extensions?: Record; } = {}; + if (config.extensions) { + capabilities.extensions = { ...config.extensions }; + } + // The modern tasks extension (SEP-2663) needs the tools capability too (its // task-augmented tools list via `tools/list`), even when no `config.tools` // were supplied. diff --git a/test-servers/src/load-config.ts b/test-servers/src/load-config.ts index 760363cb7..1b27dd702 100644 --- a/test-servers/src/load-config.ts +++ b/test-servers/src/load-config.ts @@ -41,6 +41,8 @@ export interface ConfigFile { name: string; version: string; }; + /** Extension capabilities advertised by this fixture server. */ + extensions?: Record; tools?: Array; resources?: PresetRef[]; resourceTemplates?: PresetRef[]; diff --git a/test-servers/src/preset-registry.ts b/test-servers/src/preset-registry.ts index 6c7e6679b..eb4f9967c 100644 --- a/test-servers/src/preset-registry.ts +++ b/test-servers/src/preset-registry.ts @@ -23,6 +23,7 @@ import { createListRootsTool, createCollectFormElicitationTool, createMrtrTool, + createMrtrAppTool, createMrtrMultiRoundTool, createMrtrRootsTool, createMrtrSamplingTool, @@ -52,6 +53,7 @@ import { createImmediateReturnTaskTool, createMcpAppDemoTool, createMcpAppDemoResource, + createMcpAppElicitationDemoResource, createArchitectureResource, createTestCwdResource, createTestEnvResource, @@ -136,6 +138,8 @@ function resolveToolPreset( return createCollectFormElicitationTool(); case "mrtr_confirm": return createMrtrTool(); + case "mrtr_app_confirm": + return createMrtrAppTool(); case "mrtr_two_step": return createMrtrMultiRoundTool(); case "mrtr_roots": @@ -235,6 +239,8 @@ function resolveResourcePreset( return createNumberedResources(Number(get("count")) || 3); case "mcp_app_demo_widget": return createMcpAppDemoResource(); + case "mcp_app_elicitation_demo": + return createMcpAppElicitationDemoResource(); default: throw new Error(`Unknown resource preset: ${name}`); } diff --git a/test-servers/src/resolve-config.ts b/test-servers/src/resolve-config.ts index 491bfed6b..ef809a32c 100644 --- a/test-servers/src/resolve-config.ts +++ b/test-servers/src/resolve-config.ts @@ -80,6 +80,7 @@ export function resolveConfig(config: ConfigFile): ServerConfig { const serverConfig: ServerConfig = { serverInfo, + extensions: config.extensions, tools: tools.length > 0 ? tools : undefined, resources: resources.length > 0 ? resources : undefined, resourceTemplates: diff --git a/test-servers/src/test-server-fixtures.ts b/test-servers/src/test-server-fixtures.ts index a87311efa..1ebc89603 100644 --- a/test-servers/src/test-server-fixtures.ts +++ b/test-servers/src/test-server-fixtures.ts @@ -458,7 +458,13 @@ export function createCollectFormElicitationTool(): ToolDefinition { // are already unique per operation. let mrtrMintCount = 0; -export function createMrtrTool(): ToolDefinition { +/** Generic UI resource used by the app-rendered MRTR confirmation fixture. */ +export const MCP_APP_ELICITATION_DEMO_URI = + "ui://demo/elicitation-confirm.mcp-app.html"; + +export function createMrtrTool( + options: { appResourceUri?: string } = {}, +): ToolDefinition { return { name: "mrtr_confirm", description: @@ -491,6 +497,9 @@ export function createMrtrTool(): ToolDefinition { }, required: ["confirm"], }, + ...(options.appResourceUri + ? { _meta: { ui: { resourceUri: options.appResourceUri } } } + : {}), }), }, requestState: `mrtr:${action}:${++mrtrMintCount}`, @@ -505,6 +514,11 @@ export function createMrtrTool(): ToolDefinition { }; } +/** Modern MRTR confirmation tool bound to the generic elicitation App. */ +export function createMrtrAppTool(): ToolDefinition { + return createMrtrTool({ appResourceUri: MCP_APP_ELICITATION_DEMO_URI }); +} + /** * A two-round MRTR tool: it asks for a first value, then (on the retry) a second * value, then completes. Exercises the manual driver's loop across MORE than one @@ -1233,6 +1247,95 @@ const MCP_APP_DEMO_HTML = ` `; +/** + * Self-contained MCP App for the generic app-rendered elicitation fixture. + * Production Apps should use `@modelcontextprotocol/ext-apps`; this raw protocol + * fixture intentionally has no external dependencies so it can be copied into + * point-in-time SEP experiments and served under the sandbox's default CSP. + */ +const MCP_APP_ELICITATION_DEMO_HTML = ` + + + + MCP App elicitation demo + + + +
+

Review and respond

+

Waiting for an elicitation request…

+ + + + + +
+ + +`; + /** * Tool definition for the MCP App demo. Carries `_meta.ui.resourceUri` so * clients recognize it as an App tool; the call result echoes the input title @@ -1280,6 +1383,23 @@ export function createMcpAppDemoResource(): ResourceDefinition { }; } +/** UI resource for {@link createMrtrAppTool}. */ +export function createMcpAppElicitationDemoResource(): ResourceDefinition { + return { + name: "mcp_app_elicitation_demo", + uri: MCP_APP_ELICITATION_DEMO_URI, + description: "Inline MCP App that answers form elicitations", + mimeType: "text/html;profile=mcp-app", + text: MCP_APP_ELICITATION_DEMO_HTML, + _meta: { + ui: { + csp: { connectDomains: [], resourceDomains: [] }, + prefersBorder: true, + }, + }, + }; +} + /** * Create an "architecture" resource definition */