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
14 changes: 14 additions & 0 deletions .changeset/browser-app-harness.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
---
"agent-bundle": minor
---

Add the browser-app proof level to the consumer test harness (#103 stage 3).
`agentBundleBrowserRstest()` from `agent-bundle/rstest` compiles every declared
MCP App once per pool run through the production Rsbuild profile and configures
an Rstest browser pool; the new browser-safe `agent-bundle/test/browser`
subpath ships `mountBrowserApp`, which mounts the compiled self-contained HTML
in a sandboxed iframe over the product's own MCP App bridge with test-supplied
binding operations, consent decisions, and captured traffic. The test manifest
now carries collision-checked MCP App descriptors from the same compiler pass,
and `compileMcpApps` accepts a per-app target selection alongside the existing
single-target form.
5 changes: 5 additions & 0 deletions examples/mcp-app/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -6,12 +6,17 @@
"build": "agent-bundle build",
"check": "pnpm validate && pnpm build",
"dev": "agent-bundle dev",
"test:browser-app": "rstest --config rstest.browser-app.config.ts",
"validate": "agent-bundle validate"
},
"devDependencies": {
"@modelcontextprotocol/ext-apps": "1.7.5",
"@modelcontextprotocol/server": "2.0.0",
"@rstest/browser": "0.11.10",
"@rstest/core": "0.11.10",
"@rstest/playwright": "0.11.10",
"agent-bundle": "workspace:*",
"playwright": "1.62.1",
"zod": "4.4.3"
}
}
4 changes: 4 additions & 0 deletions examples/mcp-app/rstest.browser-app.config.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
import { defineConfig } from '@rstest/core';
import { agentBundleBrowserRstest } from 'agent-bundle/rstest';

export default defineConfig(await agentBundleBrowserRstest());
134 changes: 134 additions & 0 deletions examples/mcp-app/tests/browser-app/status-panel.browser.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,134 @@
import { afterEach, expect, it } from '@rstest/core';

import { mountBrowserApp, type MountedBrowserApp } from 'agent-bundle/test/browser';

const statusResult = Object.freeze({
content: Object.freeze([Object.freeze({
text: 'Payment latency is above the release threshold.',
type: 'text',
})]),
structuredContent: Object.freeze({
checks: Object.freeze([
Object.freeze({ label: 'Availability', status: 'passing' }),
Object.freeze({ label: 'P95 latency', status: 'failing' }),
]),
service: 'payments-api',
status: 'degraded',
summary: 'Payment latency is above the release threshold.',
}),
});

const mounted: MountedBrowserApp[] = [];

afterEach(async () => {
await Promise.all(mounted.splice(0).map((app) => app.dispose()));
});

const waitFor = async (predicate: () => boolean, timeoutMs = 2_000): Promise<void> => {
const deadline = Date.now() + timeoutMs;
while (!predicate()) {
if (Date.now() >= deadline) throw new Error('Timed out waiting for the status panel.');
await new Promise((resolve) => setTimeout(resolve, 10));
}
};

const operations = (options: {
readonly callTool?: () => Promise<unknown>;
readonly calls?: string[];
readonly reads?: string[];
} = {}) => ({
callTool: async (_bindingId: string, request: { readonly name: string }) => {
options.calls?.push(request.name);
return options.callTool?.() ?? statusResult;
},
closeBinding: async () => true,
readResource: async (_bindingId: string, request: { readonly uri: string }) => {
options.reads?.push(request.uri);
return {
contents: [{
mimeType: 'text/plain',
text: 'Only passing checks permit release.',
uri: request.uri,
}],
};
},
});

const mountStatus = async (overrides: Partial<Parameters<typeof mountBrowserApp>[1]> = {}) => {
const app = await mountBrowserApp('status', {
operations: operations(),
toolInput: { service: 'payments-api' },
toolResult: statusResult,
...overrides,
});
mounted.push(app);
return app;
};

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');

expect(app.bridge.lifecycle).toBe('initialized');
expect(app.document.querySelector('main')).not.toBeNull();
expect(app.document.querySelector('h1')?.textContent).toBe('payments-api');
expect(app.document.querySelector('[aria-label="Service checks"]')).not.toBeNull();
expect(app.document.querySelectorAll('#checks li')).toHaveLength(2);
expect(app.provenance).toMatchObject({ proofLevel: 'browser-app', target: 'portable' });
expect(app.traffic.some(({ message }) => message.method === 'ui/notifications/tool-result')).toBe(true);
});

it('round-trips a resource read from the real App through binding operations', async () => {
const reads: string[] = [];
const app = await mountStatus({ operations: operations({ reads }) });

app.document.querySelector<HTMLButtonElement>('#read-policy')!.click();
await waitFor(() => app.document.querySelector('#bridge-outcome')?.textContent?.includes('passing checks') === true);

expect(reads).toEqual(['ui://mcp-app-example/readiness-policy']);
expect(app.traffic.some(({ message }) => message.method === 'resources/read')).toBe(true);
});

it('holds a tool call for consent, resumes approval once, and denies without calling the binding', async () => {
const approvedCalls: string[] = [];
const approved = await mountStatus({ operations: operations({ calls: approvedCalls }) });
approved.document.querySelector<HTMLButtonElement>('#refresh-status')!.click();
await waitFor(() => approved.pendingConsentChallenges.length === 1);

const challenge = approved.pendingConsentChallenges[0]!;
expect(challenge.request.capability).toBe('call-tool');
expect(approvedCalls).toEqual([]);
await expect(approved.decideConsent(challenge.id, true)).resolves.toBe(true);
await waitFor(() => approved.document.querySelector('#bridge-outcome')?.textContent === 'Status refreshed.');
expect(approvedCalls).toEqual(['refresh-status']);

const deniedCalls: string[] = [];
const denied = await mountStatus({ operations: operations({ calls: deniedCalls }) });
denied.document.querySelector<HTMLButtonElement>('#refresh-status')!.click();
await waitFor(() => denied.pendingConsentChallenges.length === 1);
await expect(denied.decideConsent(denied.pendingConsentChallenges[0]!.id, false)).resolves.toBe(true);
await waitFor(() => denied.document.querySelector('#bridge-outcome')?.textContent === 'Refresh unavailable.');

expect(deniedCalls).toEqual([]);
expect(denied.traffic.some(({ message }) => message.error?.code === -32001)).toBe(true);
});

it('fails closed when a consented binding operation is unavailable', async () => {
const calls: string[] = [];
const app = await mountStatus({
operations: operations({
calls,
callTool: async () => {
throw new Error('unavailable');
},
}),
});
app.document.querySelector<HTMLButtonElement>('#refresh-status')!.click();
await waitFor(() => app.pendingConsentChallenges.length === 1);
await app.decideConsent(app.pendingConsentChallenges[0]!.id, true);
await waitFor(() => app.document.querySelector('#bridge-outcome')?.textContent === 'Refresh unavailable.');

expect(calls).toEqual(['refresh-status']);
expect(app.traffic.some(({ message }) => message.error?.code === -32000)).toBe(true);
expect(app.document.querySelector('#bridge-outcome')?.textContent).not.toBe('Status refreshed.');
});
3 changes: 3 additions & 0 deletions examples/mcp-app/views/status-panel.html
Original file line number Diff line number Diff line change
Expand Up @@ -55,7 +55,10 @@ <h1 id="service">No service selected</h1>
<p id="summary">Invoke the readiness tool to inspect a service.</p>
<ul aria-label="Service checks" class="checks" id="checks"></ul>
<button id="toggle-details" type="button">Toggle details</button>
<button id="read-policy" type="button">Read readiness policy</button>
<button id="refresh-status" type="button">Refresh status</button>
<p id="details" hidden>The result arrived through the official MCP Apps bridge.</p>
<p aria-live="polite" id="bridge-outcome"></p>
</main>
</body>
</html>
25 changes: 25 additions & 0 deletions examples/mcp-app/views/status-panel.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ const statusIndicator = document.querySelector<HTMLElement>('#status-indicator')
const status = document.querySelector<HTMLElement>('#status')!;
const summary = document.querySelector<HTMLParagraphElement>('#summary')!;
const checks = document.querySelector<HTMLUListElement>('#checks')!;
const bridgeOutcome = document.querySelector<HTMLParagraphElement>('#bridge-outcome')!;

type StatusState = 'checking' | 'healthy' | 'degraded' | 'unknown';

Expand Down Expand Up @@ -71,4 +72,28 @@ document.querySelector('#toggle-details')!.addEventListener('click', () => {
document.querySelector('#details')!.toggleAttribute('hidden');
});

document.querySelector('#read-policy')!.addEventListener('click', async () => {
try {
const result = await app.readServerResource({ uri: 'ui://mcp-app-example/readiness-policy' });
const content = result.contents[0];
bridgeOutcome.textContent = content !== undefined && 'text' in content
? content.text
: 'Readiness policy unavailable.';
} catch {
bridgeOutcome.textContent = 'Readiness policy unavailable.';
}
});

document.querySelector('#refresh-status')!.addEventListener('click', async () => {
try {
const result = await app.callServerTool({
arguments: { service: serviceHeading.textContent ?? 'service' },
name: 'refresh-status',
});
bridgeOutcome.textContent = result.isError === true ? 'Refresh unavailable.' : 'Status refreshed.';
} catch {
bridgeOutcome.textContent = 'Refresh unavailable.';
}
});

await app.connect(new PostMessageTransport(window.parent, window.parent));
4 changes: 4 additions & 0 deletions packages/agent-bundle/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -72,6 +72,10 @@
"./test": {
"types": "./dist/test/index.d.ts",
"import": "./dist/test.js"
},
"./test/browser": {
"types": "./dist/test/browser.d.ts",
"import": "./dist/test/browser.js"
}
},
"dependencies": {
Expand Down
1 change: 1 addition & 0 deletions packages/agent-bundle/rslib.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,7 @@ export default defineConfig({
'mcp-server-runtime': './src/mcp-server-runtime.ts',
rstest: './src/rstest/index.ts',
test: './src/test/index.ts',
'test/browser': './src/test/browser.ts',
},
},
});
36 changes: 26 additions & 10 deletions packages/agent-bundle/src/build/mcp-apps.ts
Original file line number Diff line number Diff line change
Expand Up @@ -90,12 +90,26 @@ const appIdentity = (app: NormalizedMcpApp): string => stableJson({
...(app.template === undefined ? {} : { template: app.template }),
});

export type McpAppTargetSelection =
| Readonly<{ readonly target: string; readonly targets?: never }>
| Readonly<{ readonly target?: never; readonly targets: Readonly<Record<string, string>> }>;

const selectedAppTarget = (
app: NormalizedMcpApp,
selection: McpAppTargetSelection,
): string | undefined => {
const target = selection.target ?? selection.targets[app.id];
return target !== undefined && app.targets.includes(target) ? target : undefined;
};

export const planCompiledMcpApps = (
apps: readonly NormalizedMcpApp[],
options: { readonly outDir: string; readonly target: string },
options: Readonly<{ readonly outDir: string } & McpAppTargetSelection>,
): readonly CompiledMcpApp[] => {
const planned = new Map<string, { identity: string; serverIds: string[]; app: NormalizedMcpApp }>();
for (const app of apps.filter((candidate) => candidate.prebuilt !== true && candidate.targets.includes(options.target))) {
const planned = new Map<string, { identity: string; serverIds: string[]; app: NormalizedMcpApp; target: string }>();
for (const app of apps) {
const target = selectedAppTarget(app, options);
if (app.prebuilt === true || target === undefined) continue;
const identity = appIdentity(app);
const existing = planned.get(app.name);
if (existing !== undefined) {
Expand All @@ -108,9 +122,9 @@ export const planCompiledMcpApps = (
if (!existing.serverIds.includes(app.serverId)) existing.serverIds.push(app.serverId);
continue;
}
planned.set(app.name, { app, identity, serverIds: [app.serverId] });
planned.set(app.name, { app, identity, serverIds: [app.serverId], target });
}
return Object.freeze([...planned.values()].map(({ app, serverIds }) => Object.freeze({
return Object.freeze([...planned.values()].map(({ app, serverIds, target }) => Object.freeze({
...(app._meta === undefined ? {} : { _meta: app._meta }),
id: app.id,
mimeType: mcpAppMimeType,
Expand All @@ -124,7 +138,7 @@ export const planCompiledMcpApps = (
app.source,
...(app.template === undefined ? [] : [app.template]),
]),
target: options.target,
target,
})));
};

Expand Down Expand Up @@ -182,14 +196,16 @@ export const composeMcpAppsRsbuildConfig = (

export const compileMcpApps = async (
apps: readonly NormalizedMcpApp[],
options: {
options: Readonly<{
readonly cwd: string;
readonly outDir: string;
readonly target: string;
readonly tools?: AgentBundleToolsConfig;
},
} & McpAppTargetSelection>,
): Promise<readonly CompiledMcpApp[]> => {
const compiled = planCompiledMcpApps(apps, { outDir: options.outDir, target: options.target });
const compiled = planCompiledMcpApps(apps, {
outDir: options.outDir,
...(options.target === undefined ? { targets: options.targets } : { target: options.target }),
});
if (compiled.length === 0) {
return compiled;
}
Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,3 @@
import { randomUUID } from 'node:crypto';

import { MCP_APP_PROFILE_DESCRIPTORS, type McpAppProfileId } from '../mcp-app-profile-descriptors.ts';

export type McpAppJsonValue =
Expand Down Expand Up @@ -218,7 +216,7 @@ export class McpAppBindingService {
const toolDefinition = requireJson(tool.definition, 'MCP App leased tool definition') as McpAppToolDefinition;
const binding = Object.freeze({
epochId: requireNonempty(identity.epochId, 'MCP App epoch id'),
id: randomUUID(),
id: crypto.randomUUID(),
input,
previewProfile,
resourceUri,
Expand Down
Loading
Loading