feat(apps): negotiated app-rendered form elicitations - #2083
Conversation
Let a server hand a form `elicitation/create` to an MCP App and return the app's ordinary `ElicitResult`, with the native elicitation form as the fallback on every failure. No second extension, no custom method, and no custom result shape: the only new wire surface is a nested `elicitation` flag on the existing `io.modelcontextprotocol/ui` extension on each side, plus `_meta.ui.resourceUri` on the request. App rendering is selected only when all four gates hold — client `elicitation.form`, client MCP Apps MIME type, the nested `elicitation` setting on BOTH peers, and a valid absolute `ui://` URI on the request. Only the web client advertises the nested client-side setting, and only because it has a sandbox renderer: supplying `InspectorClientOptions.appElicitation` is what opts a client in, so CLI and TUI keep advertising the MIME type without ever claiming they can resolve an elicitation through an app. Routing lives in the one funnel both entry points already use (`enqueuePendingElicitation`), so the inbound handler and the MRTR driver cannot diverge. Ownership is request-scoped — a per-request id keys the renderer, iframe and bridge — so two concurrent elicitations can never resolve through each other's bridges. An explicit `decline`/`cancel` is a completed elicitation and goes back to the server; everything else (absent or malformed metadata, resource/sandbox/bridge failure, an app with no elicitation capability, a timeout, an invalid result or one that fails the requested schema) falls back. The Inspector speaks the ext-apps#733 / SEP-3118 wire protocol but cannot yet consume its helpers — the released `@modelcontextprotocol/ext-apps` (1.7.5) predates that PR. `core/mcp/appElicitation.ts` and `AppRenderer/requestAppElicitation.ts` mirror it exactly and are marked for deletion once a release containing it ships. One consequence needed its own seam: ext-apps 1.7.5 parses the view's `ui/initialize` through a schema that strips `elicitation`, so an app that correctly advertises it would look like one that did not, silently turning every negotiated elicitation into a fallback. `AppRenderer/appCapabilities.ts` records the raw frame instead, and prefers the bridge's own value once it carries the key. `AppRenderer` now takes an `AppRenderSource` union rather than a `Tool`, so the same renderer and bridge factory serve an App tool and an elicitation without either faking the other's shape. Adds the public fixture (`app_choose_option` + a self-contained `ui://demo/choose-option.html` app covering accept/decline/cancel), its two showcase configs, and `smoke:web:elicit`, which drives the negotiated path end to end in headless Chromium and then the same tool against a server that never advertised the capability, asserting the native form takes it. Closes #1854 Signed-off-by: cliffhall <cliff@futurescale.com>
There was a problem hiding this comment.
Pull request overview
Adds negotiated MCP App-rendered form elicitations to the web client while retaining native UI fallback.
Changes:
- Adds capability negotiation, validation, routing, and request-scoped rendering.
- Generalizes the App renderer and introduces elicitation modals/controllers.
- Adds fixtures, tests, smoke coverage, and documentation.
Reviewed changes
Copilot reviewed 38 out of 38 changed files in this pull request and generated 8 comments.
Show a summary per file
| File | Description |
|---|---|
AGENTS.md |
Documents the new core feature. |
README.md |
Documents usage, negotiation, and smoke coverage. |
package.json |
Registers the new smoke test. |
scripts/smoke-web-elicitation.mjs |
Exercises app and fallback flows. |
test-servers/configs/app-elicitation-http.json |
Adds negotiated fixture configuration. |
test-servers/configs/app-elicitation-native-http.json |
Adds fallback fixture configuration. |
test-servers/src/composable-test-server.ts |
Advertises server elicitation capability. |
test-servers/src/load-config.ts |
Defines the fixture option. |
test-servers/src/preset-registry.ts |
Registers fixture presets. |
test-servers/src/resolve-config.ts |
Resolves the fixture option. |
test-servers/src/test-server-fixtures.ts |
Implements the tool and app resource. |
core/mcp/appElicitation.ts |
Adds negotiation and result validation helpers. |
core/mcp/extensions.ts |
Adds client capability advertisement. |
core/mcp/inspectorClient.ts |
Routes and tracks app elicitations. |
core/mcp/types.ts |
Exposes the renderer option. |
clients/web/README.md |
Documents automation attributes. |
clients/web/src/App.tsx |
Wires the web renderer and controller. |
clients/web/src/lib/appElicitationController.ts |
Manages pending app requests. |
clients/web/src/lib/appElicitationController.test.ts |
Tests controller lifecycle and concurrency. |
clients/web/src/components/screens/AppsScreen/AppsScreen.tsx |
Adopts generalized render sources. |
clients/web/src/components/elements/AppRenderer/AppRenderer.tsx |
Generalizes rendering and forwards elicitations. |
clients/web/src/components/elements/AppRenderer/AppRenderer.test.tsx |
Tests generalized rendering behavior. |
clients/web/src/components/elements/AppRenderer/AppRenderer.stories.tsx |
Updates renderer stories. |
clients/web/src/components/elements/AppRenderer/appCapabilities.ts |
Preserves raw app capabilities. |
clients/web/src/components/elements/AppRenderer/appCapabilities.test.ts |
Tests capability observation. |
clients/web/src/components/elements/AppRenderer/appRenderSource.ts |
Defines tool/resource render sources. |
clients/web/src/components/elements/AppRenderer/appRenderSource.test.ts |
Tests source identity and titles. |
clients/web/src/components/elements/AppRenderer/createAppBridgeFactory.ts |
Adds resource sources and host capability wiring. |
clients/web/src/components/elements/AppRenderer/createAppBridgeFactory.test.ts |
Updates factory tests for source inputs. |
clients/web/src/components/elements/AppRenderer/requestAppElicitation.ts |
Sends standard bridge requests. |
clients/web/src/components/elements/AppRenderer/requestAppElicitation.test.ts |
Tests bridge request behavior. |
clients/web/src/components/elements/AppElicitation/AppElicitationHost.tsx |
Renders request-specific elicitation modals. |
clients/web/src/components/elements/AppElicitation/AppElicitationHost.test.tsx |
Tests rendering and fallback paths. |
clients/web/src/components/elements/AppElicitation/AppElicitationHost.stories.tsx |
Adds elicitation UI stories. |
clients/web/src/test/core/mcp/appElicitation.test.ts |
Tests negotiation and validation helpers. |
clients/web/src/test/core/mcp/extensions.test.ts |
Tests capability serialization. |
clients/web/src/test/core/mcp/inspectorClient-app-elicitation.test.ts |
Tests routing, fallback, and teardown. |
clients/web/src/test/integration/mcp/appElicitation.test.ts |
Tests the live server round trip. |
💡 Add a code-review agent skill for context-aware, tailored reviews. Learn more in the docs.
Eight findings from the Copilot review, all accepted.
Ownership across connections was the substantive one, in three parts. A
mid-session transport close reaches teardown only through
`clearAndAnnouncePendingPeerRequests`, whose emptiness check looked at the
native queues alone — so a lone app elicitation survived a dropped connection
with its modal still open; the check now counts the active set too. The web
bridge factory resolves its client at call time, so an entry queued by a
replaced `InspectorClient` could rebuild against the *next* one and answer
through a different server; the host now drops every entry synchronously on
the swap (`AppElicitationController.failAll`) rather than racing core's
awaited teardown. And the request id was only monotonic within one client, so
each replacement client's first request was `app-elicitation-1` — settling
that id could resolve the wrong server's request. It now carries a
per-instance prefix.
Result validation now parses the whole value with `ElicitResultSchema` before
the requested-schema check, instead of inspecting `action` by hand: a
`{ action: "decline", content: { x: {} } }` used to pass, though the standard
result permits only primitives and string arrays in `content`.
The dialog's accessible name now includes the request id and the prompt — two
concurrent elicitations can be for the same app URI, and the previous name
made those indistinguishable, which is what the comment beside it claimed to
prevent.
`observeAppCapabilities` takes a minimal structural `onmessage` contract
(generic over the message type, since a handler typed for a narrower message
is not assignable to one typed for `unknown`), removing the double cast at the
call site.
`smoke:web:elicit` now fails on an uncaught page error after a successful
drive, and captures the async half of that class off the console channel, as
its two sibling smokes do — it could otherwise print OK over a broken bundle.
Adds the tests each fix needs, including the two the review asked for
directly: that `advertiseElicitation` reaches the `AppBridge` constructor, and
that it is absent by default.
Signed-off-by: cliffhall <cliff@futurescale.com>
Review round 1 — all eight findings addressed (
|
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 38 out of 38 changed files in this pull request and generated 2 comments.
Suppressed comments (2)
Previously missed (2) — in code that hasn't changed since the last review.
clients/web/src/App.tsx:2490
appElicitationis supplied even whensandboxUrlis unavailable. The sandbox controller explicitly degrades tosandboxUrl: undefinedwhen it cannot bind, anduseInitialConfigalso starts with it undefined, so this client can advertiseio.modelcontextprotocol/ui.elicitationeven thoughAppElicitationHostcan only reject every request. Gate this option on confirmed sandbox availability (and account for the async config load) so the capability is never over-claimed.
// Web only: hands form elicitations that name a `ui://` resource to the
// MCP App the server chose, and advertises the nested MCP Apps
// `elicitation` capability that makes a server willing to send one
// (#1854). The native elicitation queue stays the fallback.
appElicitation: appElicitationController.render,
clients/web/src/components/elements/AppRenderer/appCapabilities.ts:91
- The raw frame is untrusted, but this truthiness check accepts malformed capability values such as
elicitation: trueorelicitation: []. Those do not satisfy the draft's object capability schema, yet the host will forward the form instead of failing closed to the native UI. Validate the selected parsed/raw value as a non-null, non-array object.
export function appAdvertisesElicitation(bridge: AppBridge): boolean {
const parsed = bridge.getAppCapabilities() as
| Record<string, unknown>
| undefined;
if (parsed?.elicitation) return true;
return Boolean(rawAppCapabilities.get(bridge)?.elicitation);
Both review findings from round two. The routing change sits in the funnel BOTH entry points share, but only the legacy inbound handler was driven by a test — the modern leg, where an elicitation arrives as an `input_required` result the MRTR driver unpacks and retries, was covered by construction alone. Adds `mrtr_app_choose_option` (a modern MRTR tool whose *embedded* elicitation carries `_meta.ui.resourceUri`) and drives it live over a real transport: the app answers, the retry carries that answer through `inputResponses`, and the server echoes it back as the tool's result. A second case cancels the tool call while the app is up and asserts the request-scoped signal aborts; a third runs the same tool against a modern server that never advertised the capability and asserts the native queue takes it. The cancellation case turned up a real gap: `tryAppElicitation` mounted an app for an already-aborted signal (the caller cancelled during an earlier MRTR round), leaving a modal nobody was waiting on and a promise that could never settle. It now throws the abort straight through. Also fixes the smoke count in the root README, which still said "two" headless-Chromium smokes and then introduced a third. Signed-off-by: cliffhall <cliff@futurescale.com>
Review round 2 — both findings addressed (
|
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 38 out of 38 changed files in this pull request and generated 1 comment.
Suppressed comments (2)
Previously missed (2) — in code that hasn't changed since the last review.
test-servers/src/test-server-fixtures.ts:582
- This App resource is published as generic
text/html, so the public fixture is not a conforming MCP App resource. The upstreamregisterAppResourcecontract defaults App resources totext/html;profile=mcp-app; using plain HTML means this end-to-end test can pass even though a MIME-validating host would reject the fixture. Use the MCP Apps MIME type here.
clients/web/src/App.tsx:2490 - The web client opts into the nested capability even when
sandboxUrlis unavailable.useInitialConfigexplicitly treats an omitted URL (including sandbox-controller startup failure or config-fetch failure) as “Apps unavailable” (core/react/useInitialConfig.ts:7-12), but every subsequently created client still advertises app elicitation here. That tells servers to choose an app path this host cannot render. Only supplyappElicitationwhen the sandbox is available (and account forsandboxUrlin the client-factory dependencies).
// Web only: hands form elicitations that name a `ui://` resource to the
// MCP App the server chose, and advertises the nested MCP Apps
// `elicitation` capability that makes a server willing to send one
// (#1854). The native elicitation queue stays the fallback.
appElicitation: appElicitationController.render,
| "coverage:web": "cd clients/web && npm run test:coverage", | ||
| "coverage:launcher": "cd clients/launcher && npm run test:coverage", | ||
| "smoke": "npm run smoke:launcher && npm run smoke:cli && npm run smoke:tui && npm run smoke:web && npm run smoke:web:browser && npm run smoke:web:app", | ||
| "smoke": "npm run smoke:launcher && npm run smoke:cli && npm run smoke:tui && npm run smoke:web && npm run smoke:web:browser && npm run smoke:web:app && npm run smoke:web:elicit", |
There was a problem hiding this comment.
Right on both counts — fixed in 9b775b04. AGENTS.md now lists smoke:web:elicit in the npm run smoke chain and credits the shared prod-web-server.mjs helper with four consumers rather than three, and the new smoke has its own entry beside its siblings: what it drives, why the not-negotiated half is the load-bearing one, and the two mechanics that are easy to get wrong (the main-view tabs are a Mantine SegmentedControl, so there is no role="tab" — the clickable element is the sibling label[for$="-Tools"]; and the prompt string also appears in the hidden Protocol-tab payload, so the fallback assertion is scoped to the dialog).
.github/copilot-instructions.md deliberately does not change: it is a distillation that references npm run smoke as a whole and never enumerates the individual smokes, and this adds no rule a reviewer would cite against a diff.
The command reference still ended `npm run smoke` at `smoke:web:app` and credited the shared prod-web-server helper with three consumers. Adds the new smoke to both, plus its own entry alongside the sibling smokes: what it drives, why the not-negotiated half is the load-bearing one, and the two mechanics that are easy to get wrong (the tabs are a SegmentedControl with no `role="tab"`, and the prompt string also appears in the hidden Protocol payload). Signed-off-by: cliffhall <cliff@futurescale.com>
Review round 3 — addressed (
|
The capability observer recorded `params.appCapabilities` from ANY frame whose method was `ui/initialize`, before the bridge validated it. A view could therefore send a second, malformed initialize — one the bridge rejects, keeping the capabilities it had already accepted — and flip `elicitation` on in this gate, after which the host would forward an elicitation the bridge never negotiated. Now only a well-formed initialize REQUEST is recorded (a JSON-RPC id, plus the handshake's required `protocolVersion` and `appInfo`), and only the first one: the bridge keeps what it accepted and ignores re-initialization, so this gate agrees rather than offering a second, laxer path to the same flag. A malformed first frame records nothing. This is a gate, not a second copy of the bridge's schema — the bridge stays the authority on the rest of the frame. Signed-off-by: cliffhall <cliff@futurescale.com>
Review round 4 — addressed (
|
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 38 out of 38 changed files in this pull request and generated 1 comment.
Suppressed comments (1)
Previously missed (1) — in code that hasn't changed since the last review.
clients/web/src/App.tsx:2490
- The renderer is supplied unconditionally, so
InspectorClientadvertises app-rendered elicitation even whensandboxUrlis unavailable.useInitialConfigexplicitly leaves that value undefined until loading completes and for legacy/builds without a sandbox controller, where this host cannot render an app. Only passappElicitationwhensandboxUrlexists (and add it to this callback's dependencies) so the wire capability reflects the renderer actually available for that connection.
// Web only: hands form elicitations that name a `ui://` resource to the
// MCP App the server chose, and advertises the nested MCP Apps
// `elicitation` capability that makes a server willing to send one
// (#1854). The native elicitation queue stays the fallback.
appElicitation: appElicitationController.render,
The previous round froze the recorded capabilities at the first handshake, on the belief that the bridge ignores re-initialization. It does not: ext-apps 1.7.5's `_oninitialize` warns about the double-mount and then assigns `_appCapabilities` and `_appInfo` from the new frame — "the latest appInfo/ appCapabilities replace the previous values", in its own words. Freezing left this gate reporting capabilities the bridge no longer held, in both directions: a handshake advertising `elicitation` followed by one without it still read as advertised. Every frame that passes the accept gate now replaces the recorded value, and `appCapabilities` joins `protocolVersion` and `appInfo` in that gate since the bridge's own schema requires all three. A frame the bridge would reject still records nothing AND leaves the previous value alone — it is a route to changing the gate in neither direction. Signed-off-by: cliffhall <cliff@futurescale.com>
Review round 5 — addressed (
|
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 38 out of 38 changed files in this pull request and generated 1 comment.
Suppressed comments (4)
Previously missed (3) — in code that hasn't changed since the last review.
core/mcp/inspectorClient.ts:2892
- The legacy inbound handler still calls this funnel without its request-context signal (
inspectorClient.ts:1430-1480). When the server times out or cancelselicitation/create,ctx.mcpReq.signalaborts, but this app request remains active (potentially for the 10-minute bridge timeout) and can leave a modal for an expired server request. Thread the handler context signal intoenqueuePendingElicitation, as the MRTR path already does.
const appResult = await this.tryAppElicitation(request, signal);
clients/web/src/App.tsx:2490
- This always opts the client into app-rendered elicitation even when
sandboxUrlis unavailable.useInitialConfigexplicitly leaves that value undefined before loading, for legacy backends, and for builds without the sandbox controller; in those states this client advertises a capability it cannot provide and only fails after accepting a server request. SupplyappElicitationonly when the sandbox URL is available (and include that state in the client-factory dependencies).
// Web only: hands form elicitations that name a `ui://` resource to the
// MCP App the server chose, and advertises the nested MCP Apps
// `elicitation` capability that makes a server willing to send one
// (#1854). The native elicitation queue stays the fallback.
appElicitation: appElicitationController.render,
package.json:68
- Adding the fourth web-smoke consumer leaves the shared helper's documentation stale:
scripts/lib/prod-web-server.mjs:4-9,:32, and:94still describe only three web smokes and omitsmoke:web:elicit. Update those references so the helper's consumer/isolation contract matches this command chain.
"smoke": "npm run smoke:launcher && npm run smoke:cli && npm run smoke:tui && npm run smoke:web && npm run smoke:web:browser && npm run smoke:web:app && npm run smoke:web:elicit",
clients/web/src/App.tsx:866
- A passive-effect cleanup is not synchronous with
setInspectorClient: React runs cleanup/setup for the already-committed tree before flushing the external-store update. During that pass, an existingAppRenderercan set up with the newelicitationBridgeFactorywhile its old entry is still rendered, recreating the cross-connection resource-read/bridge race this cleanup is intended to prevent. Drop entries in the synchronous client-replacement path before installing the new client, or bind each entry to its originating client.
useEffect(() => {
return () => {
appElicitationController.failAll(
new Error("Connection replaced before the app answered"),
);
};
}, [appElicitationController, inspectorClient]);
`tasks/cancel` marked the task cancelled and rejected its payload, but left whatever was collecting the answer running — the native pending-request entry, and now an app-rendered elicitation's renderer and bridge. A modal therefore outlived the task it belonged to, and an answer arriving afterwards re-settled it, overwriting `cancelled` with `completed`: a task the server had been told it cancelled. Each receiver task now owns an `AbortController`, aborted by `tasks/cancel` and by session teardown. The task-augmented elicitation branch passes its signal to the app attempt and wires it to the native queue entry, so both answer routes are torn down the same way. Both settle helpers return early on a terminal status, so a late answer through either route cannot resurrect the task. The overwrite predates the app path — a native answer after a cancel did the same thing — so the guard fixes both. Signed-off-by: cliffhall <cliff@futurescale.com>
Review round 8 — addressed (
|
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 38 out of 38 changed files in this pull request and generated no new comments.
Suppressed comments (3)
Previously missed (2) — in code that hasn't changed since the last review.
clients/web/src/App.tsx:2495
sandboxUrlis alsoundefinedwhile the initial/api/configrequest is still in flight. If a user clicks Connect—or an existing deep-link catalog row auto-connects—before that fetch resolves, this client is permanently constructed withoutappElicitation; whensandboxUrlarrives, the live client is not rebuilt, so the web session silently uses native fallback despite having a sandbox. Gate client construction on the initial-config loading state (while still allowing a confirmed missing sandbox), or reconnect/rebuild when availability becomes known.
...(sandboxUrl && {
appElicitation: appElicitationController.render,
}),
clients/web/src/components/elements/AppElicitation/AppElicitationHost.tsx:240
openedis a static behavioral prop on this extracted Mantine subcomponent. Under the repository's.withProps()rule, static props must be baked intoElicitationModal; addopened: trueto its declaration and remove this call-site prop.
<ElicitationModal
opened
onClose={dismiss}
clients/web/src/App.tsx:866
- This cleanup only rejects entries that already exist at the client swap. The old
InspectorClient.disconnect()is fire-and-forget, and core does not abort/clear app requests until afterawait client.close()(core/mcp/inspectorClient.ts:2343-2362), so the old client can still enqueue a new request through this shared controller during teardown. That new entry is then rendered by the factory bound to the replacement client, allowing its resource read/answer to cross server connections. Bind each request/controller to its originating client (or synchronously close the old controller to future renders), rather than relying on a one-timefailAll.
useEffect(() => {
return () => {
appElicitationController.failAll(
new Error("Connection replaced before the app answered"),
);
};
}, [appElicitationController, inspectorClient]);
Two of the three findings; the third is answered in the PR thread. **A replaced client could still enqueue.** The one-shot sweep only rejected what was already queued, but the outgoing `InspectorClient` disconnects asynchronously and can enqueue during its own teardown — and that late entry would be rendered by a factory bound to the REPLACEMENT client, reading its resource and answering through a different server. Requests now belong to a session: each constructed client gets one, closing it rejects that session's entries AND refuses anything it queues afterwards, and another session's entries are untouched. **The advertisement no longer has to guess.** Whether a sandbox exists is only known once `/api/config` resolves, and the answer is baked into the client at construction — guessing "available" over-claims, guessing "unavailable" strands the whole session on the native form despite having a sandbox. The connect path now awaits that fetch (already in flight since mount, so no human ever waits; it only orders a deep-link auto-connect racing the same page load), after which an absent `sandboxUrl` means confirmed-absent. Signed-off-by: cliffhall <cliff@futurescale.com>
Review round 9 — the three suppressed findings (
|
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 38 out of 38 changed files in this pull request and generated 1 comment.
Suppressed comments (1)
Previously missed (1) — in code that hasn't changed since the last review.
core/mcp/inspectorClient.ts:3011
- The direct server→client handler still calls
enqueuePendingElicitationwithout the request context's cancellation signal. When the server cancels an in-flightelicitation/create, the SDK abortsctx.mcpReq.signal, but this controller is not linked to it, so the app iframe/modal can outlive the cancelled request and later answer work the server abandoned. Accept the handler context and pass its signal through this path; add a direct-request cancellation test.
// Request-scoped abort: forwards the caller's signal (MRTR cancellation)
// and is aborted by `settleAndDropPendingPeerRequests` on disconnect, so a
// rendered app cannot outlive the connection that asked for it.
// Already cancelled before we got here (the caller aborted while an earlier
// MRTR round was in flight): don't mount an app nobody is waiting on.
The direct server→client `elicitation/create` handler ignored its request context, so `notifications/cancelled` aborted `ctx.mcpReq.signal` and nothing was listening: the modal and its bridge outlived the cancelled request and could still answer work the server had abandoned. The handler now threads that signal into `enqueuePendingElicitation`, which already wires it to both answer surfaces — the native queue entry and the app renderer. The task-augmented branch deliberately does not use it: that request is answered immediately with a `CreateTaskResult`, so its lifetime is the task's, which carries its own abort. Signed-off-by: cliffhall <cliff@futurescale.com>
Review round 10 — addressed (
|
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 38 out of 38 changed files in this pull request and generated no new comments.
Suppressed comments (1)
clients/web/src/App.tsx:2851
- Awaiting this promise does not refresh the callback's captured
setupClientForServer. If a click or deep-link auto-connect starts before/api/configsettles, this invocation resumes with the render wheresandboxUrlwas stillundefined, so line 2521 omitsappElicitation; the deep-link one-shot guard then prevents a retry and the entire connection silently loses the advertised capability despite having a sandbox. The OAuth callback path also callssetupClientForServerwithout this wait. Gate those connection effects untilinitialConfigLoadingis false (before setting their one-shot guards), or make client setup read the settled sandbox URL from a current ref.
// Whether this client may advertise app-rendered elicitation is decided
// at construction and cannot be revised afterwards, so wait for the fact
// rather than guess it (see `initialConfigSettledRef`). Already resolved
// by the time any human clicks; this only orders a deep-link auto-connect
// that races the same page load.
await initialConfigSettledRef.current?.promise;
…tion The previous round's `await` did not do what it claimed. `setupClientForServer` is synchronous and memoized, so a caller that awaited the config resumed with the `sandboxUrl` captured by the render it STARTED in — undefined, on exactly the load the wait was added for. The deep-link one-shot guard then prevented a retry, so that connection silently lost the capability despite having a sandbox. The OAuth-callback path did not wait at all. Construction now reads a ref written every render, so it sees the current value whichever entry point reached it, and the OAuth path waits like the connect path does. The wait still matters — it is what makes an absent value mean 'confirmed absent' rather than 'not known yet' — but the ref is what makes the value read be the settled one. Signed-off-by: cliffhall <cliff@futurescale.com>
Review round 11 — addressed (
|
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 38 out of 38 changed files in this pull request and generated no new comments.
Suppressed comments (1)
Previously missed (1) — in code that hasn't changed since the last review.
clients/web/src/components/elements/AppElicitation/AppElicitationHost.tsx:259
- Only the top modal owns the focus trap, but every lower
Modal.Contentremains an exposedaria-modaldialog with active controls and an iframe. Screen-reader browse navigation can therefore reach dialogs that are visually covered by the top overlay, while each dialog claims the rest of the page is inert. Hide/inert non-top contents (or use Mantine's modal stack) while keeping their React trees mounted.
<Modal.Content
aria-label={`Elicitation ${entry.requestId} — ${entry.params.message} — rendered by ${entry.resourceUri}`}
data-app-elicitation-status={status}
data-testid="app-elicitation"
>
Only the top modal owned the focus trap, but every lower `Modal.Content` remained an exposed `aria-modal` dialog with reachable controls and a live iframe — so browse navigation could land in a dialog that is visually covered and that itself claims the rest of the page is inert. Non-top contents are now `inert`: out of the accessibility tree and out of focus order, without unmounting, so each app keeps its bridge and its handshake and is live the moment it becomes top. Signed-off-by: cliffhall <cliff@futurescale.com>
Review round 12 — addressed (
|
Closes #1854
Implements negotiated, app-rendered form elicitations: a server may attach an MCP App to a standard
elicitation/create, and a host that can run one renders it and returns the app's ordinaryElicitResult. The native Inspector elicitation form stays the fallback on every failure.No second extension, no custom method, no custom result shape. The only new wire surface is a nested
elicitationflag on the existingio.modelcontextprotocol/uiextension on each side, plus_meta.ui.resourceUrion the request.Proof
The server's app renders the form in the sandbox instead of the Inspector's own:
Clicking Option A inside the sandboxed app returns the standard result to the server, which echoes it back in the tool result — so this is the round trip, not just the host:
The same tool against a server that did not advertise the nested capability falls back to the built-in form. This is the half that matters most — a client that over-claims the capability strands every user of a server that never opted in:
All three are captured by
npm run smoke:web:elicit(headless Chromium, innpm run smoke), which drives connect → call the tool → answer inside the app → assert the result, then repeats against the non-negotiating server.The contract
App rendering is selected only when all four gates hold:
elicitation.form;elicitationsetting onio.modelcontextprotocol/ui;ui://URI in_meta.ui.resourceUri.Only the web client advertises the nested client-side setting, and only because it has a sandbox renderer to back it. Supplying
InspectorClientOptions.appElicitationis what opts a client in, so CLI and TUI keep advertising the MIME type (they know what an App is) without ever claiming they can resolve an elicitation through one. Disabling form elicitation drops both capabilities; turning the Apps extension off in Server Settings drops the nested one with it.Routing goes in the one funnel both entry points already share (
enqueuePendingElicitation), so the inboundelicitation/createhandler and the MRTR driver cannot diverge on it. Ownership is request-scoped: a per-request id keys the renderer, iframe and bridge, so two concurrent elicitations — even for the same app — cannot resolve through each other's bridges.An explicit
declineorcancelis a completed elicitation and is returned to the server. Everything else falls back: absent/malformed/non-ui://metadata,urlmode, a failed resource read, sandbox or bridge init failure, an app that did not advertiseelicitation, a timeout, an invalid result, or accepted content that fails the requested schema.On the upstream dependency
The issue asks that this consume ext-apps#733 rather than inventing an Inspector-local variant. That PR is still open and the released
@modelcontextprotocol/ext-apps(1.7.5) exports none of it, so this speaks exactly its wire protocol — same method, same params, same result, same capability keys — while mirroring the two helpers it needs locally:core/mcp/appElicitation.ts(negotiation gates,_meta.ui.resourceUrireader/validator, result validation)clients/web/src/components/elements/AppRenderer/requestAppElicitation.ts(the bridge send that becomesbridge.requestElicitation(params))Both are marked for deletion when a release containing #733 ships; nothing on the wire changes then. Pinning the repo to a git ref of an unmerged PR was the alternative and was rejected — the root manifest is what a published install resolves against.
One consequence of the version gap needed its own seam. ext-apps 1.7.5 parses the view's
ui/initializethrough a schema that stripselicitation, so an app that correctly advertises it reads, throughbridge.getAppCapabilities(), as one that did not — silently turning every negotiated elicitation into a fallback (this is what the first end-to-end run actually hit).AppRenderer/appCapabilities.tsrecords the raw frame instead, and prefers the bridge's own value once it carries the key.Notable changes
AppRenderertakes anAppRenderSourceunion rather than aTool. An elicitation has no tool — the server names the resource on the request — so the same renderer, bridge factory and sandbox lifecycle now serve both without either faking the other's shape. Reuse still keys on Tool identity for the tool case, so a re-listed tool rebuilds exactly as before.hostCapabilities.elicitation; an App-tool frame is never handed an elicitation, so claiming it there would tell those apps something untrue.<header>— a duplicate banner landmark that axe rejects. Title and close live in the body, and each dialog is named by its own request.Fixture
app_choose_optionplus a self-containedui://demo/choose-option.htmlapp (accept / decline / cancel), served bytest-servers/configs/app-elicitation-http.json. Its-native-sibling is the same tool and app on a server that never advertises the capability — that pair is what makes the negotiation observable rather than asserted.Tests
decline/cancelnot falling back.src/test/integration/mcp/appElicitation.test.ts) — this is where a_metadropped by the SDK's ownelicitInputwould surface.npm run cipasses locally (validate → coverage → build gate → smokes → Storybook).