Skip to content

Commit d9abeb7

Browse files
fix(serve-app): bind the host's own opening call instead of re-posting a large tool result (#562) (#565)
* fix(serve-app): bind the host's own opening call instead of re-posting a large tool result (#562) The serve-app page posted the seeded tool result back to POST /api/mcp/sessions/<id>/apps, so any opening result past the shared 64 KiB request-body bound (AB8010) dropped the App to the fallback panel — cargo-hauler's dashboard on a busy machine, for one. The host already made that call: McpAppRoutes gains an optional openingCall lookup, serve-app supplies its selection, and the page binds by tool name alone. A request that carries input and result (the Workbench) is unchanged; one that carries only one of them, names another tool, or another session is still AB8021. * chore(changeset): reference the PR number * fix(serve-app): one scrollbar, not three — block-size the host, sandbox, and surface-proxy iframes An inline iframe at height:100% inside a 100%-tall body overflows by its line-box descender, so the serve-app host page, the MCP App sandbox document, and the Runtime App surface proxy each grew a scrollbar around the App's own. Size the iframes as blocks and clip the framing documents' overflow. * test(e2e): assert the sandbox proxy owns no scrollbar; tighten the changeset summary
1 parent 3c963e9 commit d9abeb7

9 files changed

Lines changed: 132 additions & 13 deletions

File tree

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"agent-bundle": patch
3+
---
4+
5+
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)

‎packages/agent-bundle/src/dev/mcp-apps/mcp-app-routes.ts‎

Lines changed: 36 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -92,8 +92,23 @@ export interface McpAppRoutePreviewService {
9292
readonly runtime?: McpAppRuntimeRoutePreviewService;
9393
}
9494

95+
/** The tool call a host already made for a session, which a page may bind without re-sending it. */
96+
export interface McpAppOpeningCall {
97+
readonly input: McpAppJsonValue;
98+
readonly result: McpAppJsonValue;
99+
}
100+
95101
export interface McpAppRoutesOptions {
96102
readonly authorize: (request: IncomingMessage) => void;
103+
/**
104+
* The tool call the host performed itself when it opened a session (the
105+
* standalone `serve-app` host calls the opening tool once and seeds its
106+
* page with the result). A create request that omits `input` and `result`
107+
* binds to this call, so a large result is never round-tripped through the
108+
* browser and past the request-body bound (#562); without it, both fields
109+
* are required, as the Workbench sends them.
110+
*/
111+
readonly openingCall?: (sessionId: string, toolName: string) => McpAppOpeningCall | undefined;
97112
/**
98113
* Test-only override for the graceful-close receipt window. Production
99114
* callers must leave this unset so the window keeps dominating the frame
@@ -292,16 +307,30 @@ const hostContext = (value: unknown): McpAppPreviewHostContext => {
292307
});
293308
};
294309

295-
const createRequest = (value: JsonObject, sessionId: string): Parameters<McpAppRoutePreviewService['create']>[0] => {
310+
const createRequest = (
311+
value: JsonObject,
312+
sessionId: string,
313+
openingCall: McpAppRoutesOptions['openingCall'],
314+
): Parameters<McpAppRoutePreviewService['create']>[0] => {
296315
if (!hasOnly(value, ['host', 'input', 'previewProfile', 'result', 'toolName']) || !nonemptyString(value.toolName)
297-
|| !isJsonValue(value.input) || !isJsonValue(value.result) || (value.previewProfile !== 'portable' && value.previewProfile !== 'chatgpt' && value.previewProfile !== 'claude')) {
316+
|| (value.previewProfile !== 'portable' && value.previewProfile !== 'chatgpt' && value.previewProfile !== 'claude')) {
298317
return invalidShape();
299318
}
319+
// A request carrying neither field binds the call the host already made;
320+
// one carrying both is the Workbench's own tool run. Anything in between
321+
// is malformed.
322+
const carriesCall = Object.hasOwn(value, 'input') || Object.hasOwn(value, 'result');
323+
const call = carriesCall
324+
? isJsonValue(value.input) && isJsonValue(value.result)
325+
? { input: cloneJson(value.input), result: cloneJson(value.result) }
326+
: undefined
327+
: openingCall?.(sessionId, value.toolName);
328+
if (call === undefined) return invalidShape();
300329
return Object.freeze({
301330
host: hostContext(value.host),
302-
input: cloneJson(value.input),
331+
input: call.input,
303332
previewProfile: value.previewProfile,
304-
result: cloneJson(value.result),
333+
result: call.result,
305334
sessionId,
306335
toolName: value.toolName,
307336
});
@@ -385,6 +414,7 @@ const bridgeHostContext = (host: McpAppPreviewHostContext): McpAppBridgeJsonReco
385414
export class McpAppRoutes {
386415
readonly #authorize: (request: IncomingMessage) => void;
387416
readonly #gracefulCloseReceiptTimeoutMs: number;
417+
readonly #openingCall: McpAppRoutesOptions['openingCall'];
388418
readonly #service: McpAppRoutePreviewService | undefined;
389419
readonly #tails = new Map<string, Promise<void>>();
390420
readonly #teardowns = new Map<string, ReturnType<typeof setTimeout>>();
@@ -393,6 +423,7 @@ export class McpAppRoutes {
393423
constructor(options: McpAppRoutesOptions) {
394424
this.#authorize = options.authorize;
395425
this.#gracefulCloseReceiptTimeoutMs = options.gracefulCloseReceiptTimeoutMs ?? gracefulCloseReceiptTimeoutMs;
426+
this.#openingCall = options.openingCall;
396427
this.#service = options.service;
397428
}
398429

@@ -432,7 +463,7 @@ export class McpAppRoutes {
432463
if (isRuntimeRoute(parsed)) return this.#dispatchRuntime(parsed, request, response, service.runtime);
433464
if (parsed.kind === 'create') {
434465
if (method !== 'POST') return responseDiagnostic(response, diagnostic('AB8007', 'Route does not accept this method.', 405));
435-
const preview = await service.create(createRequest(await jsonBody(request), parsed.sessionId));
466+
const preview = await service.create(createRequest(await jsonBody(request), parsed.sessionId, this.#openingCall));
436467
return writeJsonResponse(response, { lifecycle: preview.bridge.lifecycle, preview: previewSnapshot(preview) });
437468
}
438469
if (parsed.kind === 'force-close') {

‎packages/agent-bundle/src/dev/mcp-apps/mcp-app-sandbox.ts‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -35,7 +35,7 @@ const SHELL = `<!doctype html>
3535
<meta charset="utf-8">
3636
<meta name="viewport" content="width=device-width, initial-scale=1">
3737
<title>MCP App sandbox</title>
38-
<style>html,body,iframe{border:0;height:100%;margin:0;width:100%}</style>
38+
<style>html,body{height:100%;margin:0;overflow:hidden}iframe{border:0;display:block;height:100%;width:100%}</style>
3939
<iframe id="app" sandbox="allow-scripts" referrerpolicy="no-referrer"></iframe>
4040
<script>
4141
'use strict';

‎packages/agent-bundle/src/dev/runtime-client-surface-proxy.ts‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -182,7 +182,7 @@ const runtimeProxyShell = (
182182
<meta charset="utf-8">
183183
<meta name="viewport" content="width=device-width, initial-scale=1">
184184
<title>Runtime App surface</title>
185-
<style>html,body,iframe{border:0;height:100%;margin:0;width:100%}</style>
185+
<style>html,body{height:100%;margin:0;overflow:hidden}iframe{border:0;display:block;height:100%;width:100%}</style>
186186
<iframe id="app" sandbox="allow-scripts" referrerpolicy="no-referrer"></iframe>
187187
<script>
188188
'use strict';

‎packages/agent-bundle/src/serve-app/serve-app-page.ts‎

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -109,8 +109,10 @@ const browserHostContext = () => ({
109109
110110
const start = async () => {
111111
setStatus('Binding ' + seed.toolName + ' to the App…');
112+
// The host already made the opening call; binding it by tool name keeps a
113+
// large result from travelling back through the request-body bound (#562).
112114
const created = await api('POST', '/api/mcp/sessions/' + encodeURIComponent(seed.sessionId) + '/apps', {
113-
host: browserHostContext(), input: seed.input, previewProfile: seed.previewProfile, result: seed.result, toolName: seed.toolName,
115+
host: browserHostContext(), previewProfile: seed.previewProfile, toolName: seed.toolName,
114116
});
115117
const preview = created.preview;
116118
const bindingId = preview.bindingId;
@@ -273,7 +275,7 @@ start().catch((error) => {
273275

274276
const HOST_STYLE = `
275277
:root { color-scheme: light dark; font: 14px/1.4 system-ui, sans-serif; }
276-
html, body { height: 100%; margin: 0; }
278+
html, body { height: 100%; margin: 0; overflow: hidden; }
277279
body { display: flex; flex-direction: column; background: Canvas; color: CanvasText; }
278280
header { align-items: center; border-bottom: 1px solid color-mix(in srgb, CanvasText 15%, transparent); display: flex; gap: 12px; padding: 8px 16px; }
279281
header h1 { font-size: 15px; font-weight: 600; margin: 0; }
@@ -287,7 +289,7 @@ header h1 { font-size: 15px; font-weight: 600; margin: 0; }
287289
#consent li { align-items: center; display: flex; flex-wrap: wrap; gap: 8px; }
288290
#consent code { font-size: 12px; opacity: 0.8; overflow: hidden; text-overflow: ellipsis; max-width: 40ch; white-space: nowrap; }
289291
#frame-host { flex: 1; min-height: 0; }
290-
#frame-host iframe { border: 0; height: 100%; width: 100%; }
292+
#frame-host iframe { border: 0; display: block; height: 100%; width: 100%; }
291293
#fallback { overflow: auto; padding: 16px; }
292294
#fallback pre { background: color-mix(in srgb, CanvasText 6%, transparent); overflow: auto; padding: 8px; }
293295
`;

‎packages/agent-bundle/src/serve-app/serve-mcp-app.ts‎

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -486,8 +486,15 @@ const serveProgram = (options: ServeMcpAppOptions): Effect.Effect<ServedMcpAppSh
486486
throw requestError(diagnostic('AB8004', 'A valid MCP App host token is required.', 403));
487487
}
488488
};
489+
// The page binds the opening call this host already made instead of
490+
// posting the result back: a large result would otherwise exceed the
491+
// request-body bound and drop the App to the fallback panel (#562).
492+
const openingCall = (sessionId: string, toolName: string) =>
493+
sessionId === session.sessionId && toolName === selection.tool.name
494+
? Object.freeze({ input: selection.input, result: selection.result })
495+
: undefined;
489496
const routes = yield* Effect.acquireRelease(
490-
Effect.sync(() => new McpAppRoutes({ authorize, service: previews })),
497+
Effect.sync(() => new McpAppRoutes({ authorize, openingCall, service: previews })),
491498
(created) => Effect.sync(() => { created.close(); }),
492499
);
493500
const page = renderServeAppPage({

‎packages/agent-bundle/tests/mcp-app-routes.test.ts‎

Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import { expect, it } from '@rstest/core';
77
import {
88
McpAppRoutes,
99
type McpAppRoutePreviewService,
10+
type McpAppRoutesOptions,
1011
} from '../src/dev/mcp-apps/mcp-app-routes.ts';
1112
import { runtimeAppMessageLimits } from '../src/dev/runtime-app-message-limits.ts';
1213
import { McpAppRuntimePreviewError } from '../src/dev/mcp-app-runtime-preview-service.ts';
@@ -152,10 +153,12 @@ class RecordingPreviewService implements McpAppRoutePreviewService {
152153
const startRoutes = async (
153154
service = new RecordingPreviewService(),
154155
gracefulCloseReceiptTimeoutMs?: number,
156+
openingCall?: McpAppRoutesOptions['openingCall'],
155157
): Promise<StartedRoutes> => {
156158
const routes = new McpAppRoutes({
157159
authorize,
158160
...(gracefulCloseReceiptTimeoutMs === undefined ? {} : { gracefulCloseReceiptTimeoutMs }),
161+
...(openingCall === undefined ? {} : { openingCall }),
159162
service,
160163
});
161164
const server = createServer((request, response) => {
@@ -604,6 +607,65 @@ it('creates an App preview from only session-scoped JSON data', async () => {
604607
}
605608
});
606609

610+
it('binds the host\'s own opening call when a create request omits input and result (#562)', async () => {
611+
// A result far past the 64 KiB request-body bound: the host holds it, so the
612+
// page never sends it back.
613+
const large = { structuredContent: { rows: Array.from({ length: 4000 }, (_, index) => ({ index, text: 'x'.repeat(24) })) } };
614+
expect(Buffer.byteLength(JSON.stringify(large))).toBeGreaterThan(64 * 1024);
615+
const service = new RecordingPreviewService();
616+
const started = await startRoutes(service, undefined, (sessionId, toolName) =>
617+
sessionId === 'session-a' && toolName === 'show-weather' ? { input: { city: 'Oslo' }, result: large } : undefined);
618+
try {
619+
const bound = await fetch(`${started.url}/api/mcp/sessions/session-a/apps`, {
620+
body: JSON.stringify({ host, previewProfile: 'portable', toolName: 'show-weather' }),
621+
headers: { ...headers(), 'content-type': 'application/json' },
622+
method: 'POST',
623+
});
624+
expect(bound.status).toBe(200);
625+
expect(service.calls).toEqual([{
626+
kind: 'create',
627+
options: { host, input: { city: 'Oslo' }, previewProfile: 'portable', result: large, sessionId: 'session-a', toolName: 'show-weather' },
628+
}]);
629+
630+
// Another tool, or a session the host did not open, has no call to bind.
631+
for (const body of [
632+
{ host, previewProfile: 'portable', toolName: 'other-tool' },
633+
{ host, input: { city: 'Oslo' }, previewProfile: 'portable', toolName: 'show-weather' },
634+
]) {
635+
const response = await fetch(`${started.url}/api/mcp/sessions/session-a/apps`, {
636+
body: JSON.stringify(body),
637+
headers: { ...headers(), 'content-type': 'application/json' },
638+
method: 'POST',
639+
});
640+
expect(response.status).toBe(400);
641+
await expect(response.json()).resolves.toEqual({ diagnostic: { code: 'AB8021', message: 'MCP App request has an invalid shape.' } });
642+
}
643+
const otherSession = await fetch(`${started.url}/api/mcp/sessions/session-b/apps`, {
644+
body: JSON.stringify({ host, previewProfile: 'portable', toolName: 'show-weather' }),
645+
headers: { ...headers(), 'content-type': 'application/json' },
646+
method: 'POST',
647+
});
648+
expect(otherSession.status).toBe(400);
649+
expect(service.calls).toHaveLength(1);
650+
} finally {
651+
await started.close();
652+
}
653+
654+
// Without a host-made call, the Workbench shape stays required.
655+
const plain = await startRoutes();
656+
try {
657+
const response = await fetch(`${plain.url}/api/mcp/sessions/session-a/apps`, {
658+
body: JSON.stringify({ host, previewProfile: 'portable', toolName: 'show-weather' }),
659+
headers: { ...headers(), 'content-type': 'application/json' },
660+
method: 'POST',
661+
});
662+
expect(response.status).toBe(400);
663+
expect(plain.service.calls).toEqual([]);
664+
} finally {
665+
await plain.close();
666+
}
667+
});
668+
607669
it('rejects obsolete browser-created document consent on preview creation', async () => {
608670
const started = await startRoutes();
609671
try {

‎packages/agent-bundle/tests/serve-app.test.ts‎

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -196,9 +196,12 @@ it('serves the MCP App example standalone over its packed server and relays the
196196
return json;
197197
};
198198

199-
// Binding the App through the same preview service the Workbench uses.
199+
// Binding the App through the same preview service the Workbench uses —
200+
// by tool name alone, as the page does: the host already made the opening
201+
// call, so its result never crosses the request-body bound (#562).
202+
expect(html).not.toContain('result: seed.result, toolName');
200203
const created = await api('POST', `/api/mcp/sessions/${encodeURIComponent(seed.sessionId)}/apps`, {
201-
host: browserHost, input: seed.input, previewProfile: seed.previewProfile, result: seed.result, toolName: seed.toolName,
204+
host: browserHost, previewProfile: seed.previewProfile, toolName: seed.toolName,
202205
});
203206
const preview = created.preview as {
204207
readonly bindingId: string;

‎packages/workbench/tests/mcp-app-real.e2e.test.ts‎

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -334,6 +334,15 @@ e2e('runs a generated SDK-v2 App through the real foreground session and separat
334334
if (initialAppFrame === undefined) throw new Error('Expected the sandbox proxy to create the App srcdoc frame.');
335335
const initialAppState = initialAppFrame.getByTestId('app-state');
336336
await expect(initialAppState).toContainText('real-sdk-v2', { timeout: browserTimeout });
337+
// The sandbox proxy document owns no scrollbar of its own (#565): its App
338+
// frame is a block filling the document, so only the App itself scrolls.
339+
const proxyFrame = page.frames().find((frame) => frame.url().startsWith(sandboxOrigin));
340+
if (proxyFrame === undefined) throw new Error('Expected the sandbox proxy frame on the sandbox origin.');
341+
await expect.poll(() => proxyFrame.evaluate(() => ({
342+
bodyOverflow: getComputedStyle(document.body).overflow,
343+
frameDisplay: getComputedStyle(document.getElementById('app')!).display,
344+
scrolls: document.documentElement.scrollHeight > document.documentElement.clientHeight,
345+
})), { timeout: browserTimeout }).toEqual({ bodyOverflow: 'hidden', frameDisplay: 'block', scrolls: false });
337346
const consentDecisions = () => consentSnapshots.filter((snapshot) =>
338347
snapshot !== null && typeof snapshot === 'object' && Object.hasOwn(snapshot, 'approved'));
339348
const consentDecisionRequests = () => appRequests.filter((request) =>

0 commit comments

Comments
 (0)