Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/browser-app-pending-opening-call.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"agent-bundle": patch
---

Allow `mountBrowserApp` to mount pending opening calls, derive `hostContext.toolInfo`, and report the invalid host-context field precisely. (#736)
8 changes: 8 additions & 0 deletions examples/mcp-app/src/mcp/status/apps/status.ts
Original file line number Diff line number Diff line change
Expand Up @@ -96,6 +96,14 @@ client.onToolError(showStatusRoute, (error) => {
checks.replaceChildren();
});

client.onToolCancelled(({ reason }) => {
setStatus('unavailable');
summary.textContent = reason === undefined
? 'Readiness check cancelled.'
: `Readiness check cancelled: ${reason}`;
checks.replaceChildren();
});

document.querySelector('#toggle-details')!.addEventListener('click', () => {
document.querySelector('#details')!.toggleAttribute('hidden');
});
Expand Down
126 changes: 104 additions & 22 deletions examples/mcp-app/tests/browser-app/status-panel.browser.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,14 +10,6 @@ import {
type BindingOperations = MountBrowserAppOptions['operations'];
type ToolCallResult = Awaited<ReturnType<BindingOperations['callTool']>>;

/**
* The opening tool, `src/mcp/status/tools/show-status.tsx`. Framework hosts put
* the leased tool definition in the initialize `hostContext.toolInfo`, and the
* App client delivers `onToolInput`/`onToolResult` only to listeners on that
* tool's route, so the harness has to open the panel with `show-status` for
* the populated-state tests to go through the `tool:status/show-status`
* listeners.
*/
const showStatusTool = Object.freeze({
_meta: Object.freeze({ ui: Object.freeze({ resourceUri: 'ui://mcp-app-example/status.html' }) }),
description: 'Show the health of one example service.',
Expand All @@ -31,13 +23,6 @@ const showStatusTool = Object.freeze({
name: 'show-status',
});

const openingHostContext = Object.freeze({
availableDisplayModes: Object.freeze(['inline']),
displayMode: 'inline',
platform: 'desktop',
toolInfo: Object.freeze({ tool: showStatusTool }),
});

const statusResult = Object.freeze({
content: Object.freeze([Object.freeze({
text: 'Payment latency is above the release threshold.',
Expand Down Expand Up @@ -118,7 +103,6 @@ const operations = (options: {

const mountStatus = async (overrides: Partial<Parameters<typeof mountBrowserApp>[1]> = {}) => {
const app = await mountBrowserApp('status', {
host: { context: openingHostContext },
operations: operations(),
toolDefinition: showStatusTool,
toolInput: { service: 'payments-api' },
Expand Down Expand Up @@ -149,6 +133,11 @@ const initializeResult = (app: MountedBrowserApp): unknown => {
?.message.result;
};

const openingToolResults = (app: MountedBrowserApp): readonly BrowserAppTraffic[] =>
app.traffic.filter(({ direction, message }) => (
direction === 'host-to-app' && message.method === 'ui/notifications/tool-result'
));

it('mounts the compiled panel, initializes the bridge, and renders the published result accessibly', async () => {
const app = await mountStatus();
await waitFor(() => app.document.querySelector('#status')?.textContent === 'degraded');
Expand All @@ -166,9 +155,107 @@ it('mounts the compiled panel, initializes the bridge, and renders the published
]);
expect(app.provenance).toMatchObject({ proofLevel: 'browser-app' });
expect(['claude', 'codex', 'portable']).toContain(app.provenance.target);
expect(initializeResult(app)).toMatchObject({ hostContext: { toolInfo: { tool: { name: 'show-status' } } } });
expect(initializeResult(app)).toMatchObject({ hostContext: { toolInfo: { tool: showStatusTool } } });
expect(app.traffic.some(({ message }) => message.method === 'ui/notifications/tool-input')).toBe(true);
expect(app.traffic.some(({ message }) => message.method === 'ui/notifications/tool-result')).toBe(true);
expect(app.publishToolCancelled('too late')).toBe(false);
});

it('merges caller host context with the derived opening tool information', async () => {
const app = await mountStatus({ host: { context: { locale: 'fr-FR', theme: 'dark' } } });

expect(initializeResult(app)).toMatchObject({
hostContext: {
locale: 'fr-FR',
platform: 'desktop',
theme: 'dark',
toolInfo: { tool: { name: 'show-status' } },
},
});
});

it('fills the default object input schema for a partial tool definition', async () => {
const app = await mountStatus({
toolDefinition: {
_meta: showStatusTool._meta,
name: showStatusTool.name,
},
});
await waitFor(() => app.document.querySelector('#status')?.textContent === 'degraded');

expect(initializeResult(app)).toMatchObject({
hostContext: {
toolInfo: {
tool: {
inputSchema: { type: 'object' },
name: 'show-status',
},
},
},
});
});

it('rejects caller tool information that conflicts with the opening tool', async () => {
await expect(mountStatus({
host: {
context: {
toolInfo: {
tool: {
inputSchema: { type: 'object' },
name: 'other-tool',
},
},
},
},
})).rejects.toThrow(
'host.context.toolInfo.tool.name "other-tool" conflicts with opening tool "show-status"',
);
});

it('rejects a tool name that conflicts with its definition', async () => {
await expect(mountStatus({ toolName: 'other-tool' })).rejects.toThrow(
'toolDefinition.name "show-status" must match toolName "other-tool"',
);
});

it('mounts a pending opening call and renders its cancellation', async () => {
const app = await mountStatus({ toolResult: undefined });
await waitFor(() => app.document.querySelector('#status')?.textContent === 'checking');

expect(app.document.querySelector('h1')?.textContent).toBe('payments-api');
expect(openingToolResults(app)).toHaveLength(0);
expect(app.publishToolCancelled('The user stopped the readiness check.')).toBe(true);
await waitFor(() => app.document.querySelector('#status')?.textContent === 'unavailable');

expect(app.document.querySelector('#summary')?.textContent).toBe(
'Readiness check cancelled: The user stopped the readiness check.',
);
expect(app.traffic.some(({ message }) => message.method === 'ui/notifications/tool-cancelled')).toBe(true);
expect(app.publishToolResult(statusResult)).toBe(false);
});

it('settles a pending opening call with a later result', async () => {
const app = await mountStatus({ toolResult: undefined });
await waitFor(() => app.document.querySelector('#status')?.textContent === 'checking');

expect(app.publishToolResult(statusResult)).toBe(true);
await waitFor(() => app.document.querySelector('#status')?.textContent === 'degraded');

expect(openingToolResults(app)).toHaveLength(1);
expect(app.document.querySelector('h1')?.textContent).toBe('payments-api');
});

it('settles a pending opening call with a later error', async () => {
const app = await mountStatus({ toolResult: undefined });
await waitFor(() => app.document.querySelector('#status')?.textContent === 'checking');

expect(app.publishToolResult(failedStatusResult)).toBe(true);
await waitFor(() => app.document.querySelector('#status')?.textContent === 'unavailable');

expect(openingToolResults(app)).toHaveLength(1);
expect(app.document.querySelector('#summary')?.textContent).toBe(
'Readiness is unavailable: payments-api is not reachable from this host.',
);
});

it('uses the public App client without author wildcard or ext-apps plumbing', async () => {
Expand Down Expand Up @@ -252,11 +339,6 @@ it('fails closed when a consented binding operation is unavailable', async () =>
expect(app.document.querySelector('#bridge-outcome')?.textContent).not.toBe('Status refreshed.');
});

const openingToolResults = (app: MountedBrowserApp): readonly BrowserAppTraffic[] =>
app.traffic.filter(({ direction, message }) => (
direction === 'host-to-app' && message.method === 'ui/notifications/tool-result'
));

it('exits checking and renders an unavailable outcome when the opening result is an error', async () => {
const calls: string[] = [];
const app = await mountStatus({ operations: operations({ calls }), toolResult: failedStatusResult });
Expand Down
86 changes: 58 additions & 28 deletions packages/agent-bundle/src/dev/mcp-apps/mcp-app-bridge.ts
Original file line number Diff line number Diff line change
Expand Up @@ -173,6 +173,8 @@ export interface McpAppBridgeCloseOptions {
export interface CreateMcpAppBridgeOptions {
readonly binding: McpAppBinding;
readonly consentAuthority?: McpAppConsentAuthority;
/** Leave the opening call for an explicit result or cancellation publisher to settle. */
readonly deferInitialToolResult?: boolean;
readonly host: McpAppBridgeHost;
readonly maxQueuedHostMessageBytes?: number;
readonly operations: McpAppBridgeBindingOperations;
Expand Down Expand Up @@ -304,8 +306,11 @@ const snapshotBinding = (value: McpAppBinding): BridgeBindingSnapshot => {
const snapshotHost = (host: McpAppBridgeHost): McpAppBridgeHost => {
if (!nonempty(host.info?.name) || !nonempty(host.info?.version)) throw new TypeError('MCP App host info must contain nonempty name and version values.');
const capabilities = host.capabilities === undefined ? Object.freeze({}) : validHostCapabilities(host.capabilities);
const context = host.context === undefined ? Object.freeze({}) : validHostContext(host.context);
if (capabilities === undefined || context === undefined) throw new TypeError('MCP App host context must use stable MCP Apps field values.');
if (capabilities === undefined) throw new TypeError('MCP App host capabilities must use stable MCP Apps field values.');
const [context, contextError] = host.context === undefined
? [Object.freeze({}), undefined] as const
: validHostContext(host.context);
if (context === undefined) throw new TypeError(contextError);
return Object.freeze({
...host,
capabilities,
Expand Down Expand Up @@ -491,60 +496,85 @@ const validObjectJsonSchema = (value: unknown): boolean => {
return schema.required === undefined || (Array.isArray(schema.required) && schema.required.every((required) => typeof required === 'string'));
};

const validToolDefinition = (value: unknown): boolean => {
const invalidToolDefinitionField = (value: unknown): string | undefined => {
const tool = jsonRecord(value);
if (tool === undefined || !nonempty(tool.name) || !validObjectJsonSchema(tool.inputSchema)) return false;
if (tool.outputSchema !== undefined && !validObjectJsonSchema(tool.outputSchema)) return false;
if (tool.icons !== undefined && !validIcons(tool.icons)) return false;
if (tool.title !== undefined && typeof tool.title !== 'string') return false;
if (tool.description !== undefined && typeof tool.description !== 'string') return false;
if (tool === undefined) return 'tool';
if (!nonempty(tool.name)) return 'tool.name';
if (!validObjectJsonSchema(tool.inputSchema)) return 'tool.inputSchema';
if (tool.outputSchema !== undefined && !validObjectJsonSchema(tool.outputSchema)) return 'tool.outputSchema';
if (tool.icons !== undefined && !validIcons(tool.icons)) return 'tool.icons';
if (tool.title !== undefined && typeof tool.title !== 'string') return 'tool.title';
if (tool.description !== undefined && typeof tool.description !== 'string') return 'tool.description';
if (tool.annotations !== undefined) {
const annotations = jsonRecord(tool.annotations);
if (annotations === undefined
|| (annotations.title !== undefined && !nonempty(annotations.title))
|| ['readOnlyHint', 'destructiveHint', 'idempotentHint', 'openWorldHint'].some((key) => annotations[key] !== undefined && typeof annotations[key] !== 'boolean')) return false;
|| ['readOnlyHint', 'destructiveHint', 'idempotentHint', 'openWorldHint'].some((key) => annotations[key] !== undefined && typeof annotations[key] !== 'boolean')) return 'tool.annotations';
}
if (tool.execution !== undefined) {
const execution = jsonRecord(tool.execution);
if (execution === undefined || (execution.taskSupport !== undefined && execution.taskSupport !== 'required' && execution.taskSupport !== 'optional' && execution.taskSupport !== 'forbidden')) return false;
if (execution === undefined || (execution.taskSupport !== undefined && execution.taskSupport !== 'required' && execution.taskSupport !== 'optional' && execution.taskSupport !== 'forbidden')) return 'tool.execution';
}
return true;
return undefined;
};

const validHostContext = (value: unknown): McpAppBridgeJsonRecord | undefined => {
type HostContextValidation = readonly [
context: McpAppBridgeJsonRecord | undefined,
error: string | undefined,
];

const invalidHostContext = (field: string): HostContextValidation => [
undefined,
`MCP App host context.${field} must use a valid MCP Apps field value.`,
];

const validHostContext = (value: unknown): HostContextValidation => {
const context = jsonRecord(value);
if (context === undefined) return undefined;
if (context.theme !== undefined && context.theme !== 'light' && context.theme !== 'dark') return undefined;
if (context.displayMode !== undefined && (typeof context.displayMode !== 'string' || !displayModes.has(context.displayMode as McpAppBridgeDisplayMode))) return undefined;
if (context.availableDisplayModes !== undefined && validDisplayModeList(context.availableDisplayModes) === undefined) return undefined;
if (context.locale !== undefined && !nonempty(context.locale)) return undefined;
if (context.timeZone !== undefined && !nonempty(context.timeZone)) return undefined;
if (context.userAgent !== undefined && !nonempty(context.userAgent)) return undefined;
if (context.platform !== undefined && context.platform !== 'web' && context.platform !== 'desktop' && context.platform !== 'mobile') return undefined;
if (context === undefined) return [undefined, 'MCP App host context must use stable MCP Apps field values.'];
if (context.theme !== undefined && context.theme !== 'light' && context.theme !== 'dark') {
return [undefined, 'MCP App host context.theme must be "light" or "dark".'];
}
if (context.displayMode !== undefined && (typeof context.displayMode !== 'string' || !displayModes.has(context.displayMode as McpAppBridgeDisplayMode))) return invalidHostContext('displayMode');
if (context.availableDisplayModes !== undefined && validDisplayModeList(context.availableDisplayModes) === undefined) return invalidHostContext('availableDisplayModes');
if (context.locale !== undefined && !nonempty(context.locale)) return invalidHostContext('locale');
if (context.timeZone !== undefined && !nonempty(context.timeZone)) return invalidHostContext('timeZone');
if (context.userAgent !== undefined && !nonempty(context.userAgent)) return invalidHostContext('userAgent');
if (context.platform !== undefined && context.platform !== 'web' && context.platform !== 'desktop' && context.platform !== 'mobile') return invalidHostContext('platform');
if (context.toolInfo !== undefined) {
const toolInfo = jsonRecord(context.toolInfo);
if (toolInfo === undefined || !validToolDefinition(toolInfo.tool) || (toolInfo.id !== undefined && !isRequestId(toolInfo.id))) return undefined;
if (toolInfo === undefined) return invalidHostContext('toolInfo');
const invalidToolField = invalidToolDefinitionField(toolInfo.tool);
if (invalidToolField === 'tool.inputSchema' || invalidToolField === 'tool.outputSchema') {
return [
undefined,
`MCP App host context.toolInfo.${invalidToolField} must be an object-rooted JSON Schema.`,
];
}
if (invalidToolField !== undefined) return invalidHostContext(`toolInfo.${invalidToolField}`);
if (toolInfo.id !== undefined && !isRequestId(toolInfo.id)) {
return [undefined, 'MCP App host context.toolInfo.id must be a JSON-RPC request id.'];
}
}
if (context.deviceCapabilities !== undefined) {
const device = jsonRecord(context.deviceCapabilities);
if (device === undefined || (device.touch !== undefined && typeof device.touch !== 'boolean') || (device.hover !== undefined && typeof device.hover !== 'boolean')) return undefined;
if (device === undefined || (device.touch !== undefined && typeof device.touch !== 'boolean') || (device.hover !== undefined && typeof device.hover !== 'boolean')) return invalidHostContext('deviceCapabilities');
}
if (context.styles !== undefined) {
const styles = jsonRecord(context.styles);
const variables = styles === undefined || styles.variables === undefined ? undefined : jsonRecord(styles.variables);
const css = styles === undefined || styles.css === undefined ? undefined : jsonRecord(styles.css);
if (styles === undefined || (styles.variables !== undefined && (variables === undefined || !Object.entries(variables).every(([key, variable]) => hostStyleVariables.has(key) && typeof variable === 'string')))
|| (styles.css !== undefined && (css === undefined || (css.fonts !== undefined && typeof css.fonts !== 'string')))) return undefined;
|| (styles.css !== undefined && (css === undefined || (css.fonts !== undefined && typeof css.fonts !== 'string')))) return invalidHostContext('styles');
}
if (context.containerDimensions !== undefined) {
const dimensions = jsonRecord(context.containerDimensions);
if (dimensions === undefined || !['height', 'maxHeight', 'width', 'maxWidth'].every((key) => dimensions[key] === undefined || (typeof dimensions[key] === 'number' && Number.isFinite(dimensions[key]) && dimensions[key] >= 0))) return undefined;
if (dimensions === undefined || !['height', 'maxHeight', 'width', 'maxWidth'].every((key) => dimensions[key] === undefined || (typeof dimensions[key] === 'number' && Number.isFinite(dimensions[key]) && dimensions[key] >= 0))) return invalidHostContext('containerDimensions');
}
if (context.safeAreaInsets !== undefined) {
const insets = jsonRecord(context.safeAreaInsets);
if (insets === undefined || !['top', 'right', 'bottom', 'left'].every((key) => typeof insets[key] === 'number' && Number.isFinite(insets[key]) && insets[key] >= 0)) return undefined;
if (insets === undefined || !['top', 'right', 'bottom', 'left'].every((key) => typeof insets[key] === 'number' && Number.isFinite(insets[key]) && insets[key] >= 0)) return invalidHostContext('safeAreaInsets');
}
return context;
return [context, undefined];
};

const validResourceMetadata = (value: unknown): McpAppBridgeJsonRecord | undefined => {
Expand Down Expand Up @@ -1373,7 +1403,7 @@ export const createMcpAppBridge = (options: CreateMcpAppBridgeOptions): McpAppBr
}
},
publishHostContextChanged(context: McpAppBridgeJsonRecord): boolean {
const snapshot = validHostContext(context);
const [snapshot] = validHostContext(context);
if (snapshot === undefined) return false;
const availableDisplayModes = snapshot.availableDisplayModes === undefined ? undefined : validDisplayModeList(snapshot.availableDisplayModes);
const published = emitHost(Object.freeze({ jsonrpc: '2.0', method: 'ui/notifications/host-context-changed', params: snapshot }));
Expand Down Expand Up @@ -1452,7 +1482,7 @@ export const createMcpAppBridge = (options: CreateMcpAppBridgeOptions): McpAppBr
}
inputQueued = true;
}
if (!terminalQueued) {
if (!terminalQueued && options.deferInitialToolResult !== true) {
Comment thread
ScriptedAlchemy marked this conversation as resolved.
const resultMessage = Object.freeze({
jsonrpc: '2.0',
method: 'ui/notifications/tool-result',
Expand Down
Loading
Loading