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/serve-app-opening-call.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"agent-bundle": patch
---

Fix `agent-bundle serve-app` (and `serveApp`) showing the "ordinary tool result" fallback with `AB8010: Request body exceeds 64 KiB` instead of the App when the opening tool's result is large, and give a served App one scrollbar instead of three nested ones (the host page, the MCP App sandbox document, and the Runtime App surface proxy no longer scroll around it). (#565)
41 changes: 36 additions & 5 deletions packages/agent-bundle/src/dev/mcp-apps/mcp-app-routes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -92,8 +92,23 @@ export interface McpAppRoutePreviewService {
readonly runtime?: McpAppRuntimeRoutePreviewService;
}

/** The tool call a host already made for a session, which a page may bind without re-sending it. */
export interface McpAppOpeningCall {
readonly input: McpAppJsonValue;
readonly result: McpAppJsonValue;
}

export interface McpAppRoutesOptions {
readonly authorize: (request: IncomingMessage) => void;
/**
* The tool call the host performed itself when it opened a session (the
* standalone `serve-app` host calls the opening tool once and seeds its
* page with the result). A create request that omits `input` and `result`
* binds to this call, so a large result is never round-tripped through the
* browser and past the request-body bound (#562); without it, both fields
* are required, as the Workbench sends them.
*/
readonly openingCall?: (sessionId: string, toolName: string) => McpAppOpeningCall | undefined;
/**
* Test-only override for the graceful-close receipt window. Production
* callers must leave this unset so the window keeps dominating the frame
Expand Down Expand Up @@ -292,16 +307,30 @@ const hostContext = (value: unknown): McpAppPreviewHostContext => {
});
};

const createRequest = (value: JsonObject, sessionId: string): Parameters<McpAppRoutePreviewService['create']>[0] => {
const createRequest = (
value: JsonObject,
sessionId: string,
openingCall: McpAppRoutesOptions['openingCall'],
): Parameters<McpAppRoutePreviewService['create']>[0] => {
if (!hasOnly(value, ['host', 'input', 'previewProfile', 'result', 'toolName']) || !nonemptyString(value.toolName)
|| !isJsonValue(value.input) || !isJsonValue(value.result) || (value.previewProfile !== 'portable' && value.previewProfile !== 'chatgpt' && value.previewProfile !== 'claude')) {
|| (value.previewProfile !== 'portable' && value.previewProfile !== 'chatgpt' && value.previewProfile !== 'claude')) {
return invalidShape();
}
// A request carrying neither field binds the call the host already made;
// one carrying both is the Workbench's own tool run. Anything in between
// is malformed.
const carriesCall = Object.hasOwn(value, 'input') || Object.hasOwn(value, 'result');
const call = carriesCall
? isJsonValue(value.input) && isJsonValue(value.result)
? { input: cloneJson(value.input), result: cloneJson(value.result) }
: undefined
: openingCall?.(sessionId, value.toolName);
if (call === undefined) return invalidShape();
return Object.freeze({
host: hostContext(value.host),
input: cloneJson(value.input),
input: call.input,
previewProfile: value.previewProfile,
result: cloneJson(value.result),
result: call.result,
sessionId,
toolName: value.toolName,
});
Expand Down Expand Up @@ -385,6 +414,7 @@ const bridgeHostContext = (host: McpAppPreviewHostContext): McpAppBridgeJsonReco
export class McpAppRoutes {
readonly #authorize: (request: IncomingMessage) => void;
readonly #gracefulCloseReceiptTimeoutMs: number;
readonly #openingCall: McpAppRoutesOptions['openingCall'];
readonly #service: McpAppRoutePreviewService | undefined;
readonly #tails = new Map<string, Promise<void>>();
readonly #teardowns = new Map<string, ReturnType<typeof setTimeout>>();
Expand All @@ -393,6 +423,7 @@ export class McpAppRoutes {
constructor(options: McpAppRoutesOptions) {
this.#authorize = options.authorize;
this.#gracefulCloseReceiptTimeoutMs = options.gracefulCloseReceiptTimeoutMs ?? gracefulCloseReceiptTimeoutMs;
this.#openingCall = options.openingCall;
this.#service = options.service;
}

Expand Down Expand Up @@ -432,7 +463,7 @@ export class McpAppRoutes {
if (isRuntimeRoute(parsed)) return this.#dispatchRuntime(parsed, request, response, service.runtime);
if (parsed.kind === 'create') {
if (method !== 'POST') return responseDiagnostic(response, diagnostic('AB8007', 'Route does not accept this method.', 405));
const preview = await service.create(createRequest(await jsonBody(request), parsed.sessionId));
const preview = await service.create(createRequest(await jsonBody(request), parsed.sessionId, this.#openingCall));
return writeJsonResponse(response, { lifecycle: preview.bridge.lifecycle, preview: previewSnapshot(preview) });
}
if (parsed.kind === 'force-close') {
Expand Down
2 changes: 1 addition & 1 deletion packages/agent-bundle/src/dev/mcp-apps/mcp-app-sandbox.ts
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,7 @@ const SHELL = `<!doctype html>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>MCP App sandbox</title>
<style>html,body,iframe{border:0;height:100%;margin:0;width:100%}</style>
<style>html,body{height:100%;margin:0;overflow:hidden}iframe{border:0;display:block;height:100%;width:100%}</style>
<iframe id="app" sandbox="allow-scripts" referrerpolicy="no-referrer"></iframe>
<script>
'use strict';
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -182,7 +182,7 @@ const runtimeProxyShell = (
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Runtime App surface</title>
<style>html,body,iframe{border:0;height:100%;margin:0;width:100%}</style>
<style>html,body{height:100%;margin:0;overflow:hidden}iframe{border:0;display:block;height:100%;width:100%}</style>
<iframe id="app" sandbox="allow-scripts" referrerpolicy="no-referrer"></iframe>
<script>
'use strict';
Expand Down
8 changes: 5 additions & 3 deletions packages/agent-bundle/src/serve-app/serve-app-page.ts
Original file line number Diff line number Diff line change
Expand Up @@ -109,8 +109,10 @@ const browserHostContext = () => ({

const start = async () => {
setStatus('Binding ' + seed.toolName + ' to the App…');
// The host already made the opening call; binding it by tool name keeps a
// large result from travelling back through the request-body bound (#562).
const created = await api('POST', '/api/mcp/sessions/' + encodeURIComponent(seed.sessionId) + '/apps', {
host: browserHostContext(), input: seed.input, previewProfile: seed.previewProfile, result: seed.result, toolName: seed.toolName,
host: browserHostContext(), previewProfile: seed.previewProfile, toolName: seed.toolName,
});
const preview = created.preview;
const bindingId = preview.bindingId;
Expand Down Expand Up @@ -273,7 +275,7 @@ start().catch((error) => {

const HOST_STYLE = `
:root { color-scheme: light dark; font: 14px/1.4 system-ui, sans-serif; }
html, body { height: 100%; margin: 0; }
html, body { height: 100%; margin: 0; overflow: hidden; }
body { display: flex; flex-direction: column; background: Canvas; color: CanvasText; }
header { align-items: center; border-bottom: 1px solid color-mix(in srgb, CanvasText 15%, transparent); display: flex; gap: 12px; padding: 8px 16px; }
header h1 { font-size: 15px; font-weight: 600; margin: 0; }
Expand All @@ -287,7 +289,7 @@ header h1 { font-size: 15px; font-weight: 600; margin: 0; }
#consent li { align-items: center; display: flex; flex-wrap: wrap; gap: 8px; }
#consent code { font-size: 12px; opacity: 0.8; overflow: hidden; text-overflow: ellipsis; max-width: 40ch; white-space: nowrap; }
#frame-host { flex: 1; min-height: 0; }
#frame-host iframe { border: 0; height: 100%; width: 100%; }
#frame-host iframe { border: 0; display: block; height: 100%; width: 100%; }
#fallback { overflow: auto; padding: 16px; }
#fallback pre { background: color-mix(in srgb, CanvasText 6%, transparent); overflow: auto; padding: 8px; }
`;
Expand Down
9 changes: 8 additions & 1 deletion packages/agent-bundle/src/serve-app/serve-mcp-app.ts
Original file line number Diff line number Diff line change
Expand Up @@ -486,8 +486,15 @@ const serveProgram = (options: ServeMcpAppOptions): Effect.Effect<ServedMcpAppSh
throw requestError(diagnostic('AB8004', 'A valid MCP App host token is required.', 403));
}
};
// The page binds the opening call this host already made instead of
// posting the result back: a large result would otherwise exceed the
// request-body bound and drop the App to the fallback panel (#562).
const openingCall = (sessionId: string, toolName: string) =>
sessionId === session.sessionId && toolName === selection.tool.name
? Object.freeze({ input: selection.input, result: selection.result })
: undefined;
const routes = yield* Effect.acquireRelease(
Effect.sync(() => new McpAppRoutes({ authorize, service: previews })),
Effect.sync(() => new McpAppRoutes({ authorize, openingCall, service: previews })),
(created) => Effect.sync(() => { created.close(); }),
);
const page = renderServeAppPage({
Expand Down
62 changes: 62 additions & 0 deletions packages/agent-bundle/tests/mcp-app-routes.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import { expect, it } from '@rstest/core';
import {
McpAppRoutes,
type McpAppRoutePreviewService,
type McpAppRoutesOptions,
} from '../src/dev/mcp-apps/mcp-app-routes.ts';
import { runtimeAppMessageLimits } from '../src/dev/runtime-app-message-limits.ts';
import { McpAppRuntimePreviewError } from '../src/dev/mcp-app-runtime-preview-service.ts';
Expand Down Expand Up @@ -152,10 +153,12 @@ class RecordingPreviewService implements McpAppRoutePreviewService {
const startRoutes = async (
service = new RecordingPreviewService(),
gracefulCloseReceiptTimeoutMs?: number,
openingCall?: McpAppRoutesOptions['openingCall'],
): Promise<StartedRoutes> => {
const routes = new McpAppRoutes({
authorize,
...(gracefulCloseReceiptTimeoutMs === undefined ? {} : { gracefulCloseReceiptTimeoutMs }),
...(openingCall === undefined ? {} : { openingCall }),
service,
});
const server = createServer((request, response) => {
Expand Down Expand Up @@ -604,6 +607,65 @@ it('creates an App preview from only session-scoped JSON data', async () => {
}
});

it('binds the host\'s own opening call when a create request omits input and result (#562)', async () => {
// A result far past the 64 KiB request-body bound: the host holds it, so the
// page never sends it back.
const large = { structuredContent: { rows: Array.from({ length: 4000 }, (_, index) => ({ index, text: 'x'.repeat(24) })) } };
expect(Buffer.byteLength(JSON.stringify(large))).toBeGreaterThan(64 * 1024);
const service = new RecordingPreviewService();
const started = await startRoutes(service, undefined, (sessionId, toolName) =>
sessionId === 'session-a' && toolName === 'show-weather' ? { input: { city: 'Oslo' }, result: large } : undefined);
try {
const bound = await fetch(`${started.url}/api/mcp/sessions/session-a/apps`, {
body: JSON.stringify({ host, previewProfile: 'portable', toolName: 'show-weather' }),
headers: { ...headers(), 'content-type': 'application/json' },
method: 'POST',
});
expect(bound.status).toBe(200);
expect(service.calls).toEqual([{
kind: 'create',
options: { host, input: { city: 'Oslo' }, previewProfile: 'portable', result: large, sessionId: 'session-a', toolName: 'show-weather' },
}]);

// Another tool, or a session the host did not open, has no call to bind.
for (const body of [
{ host, previewProfile: 'portable', toolName: 'other-tool' },
{ host, input: { city: 'Oslo' }, previewProfile: 'portable', toolName: 'show-weather' },
]) {
const response = await fetch(`${started.url}/api/mcp/sessions/session-a/apps`, {
body: JSON.stringify(body),
headers: { ...headers(), 'content-type': 'application/json' },
method: 'POST',
});
expect(response.status).toBe(400);
await expect(response.json()).resolves.toEqual({ diagnostic: { code: 'AB8021', message: 'MCP App request has an invalid shape.' } });
}
const otherSession = await fetch(`${started.url}/api/mcp/sessions/session-b/apps`, {
body: JSON.stringify({ host, previewProfile: 'portable', toolName: 'show-weather' }),
headers: { ...headers(), 'content-type': 'application/json' },
method: 'POST',
});
expect(otherSession.status).toBe(400);
expect(service.calls).toHaveLength(1);
} finally {
await started.close();
}

// Without a host-made call, the Workbench shape stays required.
const plain = await startRoutes();
try {
const response = await fetch(`${plain.url}/api/mcp/sessions/session-a/apps`, {
body: JSON.stringify({ host, previewProfile: 'portable', toolName: 'show-weather' }),
headers: { ...headers(), 'content-type': 'application/json' },
method: 'POST',
});
expect(response.status).toBe(400);
expect(plain.service.calls).toEqual([]);
} finally {
await plain.close();
}
});

it('rejects obsolete browser-created document consent on preview creation', async () => {
const started = await startRoutes();
try {
Expand Down
7 changes: 5 additions & 2 deletions packages/agent-bundle/tests/serve-app.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -196,9 +196,12 @@ it('serves the MCP App example standalone over its packed server and relays the
return json;
};

// Binding the App through the same preview service the Workbench uses.
// Binding the App through the same preview service the Workbench uses —
// by tool name alone, as the page does: the host already made the opening
// call, so its result never crosses the request-body bound (#562).
expect(html).not.toContain('result: seed.result, toolName');
const created = await api('POST', `/api/mcp/sessions/${encodeURIComponent(seed.sessionId)}/apps`, {
host: browserHost, input: seed.input, previewProfile: seed.previewProfile, result: seed.result, toolName: seed.toolName,
host: browserHost, previewProfile: seed.previewProfile, toolName: seed.toolName,
});
const preview = created.preview as {
readonly bindingId: string;
Expand Down
9 changes: 9 additions & 0 deletions packages/workbench/tests/mcp-app-real.e2e.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -334,6 +334,15 @@ e2e('runs a generated SDK-v2 App through the real foreground session and separat
if (initialAppFrame === undefined) throw new Error('Expected the sandbox proxy to create the App srcdoc frame.');
const initialAppState = initialAppFrame.getByTestId('app-state');
await expect(initialAppState).toContainText('real-sdk-v2', { timeout: browserTimeout });
// The sandbox proxy document owns no scrollbar of its own (#565): its App
// frame is a block filling the document, so only the App itself scrolls.
const proxyFrame = page.frames().find((frame) => frame.url().startsWith(sandboxOrigin));
if (proxyFrame === undefined) throw new Error('Expected the sandbox proxy frame on the sandbox origin.');
await expect.poll(() => proxyFrame.evaluate(() => ({
bodyOverflow: getComputedStyle(document.body).overflow,
frameDisplay: getComputedStyle(document.getElementById('app')!).display,
scrolls: document.documentElement.scrollHeight > document.documentElement.clientHeight,
})), { timeout: browserTimeout }).toEqual({ bodyOverflow: 'hidden', frameDisplay: 'block', scrolls: false });
const consentDecisions = () => consentSnapshots.filter((snapshot) =>
snapshot !== null && typeof snapshot === 'object' && Object.hasOwn(snapshot, 'approved'));
const consentDecisionRequests = () => appRequests.filter((request) =>
Expand Down
Loading