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.
- 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.
- 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).
Summary
Two things, one cause.
The gap.
publishToolCancelledrefuses for every mounted view, soagent-bundle/app'sonToolCancelledhas no browser-app case at all.mountBrowserApprequirestoolResultand publishes it during mount, which queuesthe 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 atoolwhoseinputSchemapassesthe bridge's own
validToolDefinitioncheck — otherwise the bridge fails to createwith "MCP App bridge could not be created", which does not name the missing field.
The harness already receives
toolDefinitionas a first-class option and computesthe binding from it, so it has everything it needs to fill
toolInfoin. A callersupplying
host.contextfor an unrelated reason (display mode, platform) silentlyreplaces the context rather than merging into it, which is how the first version
of this issue reached the wrong conclusion.
Root cause
agent-bundle/applearns which route the opening call belongs to from the initialize result, and gates every opening listener on it — so an absenttoolInfodisables them silently rather than erroring:The preview service supplies that field from the binding it already holds:
The browser test harness does not. It builds the context as a literal:
So unless the caller supplies
toolInfoitself,openingToolNameisundefinedfor the lifetime of the page andpublishOpeningreturns 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; supplyingtoolInfothroughhost.contextis 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, andmountBrowserAppbypasses the service.bindingForin the same file already computes thetoolDefinitionthe context needs.Reproduction
An App entry that registers the listeners:
A browser-app test that opens it with a result already in hand:
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: theinputSchemais load-bearing (without it the bridge fails to create) and the rest of the context has to be restated becausehost.contextreplaces rather than merges.No arrangement of options reaches the cancellation, though:
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.
toolResultshould be optional, or apending: trueform accepted, sopublishToolResultandpublishToolCancelledare both reachable after mount and the cancelled path gets a case. This is the gap.toolInfoin fromtoolDefinition.mountBrowserAppshould derive the host context the way the product host does — through the same helper the preview service uses (hostContextRecord) — and merge a caller-suppliedhost.contextover it rather than replacing it. Then the opening listeners work by default instead of requiring a caller to reconstruct a validtoolInfoby reading the bridge's validator.Impact
The
browser-applevel is the only level that executes an App view, and this is the one App-client feature it cannot cover. Found while migratingmovie-library's widget off its hand-rolled postMessage bridge ontoagent-bundle/app(ScriptedAlchemy/movie-library#10). With thetoolInfoworkaround 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 pinspublishToolCancelled() === falseso the case gets written the day this is fixed. Related: #726 (making the MCP App example convention-first, which would exercise the same listeners).