fix(apps): address review of app-rendered elicitations
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>
This commit is contained in:
parent
08fc20a22d
commit
7c77c2b15f
1 changed files with 286 additions and 38 deletions
|
|
@ -1,5 +1,4 @@
|
|||
import type { AppBridge } from "@modelcontextprotocol/ext-apps/app-bridge";
|
||||
import type { Transport } from "@modelcontextprotocol/client";
|
||||
|
||||
/**
|
||||
* The app capabilities exactly as the view sent them, per bridge.
|
||||
|
|
@ -22,10 +21,41 @@ import type { Transport } from "@modelcontextprotocol/client";
|
|||
*/
|
||||
const rawAppCapabilities = new WeakMap<AppBridge, Record<string, unknown>>();
|
||||
|
||||
/** Shape of the `ui/initialize` frame this reads — nothing else is touched. */
|
||||
interface UiInitializeFrame {
|
||||
method?: unknown;
|
||||
params?: { appCapabilities?: unknown };
|
||||
/**
|
||||
* The only part of a transport this needs: the inbound-message callback.
|
||||
*
|
||||
* Structural, and generic over the message type, on purpose. ext-apps'
|
||||
* `PostMessageTransport` implements the SDK *v1* `Transport` — a different
|
||||
* nominal type from the v2 client's, though runtime-identical — so naming
|
||||
* either would force a cast at the call site. Generic rather than
|
||||
* `(message: unknown)` because a handler typed for a narrower message is not
|
||||
* assignable to one typed for `unknown` (contravariance), which would put the
|
||||
* cast back; `rest` is `never[]` for the same reason, accepting any trailing
|
||||
* parameter list without claiming to know it. The message is only ever read
|
||||
* through {@link recordAdvertisedCapabilities}, which narrows from `unknown`.
|
||||
*/
|
||||
export interface MessageObservable<TMessage = unknown> {
|
||||
onmessage?: (message: TMessage, ...rest: never[]) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Record `params.appCapabilities` if `message` is a `ui/initialize` frame.
|
||||
* Narrows from `unknown` rather than asserting a shape: the frame is whatever
|
||||
* the sandboxed view posted.
|
||||
*/
|
||||
function recordAdvertisedCapabilities(
|
||||
bridge: AppBridge,
|
||||
message: unknown,
|
||||
): void {
|
||||
if (typeof message !== "object" || message === null) return;
|
||||
const frame = message as { method?: unknown; params?: unknown };
|
||||
if (frame.method !== "ui/initialize") return;
|
||||
if (typeof frame.params !== "object" || frame.params === null) return;
|
||||
const advertised = (frame.params as { appCapabilities?: unknown })
|
||||
.appCapabilities;
|
||||
if (typeof advertised === "object" && advertised !== null) {
|
||||
rawAppCapabilities.set(bridge, advertised as Record<string, unknown>);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -36,20 +66,14 @@ interface UiInitializeFrame {
|
|||
* wraps. That ordering is safe: the view cannot send `ui/initialize` until the
|
||||
* host has pushed its HTML into the sandbox, which happens later still.
|
||||
*/
|
||||
export function observeAppCapabilities(
|
||||
export function observeAppCapabilities<TMessage>(
|
||||
bridge: AppBridge,
|
||||
transport: Transport,
|
||||
transport: MessageObservable<TMessage>,
|
||||
): void {
|
||||
const inner = transport.onmessage?.bind(transport);
|
||||
transport.onmessage = (message, extra) => {
|
||||
const frame = message as UiInitializeFrame;
|
||||
if (frame.method === "ui/initialize") {
|
||||
const advertised = frame.params?.appCapabilities;
|
||||
if (typeof advertised === "object" && advertised !== null) {
|
||||
rawAppCapabilities.set(bridge, advertised as Record<string, unknown>);
|
||||
}
|
||||
}
|
||||
inner?.(message, extra);
|
||||
transport.onmessage = (message: TMessage, ...rest: never[]) => {
|
||||
recordAdvertisedCapabilities(bridge, message);
|
||||
inner?.(message, ...rest);
|
||||
};
|
||||
}
|
||||
|
||||
|
|
|
|||
Loading…
Reference in a new issue