From c32c17b42f0c3a09cd70ee7b960cbec52685e5f0 Mon Sep 17 00:00:00 2001 From: ScriptedAlchemy Date: Fri, 4 Sep 2026 23:26:31 +0000 Subject: [PATCH 1/4] fix(serve-app): bind the host's own opening call instead of re-posting a large tool result (#562) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The serve-app page posted the seeded tool result back to POST /api/mcp/sessions//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. --- .changeset/serve-app-opening-call.md | 5 ++ .../src/dev/mcp-apps/mcp-app-routes.ts | 41 ++++++++++-- .../src/serve-app/serve-app-page.ts | 4 +- .../src/serve-app/serve-mcp-app.ts | 9 ++- .../agent-bundle/tests/mcp-app-routes.test.ts | 62 +++++++++++++++++++ packages/agent-bundle/tests/serve-app.test.ts | 7 ++- 6 files changed, 119 insertions(+), 9 deletions(-) create mode 100644 .changeset/serve-app-opening-call.md diff --git a/.changeset/serve-app-opening-call.md b/.changeset/serve-app-opening-call.md new file mode 100644 index 000000000..005a7c07c --- /dev/null +++ b/.changeset/serve-app-opening-call.md @@ -0,0 +1,5 @@ +--- +"agent-bundle": patch +--- + +Fix `agent-bundle serve-app` (and `serveApp`) dropping the App to the "ordinary tool result" fallback with `AB8010: Request body exceeds 64 KiB` when the opening tool's result is large: the host page now binds the tool call the host already made by tool name instead of re-sending the result through `POST /api/mcp/sessions//apps`. `McpAppRoutes` accepts an `openingCall` for that purpose; the Workbench's full-body shape is unchanged. (#562) diff --git a/packages/agent-bundle/src/dev/mcp-apps/mcp-app-routes.ts b/packages/agent-bundle/src/dev/mcp-apps/mcp-app-routes.ts index b844e7fa1..82643c86c 100644 --- a/packages/agent-bundle/src/dev/mcp-apps/mcp-app-routes.ts +++ b/packages/agent-bundle/src/dev/mcp-apps/mcp-app-routes.ts @@ -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 @@ -292,16 +307,30 @@ const hostContext = (value: unknown): McpAppPreviewHostContext => { }); }; -const createRequest = (value: JsonObject, sessionId: string): Parameters[0] => { +const createRequest = ( + value: JsonObject, + sessionId: string, + openingCall: McpAppRoutesOptions['openingCall'], +): Parameters[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, }); @@ -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>(); readonly #teardowns = new Map>(); @@ -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; } @@ -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') { diff --git a/packages/agent-bundle/src/serve-app/serve-app-page.ts b/packages/agent-bundle/src/serve-app/serve-app-page.ts index b47ac3f8b..5b8519707 100644 --- a/packages/agent-bundle/src/serve-app/serve-app-page.ts +++ b/packages/agent-bundle/src/serve-app/serve-app-page.ts @@ -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; diff --git a/packages/agent-bundle/src/serve-app/serve-mcp-app.ts b/packages/agent-bundle/src/serve-app/serve-mcp-app.ts index 8fbe28fac..8f6a5f65c 100644 --- a/packages/agent-bundle/src/serve-app/serve-mcp-app.ts +++ b/packages/agent-bundle/src/serve-app/serve-mcp-app.ts @@ -486,8 +486,15 @@ const serveProgram = (options: ServeMcpAppOptions): Effect.Effect + 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({ diff --git a/packages/agent-bundle/tests/mcp-app-routes.test.ts b/packages/agent-bundle/tests/mcp-app-routes.test.ts index 1d4af1fdc..fe0c2512a 100644 --- a/packages/agent-bundle/tests/mcp-app-routes.test.ts +++ b/packages/agent-bundle/tests/mcp-app-routes.test.ts @@ -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'; @@ -152,10 +153,12 @@ class RecordingPreviewService implements McpAppRoutePreviewService { const startRoutes = async ( service = new RecordingPreviewService(), gracefulCloseReceiptTimeoutMs?: number, + openingCall?: McpAppRoutesOptions['openingCall'], ): Promise => { const routes = new McpAppRoutes({ authorize, ...(gracefulCloseReceiptTimeoutMs === undefined ? {} : { gracefulCloseReceiptTimeoutMs }), + ...(openingCall === undefined ? {} : { openingCall }), service, }); const server = createServer((request, response) => { @@ -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 { diff --git a/packages/agent-bundle/tests/serve-app.test.ts b/packages/agent-bundle/tests/serve-app.test.ts index 128b6e9f0..a3459e1bd 100644 --- a/packages/agent-bundle/tests/serve-app.test.ts +++ b/packages/agent-bundle/tests/serve-app.test.ts @@ -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; From 904448f5772ebf968241d0a469e7aa81275925cf Mon Sep 17 00:00:00 2001 From: ScriptedAlchemy Date: Fri, 4 Sep 2026 23:27:03 +0000 Subject: [PATCH 2/4] chore(changeset): reference the PR number --- .changeset/serve-app-opening-call.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.changeset/serve-app-opening-call.md b/.changeset/serve-app-opening-call.md index 005a7c07c..f2be27e35 100644 --- a/.changeset/serve-app-opening-call.md +++ b/.changeset/serve-app-opening-call.md @@ -2,4 +2,4 @@ "agent-bundle": patch --- -Fix `agent-bundle serve-app` (and `serveApp`) dropping the App to the "ordinary tool result" fallback with `AB8010: Request body exceeds 64 KiB` when the opening tool's result is large: the host page now binds the tool call the host already made by tool name instead of re-sending the result through `POST /api/mcp/sessions//apps`. `McpAppRoutes` accepts an `openingCall` for that purpose; the Workbench's full-body shape is unchanged. (#562) +Fix `agent-bundle serve-app` (and `serveApp`) dropping the App to the "ordinary tool result" fallback with `AB8010: Request body exceeds 64 KiB` when the opening tool's result is large: the host page now binds the tool call the host already made by tool name instead of re-sending the result through `POST /api/mcp/sessions//apps`. `McpAppRoutes` accepts an `openingCall` for that purpose; the Workbench's full-body shape is unchanged. (#565, fixes #562) From 4086275e2fe77d906f6d5299667bf8f1a4288e36 Mon Sep 17 00:00:00 2001 From: ScriptedAlchemy Date: Fri, 4 Sep 2026 23:51:06 +0000 Subject: [PATCH 3/4] =?UTF-8?q?fix(serve-app):=20one=20scrollbar,=20not=20?= =?UTF-8?q?three=20=E2=80=94=20block-size=20the=20host,=20sandbox,=20and?= =?UTF-8?q?=20surface-proxy=20iframes?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- .changeset/serve-app-opening-call.md | 2 +- packages/agent-bundle/src/dev/mcp-apps/mcp-app-sandbox.ts | 2 +- packages/agent-bundle/src/dev/runtime-client-surface-proxy.ts | 2 +- packages/agent-bundle/src/serve-app/serve-app-page.ts | 4 ++-- 4 files changed, 5 insertions(+), 5 deletions(-) diff --git a/.changeset/serve-app-opening-call.md b/.changeset/serve-app-opening-call.md index f2be27e35..c6b3a1032 100644 --- a/.changeset/serve-app-opening-call.md +++ b/.changeset/serve-app-opening-call.md @@ -2,4 +2,4 @@ "agent-bundle": patch --- -Fix `agent-bundle serve-app` (and `serveApp`) dropping the App to the "ordinary tool result" fallback with `AB8010: Request body exceeds 64 KiB` when the opening tool's result is large: the host page now binds the tool call the host already made by tool name instead of re-sending the result through `POST /api/mcp/sessions//apps`. `McpAppRoutes` accepts an `openingCall` for that purpose; the Workbench's full-body shape is unchanged. (#565, fixes #562) +Fix `agent-bundle serve-app` (and `serveApp`) dropping the App to the "ordinary tool result" fallback with `AB8010: Request body exceeds 64 KiB` when the opening tool's result is large: the host page now binds the tool call the host already made by tool name instead of re-sending the result through `POST /api/mcp/sessions//apps`. `McpAppRoutes` accepts an `openingCall` for that purpose; the Workbench's full-body shape is unchanged. The host page, the MCP App sandbox document, and the Runtime App surface proxy also size their iframes as blocks and clip their own overflow, so a served App shows one scrollbar (the App's) instead of three nested ones. (#565, fixes #562) diff --git a/packages/agent-bundle/src/dev/mcp-apps/mcp-app-sandbox.ts b/packages/agent-bundle/src/dev/mcp-apps/mcp-app-sandbox.ts index f602d8dd0..1ae71ee23 100644 --- a/packages/agent-bundle/src/dev/mcp-apps/mcp-app-sandbox.ts +++ b/packages/agent-bundle/src/dev/mcp-apps/mcp-app-sandbox.ts @@ -35,7 +35,7 @@ const SHELL = ` MCP App sandbox - +