From c51dcb47f0e69397b49e86b6127d0ddef2a7549a Mon Sep 17 00:00:00 2001 From: ScriptedAlchemy Date: Fri, 25 Sep 2026 02:22:58 +0000 Subject: [PATCH 1/2] refactor(workbench): delete the unmounted ext-apps runtime App bridge The runtime App bridge (runtime-app-bridge.ts) lost its only production caller when the Runtime Playground was removed in #629; since then nothing constructed it and the built Workbench carried no ext-apps code. App route previews render through the server-issued sandbox frame and the web-host frame relay. Delete the bridge and the runtime preview mode that only existed to host it: the runtime overloads of McpAppPreview and McpPage, SecureAppRenderer and the Inspector-derived AppRenderer (with its license and notice), and runtime-view-contracts. Drop @modelcontextprotocol/ext-apps from the Workbench. --- .../remove-workbench-runtime-app-renderer.md | 5 + NOTICE | 7 +- docs/architecture/rsc-runtime-workbench.md | 42 +- packages/agent-bundle/README.md | 3 +- .../src/dev/mcp-apps/mcp-app-routes.ts | 2 +- .../agent-bundle/src/dev/workbench-assets.ts | 6 +- .../tests/dev-workbench-packaging.test.ts | 9 +- .../tests/license-metadata.test.ts | 1 - .../tests/rsc-runtime-topology-script.test.ts | 4 - packages/workbench/THIRD_PARTY_NOTICES | 11 - packages/workbench/package.json | 1 - packages/workbench/rsbuild.config.ts | 1 - .../workbench/src/mcp/APP-RENDERER-LICENSE | 21 - packages/workbench/src/mcp/app-renderer.tsx | 521 -------- packages/workbench/src/mcp/mcp-app-frame.tsx | 165 --- .../workbench/src/mcp/mcp-app-preview.css | 62 - .../workbench/src/mcp/mcp-app-preview.tsx | 942 +------------ packages/workbench/src/mcp/mcp-page.tsx | 364 +----- .../workbench/src/mcp/runtime-app-bridge.ts | 680 ---------- .../workbench/src/runtime-view-contracts.ts | 44 - .../workbench/tests/mcp-app-frame.test.ts | 244 ---- .../tests/mcp-app-preview-browser.test.ts | 333 ----- .../workbench/tests/mcp-app-preview.test.ts | 1035 --------------- .../tests/mcp-page-app-browser.test.ts | 284 +--- packages/workbench/tests/mcp-page.test.ts | 186 +-- .../workbench/tests/rsbuild-workbench.test.ts | 1 - .../tests/runtime-app-bridge.test.ts | 1161 ----------------- .../tests/runtime-contract-compile.test.ts | 6 +- .../tests/support/workbench-fixture-config.ts | 3 +- pnpm-lock.yaml | 3 - scripts/rsc-runtime-topology.mjs | 2 - website/docs/en/reference/security.mdx | 3 +- website/docs/zh/reference/security.mdx | 3 +- 33 files changed, 122 insertions(+), 6033 deletions(-) create mode 100644 .changeset/remove-workbench-runtime-app-renderer.md delete mode 100644 packages/workbench/src/mcp/APP-RENDERER-LICENSE delete mode 100644 packages/workbench/src/mcp/app-renderer.tsx delete mode 100644 packages/workbench/src/mcp/mcp-app-frame.tsx delete mode 100644 packages/workbench/src/mcp/runtime-app-bridge.ts delete mode 100644 packages/workbench/src/runtime-view-contracts.ts delete mode 100644 packages/workbench/tests/runtime-app-bridge.test.ts diff --git a/.changeset/remove-workbench-runtime-app-renderer.md b/.changeset/remove-workbench-runtime-app-renderer.md new file mode 100644 index 000000000..6f370970b --- /dev/null +++ b/.changeset/remove-workbench-runtime-app-renderer.md @@ -0,0 +1,5 @@ +--- +"agent-bundle": patch +--- + +Remove the unused runtime MCP App renderer from the bundled Workbench. App previews keep rendering through the server-issued sandbox frame; the package no longer ships `dist/workbench/src/mcp/APP-RENDERER-LICENSE`, and `NOTICE` and `THIRD_PARTY_NOTICES` drop the MCP Inspector `AppRenderer` attribution. diff --git a/NOTICE b/NOTICE index a78e8e84f..f808204d2 100644 --- a/NOTICE +++ b/NOTICE @@ -7,10 +7,9 @@ LICENSE file distributed with it. This distribution includes third-party material that is covered by its own license and notices, which are preserved unmodified alongside that material: - - The Agent Bundle Workbench MCP App renderer is derived from the MCP - Inspector project (MIT License). See THIRD_PARTY_NOTICES and - src/mcp/APP-RENDERER-LICENSE, which the agent-bundle package ships under - dist/workbench/. + - The Agent Bundle Workbench bundles the xterm.js terminal emulator (MIT + License). See THIRD_PARTY_NOTICES, which the agent-bundle package ships + under dist/workbench/. - The rsc-markdown-stream package (packages/rsc-markdown-stream) was imported from https://github.com/ScriptedAlchemy/rsc-markdown-stream at diff --git a/docs/architecture/rsc-runtime-workbench.md b/docs/architecture/rsc-runtime-workbench.md index bd51a0547..f51facea7 100644 --- a/docs/architecture/rsc-runtime-workbench.md +++ b/docs/architecture/rsc-runtime-workbench.md @@ -62,7 +62,9 @@ packages/ tests/host-adapters.native.test.ts tests/host-adapters.test.ts tests/mcp-app-binding-service.test.ts + tests/mcp-app-bridge-cancellation.test.ts tests/mcp-app-bridge.test.ts + tests/mcp-app-diagnostics.test.ts tests/mcp-app-host-profiles.test.ts tests/mcp-app-metadata.test.ts tests/mcp-app-preview-service.test.ts @@ -70,8 +72,11 @@ packages/ tests/mcp-app-runtime-binding-service.test.ts tests/mcp-app-runtime-preview-service.test.ts tests/mcp-app-sandbox.test.ts + tests/mcp-apps-compile.test.ts tests/mcp-session-routes.test.ts tests/mcp-session-service.test.ts + tests/mcp-session-trace-publisher.test.ts + tests/native-host-sessions.test.ts tests/normalization.test.ts tests/playground-service.test.ts tests/portable-adapter.test.ts @@ -91,21 +96,13 @@ packages/ scripts/capture-runtime-playground.mjs src/main.tsx src/mcp/mcp-app-client.ts - src/mcp/mcp-app-frame.tsx src/mcp/mcp-app-preview.tsx src/mcp/mcp-page.tsx src/mcp/mcp-session-controller.ts src/mcp/mcp-session-model.ts - src/mcp/runtime-app-bridge.ts - src/mcp/runtime-consent-dialog.tsx - src/mcp/runtime-consent-queue.ts - src/mcp/runtime-mcp-handoff.ts src/project-client.ts src/runtime-client.ts - src/runtime-inspector.tsx src/runtime-model.ts - src/runtime-playground.tsx - src/runtime-stage.tsx src/styles.css tests/helpers/runtime-playground-fixture.ts tests/mcp-app-client.test.ts @@ -117,21 +114,11 @@ packages/ tests/mcp-session-controller.test.ts tests/mcp-session-model.test.ts tests/mcp-session-timeout.e2e.test.ts - tests/runtime-app-bridge.test.ts + tests/runtime-backend.test.ts tests/runtime-client.test.ts - tests/runtime-consent-dialog.test.ts - tests/runtime-consent-queue.test.ts tests/runtime-contract-compile.test.ts - tests/runtime-document-atoms-disposal.test.ts - tests/runtime-inspector.test.ts - tests/runtime-mcp-handoff.test.ts + tests/runtime-controller.test.ts tests/runtime-model.test.ts - tests/runtime-playground-capture-cleanup.test.ts - tests/runtime-playground-capture.test.ts - tests/runtime-playground-hmr.e2e.test.ts - tests/runtime-playground.e2e.test.ts - tests/runtime-playground.test.ts - tests/runtime-stage.test.ts examples/ rsc-agent-runtime/ package.json @@ -144,7 +131,9 @@ examples/ src/build/serialize-definition.ts src/definition.ts src/dev/canonical-json.ts + src/dev/compile-diagnostics.ts src/dev/definition-entry.ts + src/dev/durable-tree.ts src/dev/environment-checkpoint-store.ts src/dev/generation-materializer.ts src/dev/inspection-security.ts @@ -171,7 +160,6 @@ examples/ src/runtime/contracts.ts src/runtime/state-definition.ts src/runtime/state-file.ts - src/types/mcp-ext-apps-react.d.ts src/types/react-server-dom-rspack.d.ts src/types/styles.d.ts src/widget/App.tsx @@ -186,7 +174,6 @@ examples/ tests/host-artifacts.test.ts tests/host-extensions.test.tsx tests/http-security.test.ts - tests/mcp-lowering.test.tsx tests/mcp-transports.integration.test.ts tests/rsc-hook.integration.test.ts tests/runtime-artifact-manifest.test.ts @@ -251,12 +238,11 @@ are never gated. `ProjectStatus.hostAdoption` exposes the adopted epoch and the latest evaluation, and the Overview renders it as **Host adoption** beside the published build, so a rejected epoch is visible rather than silently skipped. -The RSC result tree is not the MCP App document. A current preview moves through -`McpAppPreview`, `SecureAppRenderer`, the official App renderer, the -generation-bound bridge, and the runtime client-surface proxy to an opaque-origin -App iframe. The direct frame, bridge, handoff, proxy, message-limit, binding, -preview-service, routes, mounted-page, and real-browser tests retained above -are the single lifecycle boundary for that binding. +The RSC result tree is not the MCP App document. An App route preview moves +through `McpAppPreview`, which mounts the server-issued sandbox proxy iframe and +drives it with the frame relay (`web-host/browser/frame-relay.ts`) over the +Workbench App routes. The frame-relay, preview, routes, mounted-page, and +real-browser tests retained above are the lifecycle boundary for that binding. Portable is the baseline. ChatGPT/OpenAI and Claude Workbench profiles are local compatibility simulations, not vendor certification. Native terminal evidence diff --git a/packages/agent-bundle/README.md b/packages/agent-bundle/README.md index 418a385f8..74f955d50 100644 --- a/packages/agent-bundle/README.md +++ b/packages/agent-bundle/README.md @@ -1139,8 +1139,7 @@ harness. and Codex selections are refused when it is configured. - Raw HTML, JSX/MDX, and Mermaid in Skill Markdown are inert in the workbench renderer. -Third-party notices, including the MIT license and provenance of the MCP App renderer derived from -the MCP Inspector's `AppRenderer`, ship in the published package. +Third-party notices for material bundled into the workbench ship in the published package. ## License 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 b77f56c1b..f9de43e60 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 @@ -30,7 +30,7 @@ import { runtimeAppMessageLimits } from '../runtime-app-message-limits.ts'; // A force-close DELETE that lands after an accepted graceful close must stay // idempotent (200, not 404), so this window has to dominate the frame relay's // force-close budget — clients may fall back as late as their closeTimeoutMs, -// which mcp-app-frame.tsx caps at 30s. +// which web-host/browser/frame-relay.ts caps at 30s. const gracefulCloseReceiptTimeoutMs = 35_000; interface CreateRoute { diff --git a/packages/agent-bundle/src/dev/workbench-assets.ts b/packages/agent-bundle/src/dev/workbench-assets.ts index bca351507..8b4698dd6 100644 --- a/packages/agent-bundle/src/dev/workbench-assets.ts +++ b/packages/agent-bundle/src/dev/workbench-assets.ts @@ -39,9 +39,9 @@ const packageRoot = basename(import.meta.dirname) === 'dist' const defaultRoot = (): string => resolve(packageRoot, 'dist', 'workbench'); -// The Workbench build copies its attribution files into the asset tree without -// an extension (`THIRD_PARTY_NOTICES`, `src/mcp/APP-RENDERER-LICENSE`). Those -// conventional names are plain text a browser should render; every other +// The Workbench build copies its attribution file into the asset tree without +// an extension (`THIRD_PARTY_NOTICES`). Such conventional license and notice +// names are plain text a browser should render; every other // extensionless file keeps the binary fallback. const noticeFileName = /^(?:[a-z0-9]+[-_])*(?:licen[cs]e|notices?|copying)$/iu; diff --git a/packages/agent-bundle/tests/dev-workbench-packaging.test.ts b/packages/agent-bundle/tests/dev-workbench-packaging.test.ts index 8caa39b8a..d04dad3f0 100644 --- a/packages/agent-bundle/tests/dev-workbench-packaging.test.ts +++ b/packages/agent-bundle/tests/dev-workbench-packaging.test.ts @@ -14,7 +14,6 @@ const execFile = promisify(executeFile); const workspaceRoot = process.cwd(); const packageRoot = join(workspaceRoot, 'packages', 'agent-bundle'); const workbenchRoot = join(workspaceRoot, 'packages', 'workbench'); -const appRendererLicense = join('src', 'mcp', 'APP-RENDERER-LICENSE'); let built: Promise | undefined; const buildPackage = async (): Promise => { @@ -27,7 +26,7 @@ describe.sequential('workbench package build', () => { const hashedWorkbenchBundle = (kind: 'css' | 'js'): RegExp => kind === 'css' ? /^index\.[a-f0-9]{8}\.css$/u : /^index\.[a-f0-9]{8}\.js$/u; -it('copies content-hashed prebuilt workbench assets and the exact app-renderer license into the package distribution', async () => { +it('copies content-hashed prebuilt workbench assets and third-party notices into the package distribution', async () => { await buildPackage(); await expect(access(join(packageRoot, 'dist', 'workbench', 'index.html'))).resolves.toBeUndefined(); @@ -35,9 +34,8 @@ it('copies content-hashed prebuilt workbench assets and the exact app-renderer l const hashedJs = (await readdir(jsRoot)).find((name) => hashedWorkbenchBundle('js').test(name)); if (hashedJs === undefined) throw new Error('Expected a content-hashed workbench index.js.'); await expect(readFile(join(jsRoot, hashedJs), 'utf8')).resolves.toContain('Workbench navigation'); - await expect(readFile(join(packageRoot, 'dist', 'workbench', 'THIRD_PARTY_NOTICES'), 'utf8')).resolves.toContain('MCP Inspector'); - await expect(readFile(join(packageRoot, 'dist', 'workbench', appRendererLicense), 'utf8')).resolves.toBe( - await readFile(join(workbenchRoot, appRendererLicense), 'utf8'), + await expect(readFile(join(packageRoot, 'dist', 'workbench', 'THIRD_PARTY_NOTICES'), 'utf8')).resolves.toBe( + await readFile(join(workbenchRoot, 'THIRD_PARTY_NOTICES'), 'utf8'), ); }, 60_000); @@ -76,7 +74,6 @@ it('serves prebuilt workbench assets from an installed tarball without the repos const listing = await execFile('tar', ['-tf', tarball]); expect(listing.stdout).toContain('package/dist/workbench/index.html'); expect(listing.stdout).toContain('package/dist/workbench/THIRD_PARTY_NOTICES'); - expect(listing.stdout).toContain('package/dist/workbench/src/mcp/APP-RENDERER-LICENSE'); expect(listing.stdout).not.toMatch(/package\/dist\/workbench\/.*\.map$/mu); expect(listing.stdout).toMatch(/package\/dist\/workbench\/static\/js\/index\.[a-f0-9]{8}\.js$/mu); expect(listing.stdout).toMatch(/package\/dist\/workbench\/static\/css\/index\.[a-f0-9]{8}\.css$/mu); diff --git a/packages/agent-bundle/tests/license-metadata.test.ts b/packages/agent-bundle/tests/license-metadata.test.ts index 10373e590..442212bcb 100644 --- a/packages/agent-bundle/tests/license-metadata.test.ts +++ b/packages/agent-bundle/tests/license-metadata.test.ts @@ -34,7 +34,6 @@ it('ships the canonical Apache License 2.0 text and a NOTICE naming the copyrigh expect(createHash('sha256').update(license).digest('hex')).toBe(canonicalApache2Sha256); expect(notice.startsWith('agent-bundle\nCopyright 2026 ')).toBe(true); expect(notice).toContain('THIRD_PARTY_NOTICES'); - expect(notice).toContain('src/mcp/APP-RENDERER-LICENSE'); }); it('declares Apache-2.0 on every first-party workspace package', async () => { diff --git a/packages/agent-bundle/tests/rsc-runtime-topology-script.test.ts b/packages/agent-bundle/tests/rsc-runtime-topology-script.test.ts index 725193477..a6a5a0e4d 100644 --- a/packages/agent-bundle/tests/rsc-runtime-topology-script.test.ts +++ b/packages/agent-bundle/tests/rsc-runtime-topology-script.test.ts @@ -25,11 +25,9 @@ const expectedTree = `packages/ tests/playground-service.test.ts tests/runtime-provider.test.ts workbench/ - src/mcp/runtime-app-bridge.ts src/mcp/runtime-consent-dialog.tsx src/mcp/runtime-consent-queue.ts src/runtime-model.ts - tests/runtime-app-bridge.test.ts tests/runtime-consent-dialog.test.ts tests/runtime-consent-queue.test.ts examples/ @@ -70,11 +68,9 @@ describe('rsc runtime topology script', () => { 'packages/agent-bundle/tests/normalization.test.ts', 'packages/agent-bundle/tests/playground-service.test.ts', 'packages/agent-bundle/tests/runtime-provider.test.ts', - 'packages/workbench/src/mcp/runtime-app-bridge.ts', 'packages/workbench/src/mcp/runtime-consent-dialog.tsx', 'packages/workbench/src/mcp/runtime-consent-queue.ts', 'packages/workbench/src/runtime-model.ts', - 'packages/workbench/tests/runtime-app-bridge.test.ts', 'packages/workbench/tests/runtime-consent-dialog.test.ts', 'packages/workbench/tests/runtime-consent-queue.test.ts', 'examples/rsc-agent-runtime/src/dev/provider.ts', diff --git a/packages/workbench/THIRD_PARTY_NOTICES b/packages/workbench/THIRD_PARTY_NOTICES index fc7985dbe..d8949427b 100644 --- a/packages/workbench/THIRD_PARTY_NOTICES +++ b/packages/workbench/THIRD_PARTY_NOTICES @@ -1,14 +1,3 @@ -Agent Bundle workbench includes an MCP App renderer derived from the MCP -Inspector project's AppRenderer component: - - MCP Inspector 2.2.0 - https://github.com/modelcontextprotocol/inspector - commit 672f9f41c548487a468b9e7007d2f9de14da5a69 - MIT License - -The derived code is src/mcp/app-renderer.tsx. The MIT license text is in -src/mcp/APP-RENDERER-LICENSE. - Agent Bundle workbench bundles the xterm.js terminal emulator and its fit addon for the Host sessions pane (src/sessions/terminal.tsx): diff --git a/packages/workbench/package.json b/packages/workbench/package.json index 3a3ae31b6..92a16ac63 100644 --- a/packages/workbench/package.json +++ b/packages/workbench/package.json @@ -15,7 +15,6 @@ "dependencies": { "@effect/atom-react": "4.0.0-rc.112", "@modelcontextprotocol/client": "2.0.0", - "@modelcontextprotocol/ext-apps": "2.0.0", "@xterm/addon-fit": "0.11.0", "@xterm/xterm": "6.0.0", "effect": "4.0.0-rc.112", diff --git a/packages/workbench/rsbuild.config.ts b/packages/workbench/rsbuild.config.ts index 38acb747a..7adff3685 100644 --- a/packages/workbench/rsbuild.config.ts +++ b/packages/workbench/rsbuild.config.ts @@ -27,7 +27,6 @@ export const createWorkbenchConfig = ( overrideBrowserslist: ['chrome >= 120'], copy: [ { from: resolve(import.meta.dirname, 'THIRD_PARTY_NOTICES'), to: 'THIRD_PARTY_NOTICES', toType: 'file' }, - { from: resolve(sourceRoot, 'mcp', 'APP-RENDERER-LICENSE'), to: 'src/mcp/APP-RENDERER-LICENSE', toType: 'file' }, ], distPath: { root: 'dist', diff --git a/packages/workbench/src/mcp/APP-RENDERER-LICENSE b/packages/workbench/src/mcp/APP-RENDERER-LICENSE deleted file mode 100644 index 61a92beac..000000000 --- a/packages/workbench/src/mcp/APP-RENDERER-LICENSE +++ /dev/null @@ -1,21 +0,0 @@ -MIT License - -Copyright (c) Model Context Protocol contributors - -Permission is hereby granted, free of charge, to any person obtaining a copy -of this software and associated documentation files (the "Software"), to deal -in the Software without restriction, including without limitation the rights -to use, copy, modify, merge, publish, distribute, sublicense, and/or sell -copies of the Software, and to permit persons to whom the Software is -furnished to do so, subject to the following conditions: - -The above copyright notice and this permission notice shall be included in all -copies or substantial portions of the Software. - -THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR -IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE -AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, -OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE -SOFTWARE. diff --git a/packages/workbench/src/mcp/app-renderer.tsx b/packages/workbench/src/mcp/app-renderer.tsx deleted file mode 100644 index f2206fe4b..000000000 --- a/packages/workbench/src/mcp/app-renderer.tsx +++ /dev/null @@ -1,521 +0,0 @@ -import type { CallToolResult, Tool } from '@modelcontextprotocol/client'; -import React, { - useCallback, - useEffect, - useImperativeHandle, - useRef, - type Ref, - type RefObject, -} from 'react'; - -/** - * First-party MCP App renderer, adapted from the MCP Inspector's AppRenderer - * (modelcontextprotocol/inspector 672f9f41, MIT). The Workbench previously - * vendored the Inspector; only this renderer survived the removal, retyped - * against the shapes the preview pipeline compiles against. The Workbench has - * no design-token system, so the host advertises no styles - apps use their - * own defaults, which the ext-apps spec permits - and the theme derives from - * the system color scheme. - */ - -export type McpAppRendererDisplayMode = 'fullscreen' | 'inline' | 'pip'; - -export type McpAppRendererJsonArray = readonly McpAppRendererJsonValue[]; - -export interface McpAppRendererJsonObject { - readonly [key: string]: McpAppRendererJsonValue; -} - -export type McpAppRendererJsonValue = - | null - | boolean - | number - | string - | McpAppRendererJsonArray - | McpAppRendererJsonObject; - -export type McpAppRendererTool = Tool; - -export interface McpAppRendererMessage { - readonly content: readonly McpAppRendererJsonValue[]; - readonly role: 'user'; -} - -export interface McpAppRendererHostContext { - readonly availableDisplayModes?: readonly McpAppRendererDisplayMode[]; - readonly containerDimensions?: Readonly<{ readonly height: number; readonly width: number }>; - readonly displayMode?: McpAppRendererDisplayMode; - readonly theme?: 'dark' | 'light'; -} - -export interface AppRendererBridge { - addEventListener(type: 'initialized', listener: () => void): void; - addEventListener(type: 'loggingmessage', listener: (params: Readonly<{ readonly data: McpAppRendererJsonValue; readonly level: string; readonly logger?: string }>) => void): void; - addEventListener(type: 'sizechange', listener: (params: Readonly<{ readonly height?: number; readonly width?: number }>) => void): void; - close(): Promise; - onmessage?: (params: McpAppRendererMessage) => Promise>; - onrequestdisplaymode?: (params: Readonly<{ readonly mode: McpAppRendererDisplayMode }>) => Promise>; - sendHostContextChange(context: Partial): Promise; - sendToolCancelled(params: Readonly<{ readonly reason: string }>): Promise; - sendToolInput(params: Readonly<{ readonly arguments: Record }>): Promise; - sendToolInputPartial(params: Readonly<{ readonly arguments: Record }>): Promise; - sendToolResult(result: CallToolResult): Promise; - teardownResource(params: Readonly>): Promise>>; -} - -/** - * Constructs the bridge for a freshly mounted sandbox iframe. Wrap with - * `useCallback` (or hoist out of render) - the renderer treats a new factory - * identity as a signal to tear down the current bridge and rebuild, so an - * unstable factory will thrash the iframe on every render. - */ -export type BridgeFactory = ( - iframe: HTMLIFrameElement, - tool: McpAppRendererTool, -) => AppRendererBridge | Promise; - -export interface AppRendererHandle { - sendToolCancelled(reason: string): Promise; - sendToolInput(args: Record): Promise; - sendToolResult(result: CallToolResult): Promise; - teardown(): Promise; -} - -/** - * High-level lifecycle of a running app, surfaced so a host (or an automated - * driver polling a `data-app-status` attribute) can wait for the right moment: - * `loading` while the bridge is being built and the view's `ui/initialize` - * handshake is in flight; `ready` once the view has fired - * `notifications/initialized`; `error` when the bridge factory throws or - * rejects (no live view to wait on). - */ -export type AppRendererStatus = 'error' | 'loading' | 'ready'; - -export interface AppRendererProps { - readonly bridgeFactory: BridgeFactory; - /** - * Current host display mode for the app frame. Pushed to the running view - * whenever it changes (e.g. Maximize/Restore), so an app can adapt its - * layout to inline vs fullscreen. - */ - readonly displayMode?: McpAppRendererDisplayMode; - readonly onAppStatusChange?: (status: AppRendererStatus) => void; - readonly onError?: (error: Error) => void; - /** Called for each MCP log notification the running view emits. */ - readonly onLog?: (params: Readonly<{ readonly data: McpAppRendererJsonValue; readonly level: string; readonly logger?: string }>) => void; - /** - * Called when the running view submits a user-role message via - * `ui/message`. The renderer returns the spec-required empty result on the - * host's behalf, so the callback is fire-and-forget. - */ - readonly onMessage?: (params: McpAppRendererMessage) => void; - /** - * Handles a view-originated `ui/request-display-mode`. Return the mode the - * host actually applied - the spec lets the host decline an unsupported - * mode by returning its current one. - */ - readonly onRequestDisplayMode?: (requested: McpAppRendererDisplayMode) => McpAppRendererDisplayMode; - /** Reports the view's rendered content size so the host can fit the frame. */ - readonly onSizeChange?: (size: Readonly<{ readonly height?: number; readonly width?: number }>) => void; - /** - * Ordered tool-input fragments to replay before the complete `tool-input`, - * exercising widgets that render progressively. Captured at bridge-build - * time so prop churn never rebuilds the iframe. - */ - readonly partialInputs?: readonly Readonly>[]; - /** - * The host-controlled box the app renders within, used to derive - * `hostContext.containerDimensions`. This MUST be an element whose size is - * driven by the host's layout and NOT by the view's own size reports - - * otherwise the two signals couple into a feedback loop. Falls back to the - * iframe element when omitted. - */ - readonly containerRef?: RefObject; - readonly ref?: Ref; - readonly sandboxPath: string; - readonly tool: McpAppRendererTool; -} - -const currentTheme = (): 'dark' | 'light' => - typeof window !== 'undefined' && window.matchMedia?.('(prefers-color-scheme: dark)').matches ? 'dark' : 'light'; - -const measureContainerDimensions = ( - element: HTMLElement, -): Readonly<{ readonly height: number; readonly width: number }> | undefined => { - if (typeof element.getBoundingClientRect !== 'function') return undefined; - const rect = element.getBoundingClientRect(); - const width = Math.round(rect.width); - const height = Math.round(rect.height); - if (width <= 0 || height <= 0) return undefined; - return { height, width }; -}; - -/** - * Read the live host UI state for the bridge handshake - the single place - * that decides which fields the host seeds. Optional fields are omitted (not - * set undefined) so the bridge's diff stays accurate; subsequent live changes - * are pushed by the renderer's observers as partial host-context changes. The - * seed assumes the app opens inline; the live displayMode push carries any - * subsequent inline-fullscreen transition. - */ -export const snapshotHostContext = ( - container: HTMLElement | null, - availableDisplayModes: readonly McpAppRendererDisplayMode[], -): McpAppRendererHostContext => { - const containerDimensions = container === null ? undefined : measureContainerDimensions(container); - return { - availableDisplayModes: [...availableDisplayModes], - ...(containerDimensions === undefined ? {} : { containerDimensions }), - displayMode: 'inline', - theme: currentTheme(), - }; -}; - -const toError = (value: unknown): Error => value instanceof Error ? value : new Error(String(value)); - -const disposeBridge = async (bridge: AppRendererBridge): Promise => { - // Best-effort: still close the transport even if teardownResource fails, - // otherwise the iframe unmount would leak MessagePort listeners. - try { - await bridge.teardownResource({}); - } catch { - /* swallow - closing transport below is the load-bearing step */ - } - try { - await bridge.close(); - } catch { - /* swallow - already disposing */ - } -}; - -/** - * Bridge lifecycle (the interlocking refs below): - * - * mount -> build (buildId++) -> factory(iframe, tool) -async-> bridgeRef set - * | on "initialized" - * v -> flushPending - * cleanup -> scheduleDispose() --microtask--> dispose (unless cancelled) - * ^ | - * +-- re-setup with SAME inputs -----+ cancel + REUSE bridge - * - * - `buildId` (monotonic): a bridge resolved from an older build self-disposes. - * - `disposeScheduled`: a dispose is queued (microtask); a synchronous - * re-setup (StrictMode double-invoke, or a transient re-render) cancels it - * and reuses the live bridge instead of rebuilding (rebuild double-loads - * the sandbox and races the app handshake). A re-setup with CHANGED inputs - * disposes + rebuilds. - * - `lastDeps`: distinguishes "same inputs -> reuse" from "changed -> rebuild". - * - `initialized`: gates flushing buffered input/result until the view is ready. - * - `pendingInput`/`pendingResult`: latest-wins buffer for host-initiated open. - * - `teardownStarted`: makes the imperative teardown() idempotent vs unmount. - */ -export const AppRenderer = ({ - bridgeFactory, - containerRef, - displayMode, - onAppStatusChange, - onError, - onLog, - onMessage, - onRequestDisplayMode, - onSizeChange, - partialInputs, - ref, - sandboxPath, - tool, -}: AppRendererProps): React.ReactNode => { - const iframeRef = useRef(null); - const bridgeRef = useRef(null); - const initializedRef = useRef(false); - const pendingPartialsRef = useRef>[]>([]); - const pendingInputRef = useRef | null>(null); - const pendingResultRef = useRef(null); - const teardownStartedRef = useRef(false); - const buildIdRef = useRef(0); - const disposeScheduledRef = useRef(false); - const lastDepsRef = useRef | null>(null); - const onErrorRef = useRef(onError); - const onAppStatusChangeRef = useRef(onAppStatusChange); - const onSizeChangeRef = useRef(onSizeChange); - const displayModeRef = useRef(displayMode); - const onRequestDisplayModeRef = useRef(onRequestDisplayMode); - const onMessageRef = useRef(onMessage); - const onLogRef = useRef(onLog); - const partialInputsRef = useRef(partialInputs); - useEffect(() => { - onErrorRef.current = onError; - onAppStatusChangeRef.current = onAppStatusChange; - onSizeChangeRef.current = onSizeChange; - displayModeRef.current = displayMode; - onRequestDisplayModeRef.current = onRequestDisplayMode; - onMessageRef.current = onMessage; - onLogRef.current = onLog; - partialInputsRef.current = partialInputs; - }); - - // Flush buffered tool input/result to the view, but only once the bridge - // exists AND the view has signalled `initialized`. The spec requires tool - // input/result to arrive after initialization, yet a host-initiated open - // fires before the iframe's app has loaded - so the latest values buffer - // and release when the view is ready. Input is always sent before result. - const flushPending = useCallback(() => { - const bridge = bridgeRef.current; - if (bridge === null || !initializedRef.current) return; - for (const args of pendingPartialsRef.current) { - void bridge.sendToolInputPartial({ arguments: { ...args } }); - } - pendingPartialsRef.current = []; - if (pendingInputRef.current !== null) { - const args = pendingInputRef.current; - pendingInputRef.current = null; - void bridge.sendToolInput({ arguments: args }); - } - if (pendingResultRef.current !== null) { - const result = pendingResultRef.current; - pendingResultRef.current = null; - void bridge.sendToolResult(result); - } - }, []); - - // Dispose the live bridge, but deferred to a microtask. React StrictMode - // runs effects setup->cleanup->setup synchronously in dev; deferring lets - // the re-setup cancel the disposal and keep the SAME bridge, instead of - // tearing it down and rebuilding. A rebuild here spins up a second - // transport that re-posts sandbox-resource-ready (the sandbox loads the app - // twice) and races the app's ui/initialize handshake. - const scheduleDispose = useCallback(() => { - disposeScheduledRef.current = true; - queueMicrotask(() => { - if (!disposeScheduledRef.current) return; - disposeScheduledRef.current = false; - buildIdRef.current += 1; - const bridge = bridgeRef.current; - bridgeRef.current = null; - initializedRef.current = false; - lastDepsRef.current = null; - pendingPartialsRef.current = []; - if (bridge !== null) void disposeBridge(bridge); - }); - }, []); - - useEffect(() => { - const iframe = iframeRef.current; - if (iframe === null) return undefined; - - const previous = lastDepsRef.current; - const sameInputs = - previous !== null && - previous.bridgeFactory === bridgeFactory && - previous.sandboxPath === sandboxPath && - previous.tool === tool; - - // A disposal scheduled by the immediately-preceding cleanup means this is - // a synchronous re-setup. If the inputs are identical (StrictMode's - // double-invoke, or a transient re-render) keep the live bridge: cancel - // the disposal and re-deliver any buffered input/result to it. - /* v8 ignore next 4 -- StrictMode's replayed effect body is invisible to coverage. */ - if (disposeScheduledRef.current && sameInputs) { - disposeScheduledRef.current = false; - flushPending(); - return scheduleDispose; - } - - // Otherwise this is a real (re)build. If a disposal was pending (inputs - // changed), run it synchronously before building the replacement. - if (disposeScheduledRef.current) { - disposeScheduledRef.current = false; - buildIdRef.current += 1; - const old = bridgeRef.current; - bridgeRef.current = null; - initializedRef.current = false; - if (old !== null) void disposeBridge(old); - } - - lastDepsRef.current = { bridgeFactory, sandboxPath, tool }; - const buildId = buildIdRef.current + 1; - buildIdRef.current = buildId; - teardownStartedRef.current = false; - initializedRef.current = false; - onAppStatusChangeRef.current?.('loading'); - // Snapshot the staged partial-input fragments for THIS bridge build (read - // via the ref so the prop is not a dep - adding/removing fragments must - // not rebuild the iframe). - pendingPartialsRef.current = [...(partialInputsRef.current ?? [])]; - - let pending: Promise; - try { - pending = Promise.resolve(bridgeFactory(iframe, tool)); - } catch (error) { - onAppStatusChangeRef.current?.('error'); - onErrorRef.current?.(toError(error)); - return scheduleDispose; - } - - pending - .then((bridge) => { - if (buildIdRef.current !== buildId) { - void disposeBridge(bridge); - return; - } - bridgeRef.current = bridge; - // Registered before the inner app can finish loading, so the view's - // `initialized` signal is never missed. - bridge.addEventListener('initialized', () => { - initializedRef.current = true; - onAppStatusChangeRef.current?.('ready'); - // The factory already seeded theme/displayMode into the handshake - // hostContext; only containerDimensions can plausibly differ - // between bridge construction and initialization (layout settles). - const container = containerRef?.current ?? iframeRef.current; - const containerDimensions = container === null ? undefined : measureContainerDimensions(container); - if (containerDimensions !== undefined) { - void bridge.sendHostContextChange({ containerDimensions }); - } - flushPending(); - }); - bridge.addEventListener('sizechange', (size) => { - onSizeChangeRef.current?.(size); - }); - bridge.addEventListener('loggingmessage', (params) => { - onLogRef.current?.(params); - }); - // Handle ui/request-display-mode: the host decides what mode actually - // applies. With no handler the request is declined by returning the - // current host-side mode. - bridge.onrequestdisplaymode = async ({ mode }) => { - const handler = onRequestDisplayModeRef.current; - const applied = handler === undefined ? (displayModeRef.current ?? 'inline') : handler(mode); - return { mode: applied }; - }; - // Handle ui/message: surface the submitted content and return the - // spec-required empty result. With no handler the submission is - // declined by returning isError. - bridge.onmessage = async (params) => { - const handler = onMessageRef.current; - if (handler === undefined) return { isError: true }; - handler(params); - return {}; - }; - flushPending(); - }) - .catch((error: unknown) => { - if (buildIdRef.current !== buildId) return; - onAppStatusChangeRef.current?.('error'); - onErrorRef.current?.(toError(error)); - }); - - return scheduleDispose; - // `containerRef` is listed for exhaustive-deps completeness, but a change - // to its identity does NOT force a rebuild: the `sameInputs` check above - // ignores it, and the `initialized` handler reads `containerRef?.current` - // lazily. The other deps are the real rebuild keys. - }, [ - bridgeFactory, - sandboxPath, - tool, - containerRef, - flushPending, - scheduleDispose, - ]); - - // Theme: the Workbench has no theme system of its own, so the system color - // scheme is the only live theme signal; forward changes to the running view - // once it has initialized. - useEffect(() => { - if (typeof window === 'undefined' || window.matchMedia === undefined) return undefined; - const query = window.matchMedia('(prefers-color-scheme: dark)'); - const onChange = (): void => { - if (!initializedRef.current) return; - void bridgeRef.current?.sendHostContextChange({ theme: currentTheme() }); - }; - query.addEventListener('change', onChange); - return () => query.removeEventListener('change', onChange); - }, []); - - // Container size: observes the host-controlled container (or the iframe as - // a fallback) - NOT an element whose height is driven by the view's own - // size reports, which would couple the two signals into a feedback loop. - // Gated on the view's `initialized` signal; a 0x0 (not-yet-laid-out) - // measurement and a value-equal repeat are both skipped. - useEffect(() => { - const target = containerRef?.current ?? iframeRef.current; - if (typeof ResizeObserver === 'undefined' || target === null) return undefined; - let last: Readonly<{ readonly height: number; readonly width: number }> | undefined; - const observer = new ResizeObserver(() => { - if (!initializedRef.current) return; - const next = measureContainerDimensions(target); - if (next === undefined) return; - if (last !== undefined && last.width === next.width && last.height === next.height) return; - last = next; - void bridgeRef.current?.sendHostContextChange({ containerDimensions: next }); - }); - observer.observe(target); - return () => observer.disconnect(); - }, [containerRef]); - - // Display mode: pushes whenever the prop changes (Maximize/Restore). Gated - // on `initialized` for the same reason as the other host-context pushes. - useEffect(() => { - if (displayMode === undefined) return; - if (!initializedRef.current) return; - void bridgeRef.current?.sendHostContextChange({ displayMode }); - }, [displayMode]); - - useImperativeHandle( - ref, - () => ({ - async sendToolCancelled(reason) { - const bridge = bridgeRef.current; - if (bridge === null) return; - await bridge.sendToolCancelled({ reason }); - }, - async sendToolInput(args) { - // Buffered (latest-wins) and released by flushPending once the view - // is initialized - the handle may be invoked before the bridge - // resolves. - pendingInputRef.current = args; - flushPending(); - }, - async sendToolResult(result) { - pendingResultRef.current = result; - flushPending(); - }, - async teardown() { - const bridge = bridgeRef.current; - if (bridge === null || teardownStartedRef.current) return; - teardownStartedRef.current = true; - // Null the ref synchronously so a concurrent unmount cleanup cannot - // see a still-live bridge and dispose it a second time. Bumping the - // build id makes any in-flight factory self-dispose, and clearing the - // pending-dispose flag/cached deps prevents the deferred dispose from - // acting on an already torn-down bridge. - buildIdRef.current += 1; - disposeScheduledRef.current = false; - lastDepsRef.current = null; - bridgeRef.current = null; - initializedRef.current = false; - pendingInputRef.current = null; - pendingResultRef.current = null; - await disposeBridge(bridge); - }, - }), - [flushPending], - ); - - // The iframe deliberately has no `sandbox` attribute: `sandboxPath` - // resolves to the host's own trusted same-origin sandbox page, which then - // loads the untrusted MCP App content into a nested sandboxed iframe. - // Sandboxing this outer frame would block the postMessage bridge. - return ( -