Skip to content

[P2] mountBrowserApp cannot mount an in-flight opening call, so agent-bundle/app's onToolCancelled has no browser-app case #736

Description

@ScriptedAlchemy

Corrected 2026-09-07. As first filed, this claimed mountBrowserApp makes
toolInfo unreachable, so none of onToolInput, onToolResult or
onToolCancelled could fire. That is wrong for the first two: a caller can
supply toolInfo through host.context, and doing so dispatches the opening
input and result listeners correctly. movie-library now tests both that way.
What remains is the narrower gap the title describes, and the ergonomic defect
behind it. Both are still real; the reproduction below is unchanged except for
the note on the first snippet.

Summary

Two things, one cause.

The gap. publishToolCancelled refuses for every mounted view, so
agent-bundle/app's onToolCancelled has no browser-app case at all.
mountBrowserApp requires toolResult and publishes it during mount, which queues
the binding's terminal state; the bridge then rejects a cancellation against a call
it considers settled. A host that opens a view for a call still in flight and then
cancels it is an ordinary sequence the harness cannot express, whatever the caller
passes.

The ergonomic defect. Reaching the opening listeners at all requires the caller
to hand-build host.context.toolInfo, including a tool whose inputSchema passes
the bridge's own validToolDefinition check — otherwise the bridge fails to create
with "MCP App bridge could not be created", which does not name the missing field.
The harness already receives toolDefinition as a first-class option and computes
the binding from it, so it has everything it needs to fill toolInfo in. A caller
supplying host.context for an unrelated reason (display mode, platform) silently
replaces the context rather than merging into it, which is how the first version
of this issue reached the wrong conclusion.

Root cause

agent-bundle/app learns which route the opening call belongs to from the initialize result, and gates every opening listener on it — so an absent toolInfo disables them silently rather than erroring:

// packages/agent-bundle/src/app/index.ts
const toolInfo = isPlainDataRecord(initialized.hostContext.toolInfo) ? initialized.hostContext.toolInfo : undefined;
const tool = toolInfo !== undefined && isPlainDataRecord(toolInfo.tool) ? toolInfo.tool : undefined;
openingToolName = tool !== undefined && nonempty(tool.name) ? tool.name : undefined;

const publishOpening = (listeners, value) => {
  if (openingToolName === undefined) return;   // <-- always taken under mountBrowserApp
  for (const [routeId, registered] of listeners) if (routeToolName(routeId) === openingToolName) 
};

The preview service supplies that field from the binding it already holds:

// packages/agent-bundle/src/dev/mcp-apps/mcp-app-preview-service.ts
context: hostContextRecord(options.host, toolDefinition),   // sets toolInfo: { tool: toolDefinition }

The browser test harness does not. It builds the context as a literal:

// packages/agent-bundle/src/test/browser.ts
context: userHost?.context ?? {
  availableDisplayModes: ['inline'],
  displayMode: 'inline',
  platform: 'desktop',
},

So unless the caller supplies toolInfo itself, openingToolName is undefined for the lifetime of the page and publishOpening returns before reaching any listener. Nothing errors — the notification arrives, the traffic log shows it, and the listener silently never runs. That is the ergonomic defect; supplying toolInfo through host.context is the workaround, and it works.

McpAppPreviewHostContext = Omit<McpAppHostContextInput, 'toolInfo'> makes the omission look intentional for callers, but it is the service's job to fill the field in, and mountBrowserApp bypasses the service. bindingFor in the same file already computes the toolDefinition the context needs.

Reproduction

An App entry that registers the listeners:

const client = createAppClient({ appInfo: { name, version } });
client.onToolResult('tool:my-server/my_tool', (result) => { render(result); });
client.onToolInput('tool:my-server/my_tool', (input) => { prefill(input); });
client.onToolCancelled(({ reason }) => { showCancelled(reason); });
await client.connect();

A browser-app test that opens it with a result already in hand:

const app = await mountBrowserApp('widget', {
  operations: { callTool: async () => ({ content: [], isError: true }), closeBinding: async () => true, readResource: async () => ({ contents: [] }) },
  scriptedConsent: 'approve',
  toolDefinition: { _meta: { 'io.agent-bundle/route-id': 'tool:my-server/my_tool' }, name: 'my_tool' },
  toolInput: { q: 'x' },
  toolName: 'my_tool',
  toolResult: { content: [{ text: 'ok', type: 'text' }], structuredContent: { rows: [{ title: 'hit' }] } },
});
// The traffic log contains host-to-app ui/notifications/tool-input and
// ui/notifications/tool-result, and the page renders neither.
expect(app.document.querySelector('#results')?.textContent).toContain('hit');   // fails

Adding host: { context: { availableDisplayModes: ['inline'], displayMode: 'inline', platform: 'desktop', toolInfo: { tool: { inputSchema: { type: 'object' }, name: 'my_tool' } } } } makes that assertion pass — that is the workaround, and the measure of the ergonomic defect: the inputSchema is load-bearing (without it the bridge fails to create) and the rest of the context has to be restated because host.context replaces rather than merges.

No arrangement of options reaches the cancellation, though:

expect(app.publishToolCancelled('operator cancelled')).toBe(false);   // always

A binding must carry a result, so mount queues the terminal state and the opening call has always settled by the time a page exists.

Expected

Two changes, matching the two parts above.

  1. Let a view be mounted for a call still in flight. toolResult should be optional, or a pending: true form accepted, so publishToolResult and publishToolCancelled are both reachable after mount and the cancelled path gets a case. This is the gap.
  2. Fill toolInfo in from toolDefinition. mountBrowserApp should derive the host context the way the product host does — through the same helper the preview service uses (hostContextRecord) — and merge a caller-supplied host.context over it rather than replacing it. Then the opening listeners work by default instead of requiring a caller to reconstruct a valid toolInfo by reading the bridge's validator.

Impact

The browser-app level is the only level that executes an App view, and this is the one App-client feature it cannot cover. Found while migrating movie-library's widget off its hand-rolled postMessage bridge onto agent-bundle/app (ScriptedAlchemy/movie-library#10). With the toolInfo workaround that plugin now covers the opening result, the opening error, the call paths and host teardown; the cancelled path is the one App-client feature its browser suite cannot reach, and it pins publishToolCancelled() === false so the case gets written the day this is fixed. Related: #726 (making the MCP App example convention-first, which would exercise the same listeners).

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions