Skip to content

Refactor MCP generated UI shell rendering - #146

Merged
kentcdodds merged 8 commits into
mainfrom
cursor/mcp-shell-dom-renderer-2540
Apr 10, 2026
Merged

kentcdodds merged 8 commits into
mainfrom
cursor/mcp-shell-dom-renderer-2540

Conversation

@kentcdodds

@kentcdodds kentcdodds commented Apr 10, 2026 •

Copy link
Copy Markdown
Owner

Summary

  • replace the MCP generated-UI shell document.write render loop with a stable shell document that parses model HTML inertly and mounts user head/body content into shell-owned slots
  • replay extracted scripts with explicit loader semantics so inline classic scripts run in isolated scopes while module and external scripts preserve ordering
  • split MCP runtime lifecycle into initialize-once plus per-render bootstrap updates, and add focused node/browser regression coverage for rerenders and duplicate inline scripts
Open in Web Open in Cursor 

Summary by CodeRabbit

  • Tests

    • Added an end-to-end test covering generated UI shell rerendering for inline and saved apps and lifecycle messaging.
    • Added a unit test ensuring runtime bootstrap updates refresh widget parameters without full reinitialization.
  • Improvements

    • Reworked shell rendering to update head/body and execute scripts without full document rewrites, improving stability and responsiveness.
    • Made runtime bootstrap handling more robust and tolerant so updates take effect reliably during active sessions.

Co-authored-by: Kent C. Dodds <me+github@kentcdodds.com>
@coderabbitai

coderabbitai Bot commented Apr 10, 2026 •

Copy link
Copy Markdown

Note

Reviews paused

It looks like this branch is under active development. To avoid overwhelming you with review comments due to an influx of new commits, CodeRabbit has automatically paused this review. You can configure this behavior by changing the reviews.auto_review.auto_pause_after_reviewed_commits setting.

Use the following commands to manage reviews:

  • @coderabbitai resume to resume automatic reviews.
  • @coderabbitai review to trigger a single review.

Use the checkboxes below for quick actions:

  • ▶️ Resume reviews
  • 🔍 Trigger review
📝 Walkthrough

Walkthrough

Implements a DOM-based generated-UI shell render pipeline (preserving head/body slots, classifying/executing scripts), introduces updateGeneratedUiRuntimeBootstrap() to atomically apply normalized bootstrap without reinitializing, and adds E2E and unit tests validating inline/saved rerenders and bootstrap updates.

Changes

Cohort / File(s) Summary
E2E test (host + iframe)
e2e/generated-ui-shell.spec.ts
Adds a Playwright E2E that mounts /dev/generated-ui iframe, injects host state/actions (renderInline/renderSaved), handles postMessage JSON‑RPC, and asserts inline/saved renders, dataset/attribute propagation, size notifications, and tool-call sequencing.
Shell render pipeline & utilities
packages/worker/client/mcp-apps/kody-ui-utils.ts
Replaces full-document write with a DOM-based shell render pipeline: persistent head/body mount slots, DOMParser-driven classification, script execution modes (module, native-classic, isolated-classic, data/ignore), title/attribute sync, render queue with monotonic ids, and per-render bootstrap updates via updateGeneratedUiRuntimeBootstrap().
Runtime state / bootstrap API
packages/worker/client/mcp-apps/kody-widget-runtime.ts
Adds normalizeBootstrap() and applyBootstrapToRuntimeState(), exports updateGeneratedUiRuntimeBootstrap() to normalize + apply bootstrap and mark runtime ready when appropriate; initialization now delegates to these helpers.
Unit tests
packages/worker/client/mcp-apps/kody-ui-utils.node.test.ts
Adds a unit test verifying updateGeneratedUiRuntimeBootstrap() updates kodyWidget.params and appSession when called after runtime installation.

Sequence Diagram

sequenceDiagram
    participant Host as Host (page)
    participant Runtime as KodyWidgetRuntime
    participant Iframe as Generated UI iframe

    Host->>Runtime: updateGeneratedUiRuntimeBootstrap(bootstrapA)
    Runtime->>Runtime: normalizeBootstrap() / applyBootstrapToRuntimeState()
    Host->>Iframe: postMessage(renderInline, paramsA)
    Iframe->>Iframe: parse HTML, ensure head/body mount slots, classify scripts
    Iframe->>Iframe: remount managed head/body, execute classified scripts
    Iframe->>Host: postMessage(ui/initialize)
    Iframe->>Host: postMessage(ui/notifications/size-changed)
    Iframe->>Host: postMessage(tools/call execute)
    Host->>Runtime: updateGeneratedUiRuntimeBootstrap(bootstrapB)
    Runtime->>Runtime: normalizeBootstrap() / applyBootstrapToRuntimeState()
    Host->>Iframe: postMessage(renderSaved, paramsB)
    Iframe->>Iframe: remount managed nodes for saved render, execute scripts
    Iframe->>Host: postMessage(tools/call ui_load_app_source)
    Iframe->>Host: postMessage(tools/call execute)
Loading

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~75 minutes

Possibly related PRs

Poem

🐰 I parsed the head and mounted slots with care,

Bootstrap hopped in, params changed in the air,
Inline then saved, the iframe danced bright,
Tool-calls chimed thrice in the test's soft light,
A happy rabbit twitches a celebratory hare!

🚥 Pre-merge checks | ✅ 2 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 0.00% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (2 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title 'Refactor MCP generated UI shell rendering' directly aligns with the primary objective to replace the MCP generated-UI shell document.write render loop with a stable shell document and new rendering pipeline.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch cursor/mcp-shell-dom-renderer-2540

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

@kentcdodds
kentcdodds marked this pull request as ready for review April 10, 2026 02:12
@github-actions

Copy link
Copy Markdown
Contributor

🔎 Preview deployed: https://kody-pr-146.kentcdodds.workers.dev

Worker: kody-pr-146
D1: kody-pr-146-db
KV: kody-pr-146-oauth-kv

Mocks:

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🧹 Nitpick comments (2)
packages/worker/client/mcp-apps/kody-ui-utils.node.test.ts (1)

225-257: Consider extending test coverage to verify appSession update.

The test validates that params are updated correctly, but doesn't verify that the appSession (token and endpoints) is also applied. Since the function updates both, it would strengthen the test to assert both are propagated.

🧪 Proposed extension to verify appSession
 	expect(kodyWidget.params).toEqual({ owner: 'updated', limit: 3 })
+
+	// Access internal state to verify appSession was updated
+	const state = (globalThis as any).__kodyWidgetRuntimeState
+	expect(state?.appSession?.token).toBe('token-2')
+	expect(state?.appSession?.endpoints?.execute).toBe(
+		'https://kody.example/ui-api/next/execute',
+	)
 })
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/worker/client/mcp-apps/kody-ui-utils.node.test.ts` around lines 225
- 257, The test currently checks that params are updated after calling
updateGeneratedUiRuntimeBootstrap but doesn't assert that the new appSession was
applied; update the test (around getKodyWidgetRuntimeStateForTest(),
updateGeneratedUiRuntimeBootstrap(...) and the existing
expect(kodyWidget.params) assertion) to also assert that
kodyWidget.appSession.token equals 'token-2' and that
kodyWidget.appSession.endpoints matches the updated endpoints object (source,
execute, secrets, deleteSecret) to ensure the session token and endpoint URLs
were propagated.
packages/worker/client/mcp-apps/kody-ui-utils.ts (1)

1219-1221: Add origin validation to the message event listener for defense-in-depth.

While handleBridgeResponseMessage and handleLifecycleMessage do validate message structure via isRecord() checks and defensive early returns, the message listener itself lacks origin validation. Adding a check like if (event.origin !== expectedParentOrigin) return would provide an additional layer of security before the message reaches the handlers, preventing even structurally-valid messages from untrusted origins from being processed.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/worker/client/mcp-apps/kody-ui-utils.ts` around lines 1219 - 1221,
Add an origin check before delegating incoming postMessage events so only
trusted origins reach hostBridge.handleHostMessage: inside the
globalThis.window.addEventListener('message', ...) callback, compare
event.origin to a trusted origin (e.g., an expectedParentOrigin constant or a
value from hostBridge like hostBridge.expectedParentOrigin) and return early if
it does not match, then call hostBridge.handleHostMessage(event.data); this
provides defense-in-depth by blocking messages from untrusted origins before
they hit handleHostMessage.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Nitpick comments:
In `@packages/worker/client/mcp-apps/kody-ui-utils.node.test.ts`:
- Around line 225-257: The test currently checks that params are updated after
calling updateGeneratedUiRuntimeBootstrap but doesn't assert that the new
appSession was applied; update the test (around
getKodyWidgetRuntimeStateForTest(), updateGeneratedUiRuntimeBootstrap(...) and
the existing expect(kodyWidget.params) assertion) to also assert that
kodyWidget.appSession.token equals 'token-2' and that
kodyWidget.appSession.endpoints matches the updated endpoints object (source,
execute, secrets, deleteSecret) to ensure the session token and endpoint URLs
were propagated.

In `@packages/worker/client/mcp-apps/kody-ui-utils.ts`:
- Around line 1219-1221: Add an origin check before delegating incoming
postMessage events so only trusted origins reach hostBridge.handleHostMessage:
inside the globalThis.window.addEventListener('message', ...) callback, compare
event.origin to a trusted origin (e.g., an expectedParentOrigin constant or a
value from hostBridge like hostBridge.expectedParentOrigin) and return early if
it does not match, then call hostBridge.handleHostMessage(event.data); this
provides defense-in-depth by blocking messages from untrusted origins before
they hit handleHostMessage.

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: a89a2329-8f9d-4855-8c32-423711561f1f

📥 Commits

Reviewing files that changed from the base of the PR and between 3a8c8eb and 0ca04cd.

📒 Files selected for processing (4)
  • e2e/generated-ui-shell.spec.ts
  • packages/worker/client/mcp-apps/kody-ui-utils.node.test.ts
  • packages/worker/client/mcp-apps/kody-ui-utils.ts
  • packages/worker/client/mcp-apps/kody-widget-runtime.ts

Comment thread packages/worker/client/mcp-apps/kody-ui-utils.ts
Comment thread packages/worker/client/mcp-apps/kody-ui-utils.ts
Co-authored-by: Kent C. Dodds <me+github@kentcdodds.com>

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 2

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
packages/worker/client/mcp-apps/kody-ui-utils.ts (1)

1153-1219: ⚠️ Potential issue | 🔴 Critical

Guard shell renders with a generation token.

renderGeneratedUiShellDocument() now awaits external/module scripts, but nothing cancels an older render once a newer envelope arrives. That lets stale renders resume later and append old scripts into the current slots, mixing two app versions in one document.

🛡️ Suggested direction
+ let renderGeneration = 0

  const renderEnvelope = async (envelope: RenderEnvelope | null) => {
+   const generation = ++renderGeneration
    latestEnvelope = envelope
    if (!envelope) {
      return
    }

    const renderCode = async (code: string, runtime: AppRuntime) => {
      if (!shellRenderState) {
        return
      }
      const renderSource = buildGeneratedUiShellRenderSource({
        code,
        runtime,
        baseHref,
      })
      await renderGeneratedUiShellDocument({
        state: shellRenderState,
        htmlSource: renderSource.htmlSource,
        preserveDocumentChrome: renderSource.preserveDocumentChrome,
+       isCurrent: () => generation === renderGeneration,
      })
    }

Then bail out inside renderGeneratedUiShellDocument() before DOM writes and after each awaited script if isCurrent() is false.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/worker/client/mcp-apps/kody-ui-utils.ts` around lines 1153 - 1219,
The render flow can let older async renders resume and mutate the DOM; to fix,
introduce a short-lived generation token (e.g., renderToken) updated at the
start of renderEnvelope and capture it in each async path (renderCode,
renderError, and before/after calling resolveSavedAppCode), then pass that token
into renderGeneratedUiShellDocument (or attach it to shellRenderState) and have
renderGeneratedUiShellDocument check the token and bail (return) before any DOM
writes and after awaiting external/module scripts if the token is no longer
current; ensure every early-return path in renderEnvelope (inline_code, missing
appId, catch) also checks the token (compare latestEnvelope or token) so only
the most recent envelope's render proceeds.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@packages/worker/client/mcp-apps/kody-ui-utils.ts`:
- Around line 1193-1199: In the envelope.mode === 'inline_code' branch the call
to renderCode(envelope.code, envelope.runtime ?? 'html') can throw and is not
caught, so wrap that await call in a try/catch (similar to the saved-apps path)
and on error call await renderError(...) with the error details and return;
ensure you still validate envelope.code first and preserve the runtime fallback
(envelope.runtime ?? 'html') when invoking renderCode.
- Around line 712-724: The function getShellScriptExecutionMode currently
returns 'ignore' for normalizedType === 'importmap', which drops importmap
scripts and breaks module specifier resolution; change the behavior so importmap
scripts are preserved (e.g., return 'data' for normalizedType === 'importmap')
so they are processed before replaying modules; keep 'speculationrules' as
'ignore' if desired, and leave other branches (module -> 'module', non-classic
-> 'data', classic -> 'native-classic'|'isolated-classic') intact; update the
conditional in getShellScriptExecutionMode and ensure callers that treat 'data'
scripts will retain and re-insert import maps.

---

Outside diff comments:
In `@packages/worker/client/mcp-apps/kody-ui-utils.ts`:
- Around line 1153-1219: The render flow can let older async renders resume and
mutate the DOM; to fix, introduce a short-lived generation token (e.g.,
renderToken) updated at the start of renderEnvelope and capture it in each async
path (renderCode, renderError, and before/after calling resolveSavedAppCode),
then pass that token into renderGeneratedUiShellDocument (or attach it to
shellRenderState) and have renderGeneratedUiShellDocument check the token and
bail (return) before any DOM writes and after awaiting external/module scripts
if the token is no longer current; ensure every early-return path in
renderEnvelope (inline_code, missing appId, catch) also checks the token
(compare latestEnvelope or token) so only the most recent envelope's render
proceeds.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: ddbe557d-9843-4f9c-931f-9d2d412f20e9

📥 Commits

Reviewing files that changed from the base of the PR and between 0ca04cd and cbfd27d.

📒 Files selected for processing (1)
  • packages/worker/client/mcp-apps/kody-ui-utils.ts

Comment thread packages/worker/client/mcp-apps/kody-ui-utils.ts
Comment on lines 1193 to 1199
if (envelope.mode === 'inline_code') {
if (!envelope.code) {
writeDocument(
renderGeneratedUiErrorDocument(
'The tool result did not include inline code.',
),
)
await renderError('The tool result did not include inline code.')
return
}
writeDocument(buildDocument(envelope.code, envelope.runtime ?? 'html'))
activateMcpGeneratedUiRuntime(
hostBridge,
mcpRuntimeBootstrap,
latestRenderDataRef,
)
await renderCode(envelope.code, envelope.runtime ?? 'html')
return

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Inline-code renders need the same error fallback as saved apps.

renderCode() can now throw, but the inline_code branch does not catch it. A bad external/module script here leaves the shell partially rendered with no error document.

💡 Proposed fix
 		if (envelope.mode === 'inline_code') {
 			if (!envelope.code) {
 				await renderError('The tool result did not include inline code.')
 				return
 			}
-			await renderCode(envelope.code, envelope.runtime ?? 'html')
+			try {
+				await renderCode(envelope.code, envelope.runtime ?? 'html')
+			} catch (error) {
+				if (latestEnvelope !== envelope) return
+				await renderError(
+					error instanceof Error ? error.message : 'Unknown render error.',
+				)
+			}
 			return
 		}
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/worker/client/mcp-apps/kody-ui-utils.ts` around lines 1193 - 1199,
In the envelope.mode === 'inline_code' branch the call to
renderCode(envelope.code, envelope.runtime ?? 'html') can throw and is not
caught, so wrap that await call in a try/catch (similar to the saved-apps path)
and on error call await renderError(...) with the error details and return;
ensure you still validate envelope.code first and preserve the runtime fallback
(envelope.runtime ?? 'html') when invoking renderCode.

@cursor cursor Bot left a comment •

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Cursor Bugbot has reviewed your changes and found 2 potential issues.

Autofix Details

Bugbot Autofix prepared fixes for both issues found in the latest run.

  • ✅ Fixed: Test joins script lines without newlines breaking ASI
    • Updated the test HTML assembly to join lines with newlines so inline and module scripts retain valid statement separation.
  • ✅ Fixed: App session lost during bootstrap normalization round-trip
    • Relaxed app session coercion to accept bootstrap sessions without sessionId/expiresAt while preserving token and endpoints for HTTP execution.
Preview (21023b45e6)
diff --git a/e2e/generated-ui-shell.spec.ts b/e2e/generated-ui-shell.spec.ts
new file mode 100644
--- /dev/null
+++ b/e2e/generated-ui-shell.spec.ts
@@ -1,0 +1,246 @@
+import { expect, test } from '@playwright/test'
+
+test('generated UI shell rerenders inline and saved apps without document rewrites', async ({
+	page,
+	baseURL,
+}) => {
+	if (!baseURL) {
+		throw new Error('Playwright baseURL is required for generated UI shell tests.')
+	}
+
+	const runtimeUrl = new URL('/dev/generated-ui', baseURL).toString()
+	const inlineHtml = [
+		'<main id="inline-app"></main>',
+		'<script>',
+		"const btn = document.createElement('button')",
+		"btn.id = 'first-inline-button'",
+		"btn.textContent = 'First inline button'",
+		"document.querySelector('#inline-app')?.append(btn)",
+		'</script>',
+		'<script>',
+		"const btn = document.createElement('button')",
+		"btn.id = 'second-inline-button'",
+		"btn.textContent = 'Second inline button'",
+		"document.querySelector('#inline-app')?.append(btn)",
+		"window.kodyWidget.executeCode('async () => ({ phase: \"inline\", owner: window.kodyWidget.params.owner })').then((result) => {",
+		"\tdocument.body.dataset.inlineExecute = JSON.stringify(result)",
+		'})',
+		'</script>',
+	].join('\n')
+	const savedHtml = [
+		'<!doctype html>',
+		'<html lang="en" data-shell-phase="saved">',
+		'<head>',
+		'<title>Saved app</title>',
+		'<style>body { font-family: system-ui, sans-serif; }</style>',
+		'</head>',
+		'<body data-shell-body="saved">',
+		'<main id="saved-app"><button id="saved-app-button">Saved app</button></main>',
+		'<script type="module">',
+		"const result = await window.kodyWidget.executeCode('async () => ({ phase: \"saved\", owner: window.kodyWidget.params.owner })')",
+		"window.document.body.dataset.savedExecute = JSON.stringify(result)",
+		"window.document.body.dataset.savedParams = JSON.stringify(window.kodyWidget.params)",
+		"window.document.documentElement.dataset.savedHtmlAttr = window.document.documentElement.getAttribute('data-shell-phase') ?? 'missing'",
+		'</script>',
+		'</body>',
+		'</html>',
+	].join('\n')
+
+	await page.setContent(`
+		<!doctype html>
+		<html lang="en">
+			<body>
+				<iframe
+					id="generated-ui-frame"
+					src=${JSON.stringify(runtimeUrl)}
+					style="width: 960px; height: 720px; border: 0;"
+				></iframe>
+				<script>
+					const frame = document.getElementById('generated-ui-frame')
+					const hostState = {
+						renderData: undefined,
+						lastSize: null,
+						toolCalls: [],
+						savedSource: {
+							code: ${JSON.stringify(savedHtml)},
+							runtime: 'html',
+						},
+					}
+					window.__generatedUiHostState = hostState
+					function postToFrame(message) {
+						frame.contentWindow?.postMessage(message, '*')
+					}
+					function postRenderData() {
+						postToFrame({
+							type: 'ui-lifecycle-iframe-render-data',
+							payload: {
+								renderData: hostState.renderData,
+							},
+						})
+					}
+					window.__generatedUiHostActions = {
+						renderInline(params) {
+							hostState.renderData = {
+								toolOutput: {
+									renderSource: 'inline_code',
+									code: ${JSON.stringify(inlineHtml)},
+									runtime: 'html',
+									params,
+								},
+							}
+							postRenderData()
+						},
+						renderSaved(params) {
+							hostState.renderData = {
+								toolOutput: {
+									renderSource: 'saved_app',
+									appId: 'saved-app-123',
+									params,
+								},
+							}
+							postRenderData()
+						},
+					}
+					window.addEventListener('message', (event) => {
+						if (event.source !== frame.contentWindow) {
+							return
+						}
+						const message = event.data
+						if (!message || typeof message !== 'object') {
+							return
+						}
+						if (message.type === 'ui-request-render-data') {
+							postRenderData()
+							return
+						}
+						if (message.jsonrpc === '2.0' && message.method === 'ui/initialize') {
+							postToFrame({
+								jsonrpc: '2.0',
+								id: message.id,
+								result: {
+									protocolVersion: '2026-01-26',
+									hostInfo: {
+										name: 'playwright-shell-host',
+										version: '1.0.0',
+									},
+									hostCapabilities: {
+										message: { text: {} },
+										serverTools: {},
+									},
+									hostContext: hostState.renderData ?? {},
+								},
+							})
+							return
+						}
+						if (
+							message.jsonrpc === '2.0' &&
+							message.method === 'ui/notifications/size-changed'
+						) {
+							hostState.lastSize = message.params ?? null
+							return
+						}
+						if (message.jsonrpc === '2.0' && message.method === 'tools/call') {
+							hostState.toolCalls.push(message.params ?? {})
+							if (message.params?.name === 'ui_load_app_source') {
+								postToFrame({
+									jsonrpc: '2.0',
+									id: message.id,
+									result: {
+										structuredContent: hostState.savedSource,
+									},
+								})
+								return
+							}
+							if (message.params?.name === 'execute') {
+								postToFrame({
+									jsonrpc: '2.0',
+									id: message.id,
+									result: {
+										structuredContent: {
+											result: {
+												phase:
+													hostState.toolCalls.filter(
+														(call) => call?.name === 'execute',
+													).length === 1
+														? 'inline'
+														: 'saved',
+												owner:
+													hostState.renderData?.toolOutput?.params?.owner ??
+													null,
+											},
+										},
+									},
+								})
+							}
+						}
+					})
+				</script>
+			</body>
+		</html>
+	`)
+
+	await page.waitForSelector('#generated-ui-frame')
+	await page.evaluate(() => {
+		;(window as typeof window & {
+			__generatedUiHostActions: {
+				renderInline: (params: Record<string, unknown>) => void
+			}
+		}).__generatedUiHostActions.renderInline({ owner: 'alpha' })
+	})
+
+	const frame = page.frameLocator('#generated-ui-frame')
+	await expect(
+		frame.getByRole('button', { name: 'First inline button' }),
+	).toBeVisible()
+	await expect(
+		frame.getByRole('button', { name: 'Second inline button' }),
+	).toBeVisible()
+	await expect(frame.locator('body')).toHaveAttribute(
+		'data-inline-execute',
+		/alpha/,
+	)
+	await expect
+		.poll(async () => {
+			return await page.evaluate(() => {
+				return (
+					(window as typeof window & {
+						__generatedUiHostState: { lastSize?: { height?: number } | null }
+					}).__generatedUiHostState.lastSize?.height ?? 0
+				)
+			})
+		})
+		.toBeGreaterThan(0)
+
+	await page.evaluate(() => {
+		;(window as typeof window & {
+			__generatedUiHostActions: {
+				renderSaved: (params: Record<string, unknown>) => void
+			}
+		}).__generatedUiHostActions.renderSaved({ owner: 'beta', count: 2 })
+	})
+
+	await expect(
+		frame.getByRole('button', { name: 'Saved app' }),
+	).toBeVisible()
+	await expect(frame.locator('html')).toHaveAttribute('data-shell-phase', 'saved')
+	await expect(frame.locator('body')).toHaveAttribute('data-shell-body', 'saved')
+	await expect(frame.locator('body')).toHaveAttribute('data-saved-execute', /beta/)
+	await expect(frame.locator('body')).toHaveAttribute('data-saved-params', /beta/)
+	await expect(frame.locator('html')).toHaveAttribute(
+		'data-saved-html-attr',
+		'saved',
+	)
+	await expect
+		.poll(async () => {
+			return await page.evaluate(() => {
+				return (
+					(window as typeof window & {
+						__generatedUiHostState: {
+							toolCalls: Array<{ name?: string }>
+						}
+					}).__generatedUiHostState.toolCalls.map((call) => call.name)
+				)
+			})
+		})
+		.toEqual(['execute', 'ui_load_app_source', 'execute'])
+})

diff --git a/packages/worker/client/mcp-apps/kody-ui-utils.node.test.ts b/packages/worker/client/mcp-apps/kody-ui-utils.node.test.ts
--- a/packages/worker/client/mcp-apps/kody-ui-utils.node.test.ts
+++ b/packages/worker/client/mcp-apps/kody-ui-utils.node.test.ts
@@ -1,6 +1,9 @@
 import { expect, test, vi } from 'vitest'
 import * as uiUtils from './kody-ui-utils.ts'
-import { getKodyWidgetRuntimeStateForTest } from './kody-widget-runtime.ts'
+import {
+	getKodyWidgetRuntimeStateForTest,
+	updateGeneratedUiRuntimeBootstrap,
+} from './kody-widget-runtime.ts'
 
 const {
 	absolutizeHtmlAttributeUrls,
@@ -219,6 +222,40 @@
 	await expect(kodyWidget.executeCode('return "ok"')).resolves.toBe('ok')
 })
 
+test('runtime bootstrap updates refresh params and app session without reinit', () => {
+	const runtimeState = getKodyWidgetRuntimeStateForTest()
+	runtimeState.reset()
+	runtimeState.install({
+		mode: 'mcp',
+		params: { owner: 'kody' },
+		appSession: {
+			token: 'token-1',
+			endpoints: {
+				source: 'https://kody.example/ui-api/app/source',
+				execute: 'https://kody.example/ui-api/app/execute',
+				secrets: 'https://kody.example/ui-api/app/secrets',
+				deleteSecret: 'https://kody.example/ui-api/app/secrets/delete',
+			},
+		},
+	})
+
+	updateGeneratedUiRuntimeBootstrap({
+		mode: 'mcp',
+		params: { owner: 'updated', limit: 3 },
+		appSession: {
+			token: 'token-2',
+			endpoints: {
+				source: 'https://kody.example/ui-api/next/source',
+				execute: 'https://kody.example/ui-api/next/execute',
+				secrets: 'https://kody.example/ui-api/next/secrets',
+				deleteSecret: 'https://kody.example/ui-api/next/secrets/delete',
+			},
+		},
+	})
+
+	expect(kodyWidget.params).toEqual({ owner: 'updated', limit: 3 })
+})
+
 test('runtime-backed helpers time out if the runtime never becomes ready', async () => {
 	vi.useFakeTimers()
 	try {

diff --git a/packages/worker/client/mcp-apps/kody-ui-utils.ts b/packages/worker/client/mcp-apps/kody-ui-utils.ts
--- a/packages/worker/client/mcp-apps/kody-ui-utils.ts
+++ b/packages/worker/client/mcp-apps/kody-ui-utils.ts
@@ -4,6 +4,7 @@
 import {
 	initializeGeneratedUiRuntime,
 	setGeneratedUiRuntimeHooks,
+	updateGeneratedUiRuntimeBootstrap,
 } from './kody-widget-runtime.ts'
 import {
 	buildGeneratedUiRuntimeImportMap,
@@ -38,8 +39,8 @@
 type DisplayMode = 'inline' | 'fullscreen' | 'pip'
 
 type AppSessionEnvelope = {
-	sessionId: string
-	expiresAt: string
+	sessionId?: string
+	expiresAt?: string
 	endpoints: {
 		source: string
 		execute: string
@@ -91,6 +92,39 @@
 	documentElement?: SizeMeasurementElement | null
 }
 
+type ShellScriptExecutionMode =
+	| 'module'
+	| 'native-classic'
+	| 'isolated-classic'
+	| 'ignore'
+	| 'data'
+
+type ShellScriptDescriptor = {
+	target: 'head' | 'body'
+	executionMode: ShellScriptExecutionMode
+	attributes: Array<{ name: string; value: string }>
+	src: string | null
+	textContent: string
+}
+
+type ParsedShellRenderDocument = {
+	title: string | null
+	htmlAttributes: Array<{ name: string; value: string }>
+	bodyAttributes: Array<{ name: string; value: string }>
+	headNodes: Array<Node>
+	bodyNodes: Array<Node>
+	scripts: Array<ShellScriptDescriptor>
+}
+
+type GeneratedUiShellRenderState = {
+	headSlot: HTMLElement
+	bodySlot: HTMLElement
+	mountedHeadNodes: Array<Node>
+	managedHtmlAttributes: Set<string>
+	managedBodyAttributes: Set<string>
+	defaultTitle: string
+}
+
 type HostToolResult = {
 	structuredContent?: unknown
 	isError?: boolean
@@ -172,19 +206,13 @@
 
 function coerceAppSession(value: unknown): AppSessionEnvelope | null {
 	if (!isRecord(value)) return null
-	if (
-		typeof value.sessionId !== 'string' ||
-		typeof value.expiresAt !== 'string'
-	) {
-		return null
-	}
 	const endpoints = coerceGeneratedUiEndpoints(value.endpoints)
 	if (!endpoints) {
 		return null
 	}
 	return {
-		sessionId: value.sessionId,
-		expiresAt: value.expiresAt,
+		sessionId: typeof value.sessionId === 'string' ? value.sessionId : undefined,
+		expiresAt: typeof value.expiresAt === 'string' ? value.expiresAt : undefined,
 		endpoints,
 		token: typeof value.token === 'string' ? value.token : undefined,
 	}
@@ -561,14 +589,334 @@
 	}
 }
 
-function writeDocument(html: string) {
+const generatedUiUserHeadSlotSelector = '[data-generated-ui-user-head-slot="true"]'
+const generatedUiBodySlotSelector = '[data-generated-ui-body-slot="true"]'
+
+function ensureGeneratedUiShellRenderState():
+	| GeneratedUiShellRenderState
+	| null {
 	const documentRef = globalThis.document
+	if (!documentRef?.head || !documentRef.body) {
+		return null
+	}
+	const existingHeadSlot = documentRef.head.querySelector(
+		generatedUiUserHeadSlotSelector,
+	)
+	let headSlot: HTMLElement
+	if (existingHeadSlot instanceof HTMLElement) {
+		headSlot = existingHeadSlot
+	} else {
+		headSlot = documentRef.createElement('meta')
+		headSlot.setAttribute('data-generated-ui-user-head-slot', 'true')
+		documentRef.head.appendChild(headSlot)
+	}
+	const existingBodySlot = documentRef.body.querySelector(
+		generatedUiBodySlotSelector,
+	)
+	let bodySlot: HTMLElement
+	if (existingBodySlot instanceof HTMLElement) {
+		bodySlot = existingBodySlot
+	} else {
+		bodySlot = documentRef.createElement('div')
+		bodySlot.setAttribute('id', 'app')
+		bodySlot.setAttribute('data-generated-ui-root', '')
+		bodySlot.setAttribute('data-generated-ui-body-slot', 'true')
+		documentRef.body.insertBefore(bodySlot, documentRef.body.firstChild)
+	}
+	return {
+		headSlot,
+		bodySlot,
+		mountedHeadNodes: [],
+		managedHtmlAttributes: new Set<string>(),
+		managedBodyAttributes: new Set<string>(),
+		defaultTitle: documentRef.title,
+	}
+}
+
+function clearGeneratedUiShellHead(state: GeneratedUiShellRenderState) {
+	for (const node of state.mountedHeadNodes) {
+		node.parentNode?.removeChild(node)
+	}
+	state.mountedHeadNodes = []
+}
+
+function setManagedElementAttributes(
+	element: HTMLElement,
+	nextAttributes: Array<{ name: string; value: string }>,
+	managedAttributes: Set<string>,
+) {
+	for (const name of managedAttributes) {
+		element.removeAttribute(name)
+	}
+	managedAttributes.clear()
+	for (const attribute of nextAttributes) {
+		element.setAttribute(attribute.name, attribute.value)
+		managedAttributes.add(attribute.name)
+	}
+}
+
+function mountGeneratedUiShellHeadNodes(
+	state: GeneratedUiShellRenderState,
+	nodes: Array<Node>,
+) {
+	const documentRef = globalThis.document
 	if (!documentRef) return
-	documentRef.open()
-	documentRef.write(html)
-	documentRef.close()
+	const parentNode = state.headSlot.parentNode
+	if (!parentNode) return
+	for (const node of nodes) {
+		const clone = documentRef.importNode(node, true)
+		parentNode.insertBefore(clone, state.headSlot)
+		state.mountedHeadNodes.push(clone)
+	}
 }
 
+function mountGeneratedUiShellBodyNodes(
+	state: GeneratedUiShellRenderState,
+	nodes: Array<Node>,
+) {
+	const documentRef = globalThis.document
+	if (!documentRef) return
+	state.bodySlot.replaceChildren(
+		...nodes.map((node) => documentRef.importNode(node, true)),
+	)
+}
+
+function preserveGeneratedUiHeadNode(node: Node) {
+	if (node instanceof HTMLTitleElement) return false
+	if (node instanceof HTMLBaseElement) return false
+	if (node instanceof HTMLMetaElement) {
+		return !node.hasAttribute('charset') && node.httpEquiv.length === 0
+	}
+	if (node.nodeType === Node.TEXT_NODE) {
+		return (node.textContent ?? '').trim().length > 0
+	}
+	return true
+}
+
+function isClassicJavascriptScriptType(type: string) {
+	return (
+		type === '' ||
+		type === 'application/ecmascript' ||
+		type === 'application/javascript' ||
+		type === 'text/ecmascript' ||
+		type === 'text/javascript'
+	)
+}
+
+function getShellScriptExecutionMode(script: HTMLScriptElement) {
+	const normalizedType = script.type.trim().toLowerCase()
+	if (normalizedType === 'importmap' || normalizedType === 'speculationrules') {
+		return 'ignore' as const
+	}
+	if (normalizedType === 'module') {
+		return 'module' as const
+	}
+	if (!isClassicJavascriptScriptType(normalizedType)) {
+		return 'data' as const
+	}
+	return script.src ? ('native-classic' as const) : ('isolated-classic' as const)
+}
+
+function collectElementAttributes(element: Element) {
+	return Array.from(element.attributes, (attribute) => ({
+		name: attribute.name,
+		value: attribute.value,
+	}))
+}
+
+function classifyGeneratedUiShellRenderDocument(input: {
+	documentRef: Document
+	preserveDocumentChrome: boolean
+}) {
+	const parsed: ParsedShellRenderDocument = {
+		title: input.preserveDocumentChrome ? (input.documentRef.title || null) : null,
+		htmlAttributes: input.preserveDocumentChrome
+			? collectElementAttributes(input.documentRef.documentElement)
+			: [],
+		bodyAttributes: input.preserveDocumentChrome
+			? collectElementAttributes(input.documentRef.body)
+			: [],
+		headNodes: [],
+		bodyNodes: [],
+		scripts: [],
+	}
+
+	for (const node of Array.from(input.documentRef.head.childNodes)) {
+		if (node instanceof HTMLScriptElement) {
+			const executionMode = getShellScriptExecutionMode(node)
+			if (executionMode === 'data') {
+				if (preserveGeneratedUiHeadNode(node)) {
+					parsed.headNodes.push(node)
+				}
+				continue
+			}
+			if (executionMode === 'ignore') {
+				continue
+			}
+			parsed.scripts.push({
+				target: 'head',
+				executionMode,
+				attributes: collectElementAttributes(node),
+				src: node.src || null,
+				textContent: node.textContent ?? '',
+			})
+			continue
+		}
+		if (preserveGeneratedUiHeadNode(node)) {
+			parsed.headNodes.push(node)
+		}
+	}
+
+	for (const node of Array.from(input.documentRef.body.childNodes)) {
+		if (node instanceof HTMLScriptElement) {
+			const executionMode = getShellScriptExecutionMode(node)
+			if (executionMode === 'data') {
+				parsed.bodyNodes.push(node)
+				continue
+			}
+			if (executionMode === 'ignore') {
+				continue
+			}
+			parsed.scripts.push({
+				target: 'body',
+				executionMode,
+				attributes: collectElementAttributes(node),
+				src: node.src || null,
+				textContent: node.textContent ?? '',
+			})
+			continue
+		}
+		parsed.bodyNodes.push(node)
+	}
+
+	return parsed
+}
+
+function wrapInlineClassicScriptForIsolation(source: string) {
+	return [';(function () {', source, '}).call(window);'].join('\n')
+}
+
+async function executeGeneratedUiShellScript(
+	state: GeneratedUiShellRenderState,
+	scriptDescriptor: ShellScriptDescriptor,
+) {
+	const documentRef = globalThis.document
+	if (!documentRef) return
+	const script = documentRef.createElement('script')
+	const insertScript = () => {
+		if (scriptDescriptor.target === 'head') {
+			state.headSlot.parentNode?.insertBefore(script, state.headSlot)
+			state.mountedHeadNodes.push(script)
+			return
+		}
+		state.bodySlot.appendChild(script)
+	}
+	for (const attribute of scriptDescriptor.attributes) {
+		if (attribute.name === 'src' || attribute.name === 'type') {
+			continue
+		}
+		script.setAttribute(attribute.name, attribute.value)
+	}
+	if (scriptDescriptor.executionMode === 'module') {
+		script.type = 'module'
+	}
+	if (scriptDescriptor.executionMode === 'isolated-classic') {
+		script.textContent = wrapInlineClassicScriptForIsolation(
+			scriptDescriptor.textContent,
+		)
+		insertScript()
+		return
+	}
+	if (!scriptDescriptor.src) {
+		script.textContent = scriptDescriptor.textContent
+		if (scriptDescriptor.executionMode === 'module') {
+			const loading = new Promise<void>((resolve, reject) => {
+				script.addEventListener('load', () => resolve(), { once: true })
+				script.addEventListener(
+					'error',
+					() => {
+						reject(new Error('Failed to execute generated UI module script.'))
+					},
+					{ once: true },
+				)
+			})
+			insertScript()
+			await loading
+			return
+		}
+		insertScript()
+		return
+	}
+	const loading = new Promise<void>((resolve, reject) => {
+		script.addEventListener('load', () => resolve(), { once: true })
+		script.addEventListener(
+			'error',
+			() => {
+				reject(new Error(`Failed to load generated UI script: ${script.src}`))
+			},
+			{ once: true },
+		)
+	})
+	if (scriptDescriptor.executionMode === 'native-classic') {
+		script.async = false
+	}
+	script.src = scriptDescriptor.src
+	insertScript()
+	await loading
+}
+
+function buildGeneratedUiShellRenderSource(input: {
+	code: string
+	runtime: AppRuntime
+	baseHref: string | null
+}) {
+	return {
+		htmlSource: renderGeneratedUiDocument({
+			code: input.code,
+			runtime: input.runtime,
+			headInjection: '',
+			baseHref: input.baseHref,
+		}),
+		preserveDocumentChrome:
+			input.runtime === 'html' && /<(?:!doctype|html|head|body)\b/i.test(input.code),
+	}
+}
+
+async function renderGeneratedUiShellDocument(input: {
+	state: GeneratedUiShellRenderState
+	htmlSource: string
+	preserveDocumentChrome: boolean
+}) {
+	const documentRef = globalThis.document
+	if (!documentRef) return
+	const parsedDocument = new DOMParser().parseFromString(
+		input.htmlSource,
+		'text/html',
+	)
+	const classifiedDocument = classifyGeneratedUiShellRenderDocument({
+		documentRef: parsedDocument,
+		preserveDocumentChrome: input.preserveDocumentChrome,
+	})
+	clearGeneratedUiShellHead(input.state)
+	input.state.bodySlot.replaceChildren()
+	setManagedElementAttributes(
+		documentRef.documentElement,
+		classifiedDocument.htmlAttributes,
+		input.state.managedHtmlAttributes,
+	)
+	setManagedElementAttributes(
+		documentRef.body,
+		classifiedDocument.bodyAttributes,
+		input.state.managedBodyAttributes,
+	)
+	documentRef.title = classifiedDocument.title ?? input.state.defaultTitle
+	mountGeneratedUiShellHeadNodes(input.state, classifiedDocument.headNodes)
+	mountGeneratedUiShellBodyNodes(input.state, classifiedDocument.bodyNodes)
+	for (const scriptDescriptor of classifiedDocument.scripts) {
+		await executeGeneratedUiShellScript(input.state, scriptDescriptor)
+	}
+}
+
 export type BuildGeneratedUiHeadInjectionInput = {
 	mode: Extract<GeneratedUiRuntimeMode, 'hosted' | 'mcp'>
 	params?: Record<string, unknown>
@@ -648,13 +996,15 @@
 		}
 		return (await hostBridge.requestDisplayMode(nextMode)) ?? null
 	}
+	updateGeneratedUiRuntimeBootstrap(bootstrap)
 	installGeneratedUiRuntimeHooks({
 		sendMessage: (text) => hostBridge.sendUserMessageWithFallback(text),
 		openLink: (url) => hostBridge.openLink(url),
 		requestDisplayMode,
 		executeCode: async (code, params) => {
+			const { appSession } = readGeneratedUiBootstrap()
 			const viaHttp = await executeCodeWithHttp(
-				bootstrap.appSession,
+				appSession,
 				code,
 				params,
 			)
@@ -705,6 +1055,7 @@
 		},
 	}
 	let latestEnvelope: RenderEnvelope | null = null
+	const shellRenderState = ensureGeneratedUiShellRenderState()
 
 	const hostBridge = createWidgetHostBridge({
 		appInfo: {
@@ -799,50 +1150,51 @@
 			return
 		}
 
-		const buildDocument = (code: string, runtime: AppRuntime) =>
-			renderGeneratedUiDocument({
+		const mcpRuntimeBootstrap: GeneratedUiRuntimeBootstrap = {
+			mode: 'mcp',
+			params: envelope.params ?? {},
+			...(envelope.appSession ? { appSession: envelope.appSession } : {}),
+		}
+		updateGeneratedUiRuntimeBootstrap(mcpRuntimeBootstrap)
+
+		const renderCode = async (code: string, runtime: AppRuntime) => {
+			if (!shellRenderState) {
+				return
+			}
+			const renderSource = buildGeneratedUiShellRenderSource({
 				code,
 				runtime,
-				headInjection: buildGeneratedUiRuntimeHeadInjection({
-					mode: 'mcp',
-					params: envelope.params,
-					appSession: envelope.appSession ?? undefined,
-					baseHref,
-					includeRuntimeScript: false,
-				}),
 				baseHref,
 			})
+			await renderGeneratedUiShellDocument({
+				state: shellRenderState,
+				htmlSource: renderSource.htmlSource,
+				preserveDocumentChrome: renderSource.preserveDocumentChrome,
+			})
+		}
 
-		const mcpRuntimeBootstrap: GeneratedUiRuntimeBootstrap = {
-			mode: 'mcp',
-			params: envelope.params ?? {},
-			...(envelope.appSession ? { appSession: envelope.appSession } : {}),
+		const renderError = async (message: string) => {
+			if (!shellRenderState) {
+				return
+			}
... diff truncated: showing 800 of 971 lines

You can send follow-ups to the cloud agent here.

Comment thread e2e/generated-ui-shell.spec.ts Outdated
Comment thread packages/worker/client/mcp-apps/kody-ui-utils.ts Outdated
Co-authored-by: Kent C. Dodds <me+github@kentcdodds.com>

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

♻️ Duplicate comments (2)
packages/worker/client/mcp-apps/kody-ui-utils.ts (2)

1187-1192: ⚠️ Potential issue | 🟠 Major

Handle inline_code render failures the same way as saved apps.

renderCode() can now throw during script replay, but this branch lets the error escape. A broken inline app leaves the shell partially rendered instead of switching to the error document.

Suggested fix
 		if (envelope.mode === 'inline_code') {
 			if (!envelope.code) {
 				await renderError('The tool result did not include inline code.')
 				return
 			}
-			await renderCode(envelope.code, envelope.runtime ?? 'html')
+			try {
+				await renderCode(envelope.code, envelope.runtime ?? 'html')
+			} catch (error) {
+				if (latestEnvelope !== envelope) return
+				await renderError(
+					error instanceof Error ? error.message : 'Unknown render error.',
+				)
+			}
 			return
 		}
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/worker/client/mcp-apps/kody-ui-utils.ts` around lines 1187 - 1192,
In the inline_code branch (when envelope.mode === 'inline_code'), wrap the call
to renderCode(envelope.code, ...) in a try/catch and on any thrown error call
await renderError(...) (same message used for saved apps or a descriptive one)
and return; also handle the missing envelope.code case as before; this ensures
renderCode errors are caught and the UI switches to the error document instead
of leaving a partially rendered shell.

706-709: ⚠️ Potential issue | 🟠 Major

Preserve importmap scripts instead of dropping them.

Returning 'ignore' here removes the import map before the replay loop runs, so later module scripts can lose bare-specifier resolution on rerender. importmap needs to survive classification and be inserted before dependent modules.

Suggested fix
-	if (normalizedType === 'importmap' || normalizedType === 'speculationrules') {
+	if (normalizedType === 'speculationrules') {
 		return 'ignore' as const
 	}
+	if (normalizedType === 'importmap') {
+		return 'data' as const
+	}
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/worker/client/mcp-apps/kody-ui-utils.ts` around lines 706 - 709, The
function getShellScriptExecutionMode currently treats script.type ===
'importmap' as 'ignore', which drops the importmap before replay and breaks
bare-specifier resolution; change the branch in getShellScriptExecutionMode so
that when normalizedType === 'importmap' it is preserved (e.g., return a
non-ignoring execution mode such as 'preserve' or the existing mode you use for
keeping scripts) instead of 'ignore', and ensure preserved importmap nodes are
inserted before dependent module scripts during replay so module resolution
remains correct.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@packages/worker/client/mcp-apps/kody-ui-utils.ts`:
- Around line 999-1007: The executeCode hook currently calls
readGeneratedUiBootstrap() and thus uses the shell's initial
window.__kodyGeneratedUiBootstrap (stale appSession); change executeCode in
installGeneratedUiRuntimeHooks to obtain the latest bootstrap from the runtime’s
shared ref/state that updateGeneratedUiRuntimeBootstrap updates (or accept the
current appSession via closure) before calling executeCodeWithHttp so
executeCodeWithHttp receives the up-to-date appSession; update references to
readGeneratedUiBootstrap() in the executeCode implementation to use the runtime
getter/shared ref instead.

---

Duplicate comments:
In `@packages/worker/client/mcp-apps/kody-ui-utils.ts`:
- Around line 1187-1192: In the inline_code branch (when envelope.mode ===
'inline_code'), wrap the call to renderCode(envelope.code, ...) in a try/catch
and on any thrown error call await renderError(...) (same message used for saved
apps or a descriptive one) and return; also handle the missing envelope.code
case as before; this ensures renderCode errors are caught and the UI switches to
the error document instead of leaving a partially rendered shell.
- Around line 706-709: The function getShellScriptExecutionMode currently treats
script.type === 'importmap' as 'ignore', which drops the importmap before replay
and breaks bare-specifier resolution; change the branch in
getShellScriptExecutionMode so that when normalizedType === 'importmap' it is
preserved (e.g., return a non-ignoring execution mode such as 'preserve' or the
existing mode you use for keeping scripts) instead of 'ignore', and ensure
preserved importmap nodes are inserted before dependent module scripts during
replay so module resolution remains correct.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 44ee7baf-a288-42ba-b088-b315b68b3965

📥 Commits

Reviewing files that changed from the base of the PR and between cbfd27d and 21023b4.

📒 Files selected for processing (2)
  • e2e/generated-ui-shell.spec.ts
  • packages/worker/client/mcp-apps/kody-ui-utils.ts

Comment on lines +999 to +1007
updateGeneratedUiRuntimeBootstrap(bootstrap)
installGeneratedUiRuntimeHooks({
sendMessage: (text) => hostBridge.sendUserMessageWithFallback(text),
openLink: (url) => hostBridge.openLink(url),
requestDisplayMode,
executeCode: async (code, params) => {
const { appSession } = readGeneratedUiBootstrap()
const viaHttp = await executeCodeWithHttp(
bootstrap.appSession,
appSession,

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

executeCode is still reading the shell's initial bootstrap.

updateGeneratedUiRuntimeBootstrap() updates runtime state, but this hook still pulls appSession from window.__kodyGeneratedUiBootstrap. After the first activation, rerenders with a new appSession keep using stale bootstrap data, so the HTTP execute path can fall back incorrectly or use the wrong credentials. Read from a shared “latest bootstrap” ref or from runtime state instead.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/worker/client/mcp-apps/kody-ui-utils.ts` around lines 999 - 1007,
The executeCode hook currently calls readGeneratedUiBootstrap() and thus uses
the shell's initial window.__kodyGeneratedUiBootstrap (stale appSession); change
executeCode in installGeneratedUiRuntimeHooks to obtain the latest bootstrap
from the runtime’s shared ref/state that updateGeneratedUiRuntimeBootstrap
updates (or accept the current appSession via closure) before calling
executeCodeWithHttp so executeCodeWithHttp receives the up-to-date appSession;
update references to readGeneratedUiBootstrap() in the executeCode
implementation to use the runtime getter/shared ref instead.

Co-authored-by: Kent C. Dodds <me+github@kentcdodds.com>

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
packages/worker/client/mcp-apps/kody-ui-utils.ts (1)

41-51: ⚠️ Potential issue | 🟡 Minor

AppSessionEnvelope type change allows undefined sessionId/expiresAt — verify type consistency across assignment chain.

The change from required to optional sessionId and expiresAt fields creates a type mismatch: AppSessionEnvelope has optional fields (can be undefined), but downstream StorageContext expects string | null. In generated-ui-api.ts, these optional fields are assigned directly into storageContext objects (lines 234, 298, 337) and sessionExpiresAt (line 340) without explicit null coalescing. While most consumers of StorageContext use defensive patterns (optional chaining ?. or ?? null), the type definition inconsistency should be addressed: either normalize to null at assignment time using context.sessionId ?? null, or update StorageContext to accept string | null | undefined.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/worker/client/mcp-apps/kody-ui-utils.ts` around lines 41 - 51, The
AppSessionEnvelope now allows sessionId and expiresAt to be undefined but
assignments into StorageContext (and into sessionExpiresAt) assume string|null;
to fix, normalize those optional fields at the assignment sites by coalescing
undefined to null (e.g., use sessionId ?? null and expiresAt ?? null when
building storageContext and when setting sessionExpiresAt), or alternatively
update the StorageContext type to accept undefined as well; update the
assignment logic in generated-ui-api.ts where storageContext and
sessionExpiresAt are set (look for uses of storageContext, sessionId, expiresAt,
and sessionExpiresAt) and make the null-coalescing change so types align.
♻️ Duplicate comments (2)
packages/worker/client/mcp-apps/kody-ui-utils.ts (2)

1189-1196: ⚠️ Potential issue | 🟠 Major

Inline code renders still lack error handling.

The inline_code branch at line 1194 calls await renderCode(...) without a try/catch. If script execution fails (e.g., network error loading an external script), the shell will be left in a partial state with no error feedback.

This was flagged in a previous review and remains unaddressed.

💡 Proposed fix
 		if (envelope.mode === 'inline_code') {
 			if (!envelope.code) {
 				await renderError('The tool result did not include inline code.')
 				return
 			}
-			await renderCode(envelope.code, envelope.runtime ?? 'html')
+			try {
+				await renderCode(envelope.code, envelope.runtime ?? 'html')
+			} catch (error) {
+				if (latestEnvelope !== envelope) return
+				await renderError(
+					error instanceof Error ? error.message : 'Unknown render error.',
+				)
+			}
 			return
 		}
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/worker/client/mcp-apps/kody-ui-utils.ts` around lines 1189 - 1196,
The inline_code branch currently calls renderCode(envelope.code,
envelope.runtime) without error handling; wrap that await renderCode call in a
try/catch inside the envelope.mode === 'inline_code' block, catch any thrown
error, call await renderError(...) with a clear message that includes the caught
error (or its message) so the shell reports the failure, and ensure you still
return after handling the error; update references in this block (envelope,
renderCode, renderError) only.

707-721: ⚠️ Potential issue | 🟠 Major

Import maps are still being ignored, breaking module specifier resolution.

The logic at line 709 returns 'ignore' for type="importmap", which drops import map scripts entirely. This was flagged in a previous review and remains unaddressed. Import maps must be processed before any module scripts that depend on their bare specifier mappings.

Consider returning 'data' for importmap type so the script element is preserved in the document, or handle import maps specially to ensure they're inserted before module script execution begins.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/worker/client/mcp-apps/kody-ui-utils.ts` around lines 707 - 721, The
function getShellScriptExecutionMode currently drops import maps by returning
'ignore' for script.type === 'importmap'; change this so importmap scripts are
preserved and processed (e.g., return 'data' for normalizedType === 'importmap'
or add a special branch that flags import maps for pre-processing) so they are
inserted and applied before module scripts run; update the branch in
getShellScriptExecutionMode (and related logic that consumes its return values)
instead of returning 'ignore', and ensure isClassicJavascriptScriptType is still
used for other non-classic types.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@packages/worker/client/mcp-apps/kody-ui-utils.ts`:
- Around line 1217-1220: The message event listener using
globalThis.window.addEventListener currently calls
hostBridge.handleHostMessage(event.data) without origin validation; update the
listener to first check event.origin against an allowlist (or a trustedOrigin
constant) and only call hostBridge.handleHostMessage when the origin matches,
and apply the same origin-check pattern to the other unprotected listener that
dispatches to handleBridgeResponseMessage/handleLifecycleMessage so both entry
points enforce origin validation before processing event.data.

---

Outside diff comments:
In `@packages/worker/client/mcp-apps/kody-ui-utils.ts`:
- Around line 41-51: The AppSessionEnvelope now allows sessionId and expiresAt
to be undefined but assignments into StorageContext (and into sessionExpiresAt)
assume string|null; to fix, normalize those optional fields at the assignment
sites by coalescing undefined to null (e.g., use sessionId ?? null and expiresAt
?? null when building storageContext and when setting sessionExpiresAt), or
alternatively update the StorageContext type to accept undefined as well; update
the assignment logic in generated-ui-api.ts where storageContext and
sessionExpiresAt are set (look for uses of storageContext, sessionId, expiresAt,
and sessionExpiresAt) and make the null-coalescing change so types align.

---

Duplicate comments:
In `@packages/worker/client/mcp-apps/kody-ui-utils.ts`:
- Around line 1189-1196: The inline_code branch currently calls
renderCode(envelope.code, envelope.runtime) without error handling; wrap that
await renderCode call in a try/catch inside the envelope.mode === 'inline_code'
block, catch any thrown error, call await renderError(...) with a clear message
that includes the caught error (or its message) so the shell reports the
failure, and ensure you still return after handling the error; update references
in this block (envelope, renderCode, renderError) only.
- Around line 707-721: The function getShellScriptExecutionMode currently drops
import maps by returning 'ignore' for script.type === 'importmap'; change this
so importmap scripts are preserved and processed (e.g., return 'data' for
normalizedType === 'importmap' or add a special branch that flags import maps
for pre-processing) so they are inserted and applied before module scripts run;
update the branch in getShellScriptExecutionMode (and related logic that
consumes its return values) instead of returning 'ignore', and ensure
isClassicJavascriptScriptType is still used for other non-classic types.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 08592f7e-7a2b-4f10-b155-3faa04d98a39

📥 Commits

Reviewing files that changed from the base of the PR and between 21023b4 and acb057f.

📒 Files selected for processing (2)
  • e2e/generated-ui-shell.spec.ts
  • packages/worker/client/mcp-apps/kody-ui-utils.ts

Comment on lines 1217 to 1220

globalThis.window.addEventListener('message', (event: MessageEvent) => {
hostBridge.handleHostMessage(event.data)
})

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# Check if handleHostMessage has origin validation
rg -A 30 "handleHostMessage" packages/worker/client/mcp-apps/widget-host-bridge.ts

Repository: kentcdodds/kody

Length of output: 2015


🏁 Script executed:

rg "handleBridgeResponseMessage|handleLifecycleMessage" packages/worker/client/mcp-apps/widget-host-bridge.ts -A 15 -B 2

Repository: kentcdodds/kody

Length of output: 1744


🏁 Script executed:

# Also check if there are any origin validations elsewhere in the codebase
rg "event\.origin|origin.*validate" packages/worker/client/mcp-apps/ -B 2 -A 2

Repository: kentcdodds/kody

Length of output: 41


🏁 Script executed:

# Check the full context of where this listener is used - look at initializeShellHostDocument
rg -B 5 -A 10 "globalThis.window.addEventListener.*message" packages/worker/client/mcp-apps/kody-ui-utils.ts

Repository: kentcdodds/kody

Length of output: 895


🏁 Script executed:

# Check if there's any parent window validation or cross-origin handling
rg "parent|cross.origin|window.opener" packages/worker/client/mcp-apps/ -B 2 -A 2

Repository: kentcdodds/kody

Length of output: 6993


🏁 Script executed:

# Check what appInfo contains and how it's set - might indicate context
rg "appInfo.*=" packages/worker/client/mcp-apps/widget-host-bridge.ts -B 2 -A 5

Repository: kentcdodds/kody

Length of output: 404


🏁 Script executed:

# Check if there are any comments about security or origin in the file
rg -i "security|origin|xss|cwe" packages/worker/client/mcp-apps/kody-ui-utils.ts -B 2 -A 2

Repository: kentcdodds/kody

Length of output: 423


🏁 Script executed:

# Find where this origin validation code is located
rg -B 10 -A 5 "const origin = source.origin" packages/worker/client/mcp-apps/

Repository: kentcdodds/kody

Length of output: 1277


🏁 Script executed:

# Search for all addEventListener('message') calls in the file to see the full context
rg -n "addEventListener.*message" packages/worker/client/mcp-apps/kody-ui-utils.ts

Repository: kentcdodds/kody

Length of output: 216


🏁 Script executed:

# Get context around line 1044 to understand the first listener
sed -n '1030,1055p' packages/worker/client/mcp-apps/kody-ui-utils.ts

Repository: kentcdodds/kody

Length of output: 762


🏁 Script executed:

# Get context around line 1218 to confirm it's the reviewed code
sed -n '1200,1230p' packages/worker/client/mcp-apps/kody-ui-utils.ts

Repository: kentcdodds/kody

Length of output: 732


Add origin validation to the message event listener.

This listener lacks origin validation before processing messages from potentially malicious frames (CWE-346). While handleBridgeResponseMessage and handleLifecycleMessage validate message structure, they don't validate the origin. Note that there's a similar unprotected listener at line 1044 that should receive the same fix.

Add an origin check at the entry point:

Suggested fix
 globalThis.window.addEventListener('message', (event: MessageEvent) => {
+	const expectedOrigin = new URL('/', import.meta.url).origin
+	if (event.origin !== expectedOrigin) return
 	hostBridge.handleHostMessage(event.data)
 })
🧰 Tools
🪛 ast-grep (0.42.1)

[warning] 1217-1219: Message event listeners should validate the origin to prevent XSS attacks. Always check the event origin before processing the message.
Context: globalThis.window.addEventListener('message', (event: MessageEvent) => {
hostBridge.handleHostMessage(event.data)
})
Note: [CWE-346] Origin Validation Error [REFERENCES]
- https://cheatsheetseries.owasp.org/cheatsheets/Cross_Site_Scripting_Prevention_Cheat_Sheet.html
- https://developer.mozilla.org/en-US/docs/Web/API/Window/postMessage

(event-origin-validation)


[warning] 1217-1219: Message event listener without origin validation detected. Always validate the event.origin property in message event handlers to prevent XSS attacks from malicious frames or windows.
Context: globalThis.window.addEventListener('message', (event: MessageEvent) => {
hostBridge.handleHostMessage(event.data)
})
Note: [CWE-346] Origin Validation Error [REFERENCES]
- https://developer.mozilla.org/en-US/docs/Web/API/Window/postMessage
- https://cwe.mitre.org/data/definitions/346.html

(message-listener-origin-check)

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/worker/client/mcp-apps/kody-ui-utils.ts` around lines 1217 - 1220,
The message event listener using globalThis.window.addEventListener currently
calls hostBridge.handleHostMessage(event.data) without origin validation; update
the listener to first check event.origin against an allowlist (or a
trustedOrigin constant) and only call hostBridge.handleHostMessage when the
origin matches, and apply the same origin-check pattern to the other unprotected
listener that dispatches to handleBridgeResponseMessage/handleLifecycleMessage
so both entry points enforce origin validation before processing event.data.

Comment thread packages/worker/client/mcp-apps/kody-ui-utils.ts
Comment thread packages/worker/client/mcp-apps/kody-ui-utils.ts
Co-authored-by: Kent C. Dodds <me+github@kentcdodds.com>
Comment thread packages/worker/client/mcp-apps/kody-ui-utils.ts
Comment thread packages/worker/client/mcp-apps/kody-ui-utils.ts
cursoragent and others added 2 commits April 10, 2026 03:57
Co-authored-by: Kent C. Dodds <me+github@kentcdodds.com>
Co-authored-by: Kent C. Dodds <me+github@kentcdodds.com>

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

♻️ Duplicate comments (4)
packages/worker/client/mcp-apps/kody-ui-utils.ts (4)

1190-1196: ⚠️ Potential issue | 🟠 Major

Give inline-code renders the same error fallback as saved apps.

Line 1195 can reject when replaying or loading scripts, but this branch does not catch it. A bad inline render leaves the shell partially updated and skips renderError(...).

Suggested fix
 		if (envelope.mode === 'inline_code') {
 			if (!envelope.code) {
 				await renderError('The tool result did not include inline code.')
 				return
 			}
-			await renderCode(envelope.code, envelope.runtime ?? 'html')
+			try {
+				await renderCode(envelope.code, envelope.runtime ?? 'html')
+			} catch (error) {
+				if (isStaleRender(renderId)) return
+				await renderError(
+					error instanceof Error ? error.message : 'Unknown render error.',
+				)
+			}
 			return
 		}
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/worker/client/mcp-apps/kody-ui-utils.ts` around lines 1190 - 1196,
The inline_code branch currently calls renderCode(envelope.code,
envelope.runtime ?? 'html') without error handling, so failures can leave the UI
in a partial state; wrap the renderCode call in a try/catch (in the same block
that checks envelope.mode === 'inline_code') and on any exception call await
renderError('The tool result did not include inline code.') or a more specific
message, then return—ensuring the same fallback behavior as the saved-apps path
and that renderError is always invoked if renderCode fails.

1029-1031: ⚠️ Potential issue | 🟡 Minor

Validate postMessage origin at both entry points.

Both listeners forward every message event straight into hostBridge.handleHostMessage(...). That means any other frame/window can hit these handlers unless the sender is filtered first.

Suggested fix
+const trustedOrigin = new URL('/', import.meta.url).origin
+
 globalThis.window.addEventListener('message', (event: MessageEvent) => {
+	if (event.origin !== trustedOrigin) return
 	hostBridge.handleHostMessage(event.data)
 })

 // ...

 globalThis.window.addEventListener('message', (event: MessageEvent) => {
+	if (event.origin !== trustedOrigin) return
 	hostBridge.handleHostMessage(event.data)
 })

Also applies to: 1222-1224

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/worker/client/mcp-apps/kody-ui-utils.ts` around lines 1029 - 1031,
The global message handlers (globalThis.window.addEventListener('message', ...)
and the other listener around lines 1222-1224) currently forward all
MessageEvent objects straight into hostBridge.handleHostMessage(event.data);
restrict this by validating the event origin and sender before calling
hostBridge: check event.origin against a configured allowlist (or expected
origin string) and, when messages are expected from a specific iframe/window,
verify event.source === expectedWindowReference (or another trusted check) and
only then call hostBridge.handleHostMessage(event.data); also ensure you drop or
log unexpected origins to avoid silently accepting messages from untrusted
frames.

707-710: ⚠️ Potential issue | 🟠 Major

Preserve importmap scripts during replay.

Line 709 still classifies type="importmap" as ignore, so the map never reaches the live document. Any replayed module that relies on bare specifiers will then resolve without that map and fail.

Suggested fix
 function getShellScriptExecutionMode(script: HTMLScriptElement) {
 	const normalizedType = script.type.trim().toLowerCase()
-	if (normalizedType === 'importmap' || normalizedType === 'speculationrules') {
+	if (normalizedType === 'importmap') {
+		return 'data' as const
+	}
+	if (normalizedType === 'speculationrules') {
 		return 'ignore' as const
 	}
 	if (normalizedType === 'module') {
 		return 'module' as const
 	}
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/worker/client/mcp-apps/kody-ui-utils.ts` around lines 707 - 710, The
function getShellScriptExecutionMode incorrectly treats scripts with type
"importmap" as ignore, preventing import maps from being replayed; update the
conditional in getShellScriptExecutionMode so that when normalizedType ===
'importmap' it returns 'preserve' (keep 'speculationrules' as 'ignore' if
desired), ensuring importmap scripts are forwarded to the live document during
replay.

995-997: ⚠️ Potential issue | 🟠 Major

executeCode is still reading the initial bootstrap.

Line 996 calls readGeneratedUiBootstrap(), which reads window.__kodyGeneratedUiBootstrap instead of the bootstrap that updateGeneratedUiRuntimeBootstrap(...) is refreshing on rerenders. After the first activation, HTTP execute calls can still use a stale appSession.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/worker/client/mcp-apps/kody-ui-utils.ts` around lines 995 - 997,
executeCode currently calls readGeneratedUiBootstrap() which returns the initial
bootstrap and can be stale after rerenders; instead obtain appSession from the
runtime-updated bootstrap that updateGeneratedUiRuntimeBootstrap updates (i.e.
call the runtime bootstrap reader provided alongside
updateGeneratedUiRuntimeBootstrap — e.g. readGeneratedUiRuntimeBootstrap or
getRuntimeUiBootstrap) before calling executeCodeWithHttp so executeCode always
uses the latest appSession; add a safe fallback to readGeneratedUiBootstrap if
the runtime reader is unavailable.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@packages/worker/client/mcp-apps/kody-ui-utils.ts`:
- Around line 825-855: Inline module scripts (scriptDescriptor.executionMode ===
'module') are not being awaited because the code treats inline scripts the same
as non-module inline scripts and returns immediately after insertScript; update
the logic to compute a shouldAwaitLoad boolean (true for external scripts with
scriptDescriptor.src and for inline module scripts where executionMode ===
'module') and only return early for inline non-module scripts (e.g., when
!shouldAwaitLoad and !scriptDescriptor.src), otherwise create the loading
promise (using the existing loading event listeners) and await it after
insertScript; adjust handling around script.src, script.textContent,
wrapInlineClassicScriptForIsolation, insertScript, and executionMode so inline
modules and external scripts both wait for evaluation before proceeding.

---

Duplicate comments:
In `@packages/worker/client/mcp-apps/kody-ui-utils.ts`:
- Around line 1190-1196: The inline_code branch currently calls
renderCode(envelope.code, envelope.runtime ?? 'html') without error handling, so
failures can leave the UI in a partial state; wrap the renderCode call in a
try/catch (in the same block that checks envelope.mode === 'inline_code') and on
any exception call await renderError('The tool result did not include inline
code.') or a more specific message, then return—ensuring the same fallback
behavior as the saved-apps path and that renderError is always invoked if
renderCode fails.
- Around line 1029-1031: The global message handlers
(globalThis.window.addEventListener('message', ...) and the other listener
around lines 1222-1224) currently forward all MessageEvent objects straight into
hostBridge.handleHostMessage(event.data); restrict this by validating the event
origin and sender before calling hostBridge: check event.origin against a
configured allowlist (or expected origin string) and, when messages are expected
from a specific iframe/window, verify event.source === expectedWindowReference
(or another trusted check) and only then call
hostBridge.handleHostMessage(event.data); also ensure you drop or log unexpected
origins to avoid silently accepting messages from untrusted frames.
- Around line 707-710: The function getShellScriptExecutionMode incorrectly
treats scripts with type "importmap" as ignore, preventing import maps from
being replayed; update the conditional in getShellScriptExecutionMode so that
when normalizedType === 'importmap' it returns 'preserve' (keep
'speculationrules' as 'ignore' if desired), ensuring importmap scripts are
forwarded to the live document during replay.
- Around line 995-997: executeCode currently calls readGeneratedUiBootstrap()
which returns the initial bootstrap and can be stale after rerenders; instead
obtain appSession from the runtime-updated bootstrap that
updateGeneratedUiRuntimeBootstrap updates (i.e. call the runtime bootstrap
reader provided alongside updateGeneratedUiRuntimeBootstrap — e.g.
readGeneratedUiRuntimeBootstrap or getRuntimeUiBootstrap) before calling
executeCodeWithHttp so executeCode always uses the latest appSession; add a safe
fallback to readGeneratedUiBootstrap if the runtime reader is unavailable.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 3e38f56e-5206-4bbf-afa9-f2f059955482

📥 Commits

Reviewing files that changed from the base of the PR and between acb057f and 587c44f.

📒 Files selected for processing (1)
  • packages/worker/client/mcp-apps/kody-ui-utils.ts

Comment thread packages/worker/client/mcp-apps/kody-ui-utils.ts
Comment thread packages/worker/client/mcp-apps/kody-ui-utils.ts Outdated
Co-authored-by: Kent C. Dodds <me+github@kentcdodds.com>

@cursor cursor Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Cursor Bugbot has reviewed your changes and found 1 potential issue.

Fix All in Cursor

Reviewed by Cursor Bugbot for commit 2ffd963. Configure here.

element.setAttribute(attribute.name, attribute.value)
managedAttributes.add(attribute.name)
}
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Managed attributes can erase original shell HTML attributes

Medium Severity

setManagedElementAttributes can permanently remove the shell document's original <html> or <body> attributes (e.g. lang) after a render sequence. When a preserveDocumentChrome: true render (full HTML or error) sets an attribute like lang="en" that already existed on the shell's element, it gets added to the managedAttributes tracking set. A subsequent preserveDocumentChrome: false render (HTML fragment or JS app) then removes all managed attributes — including the shell's original lang. Unlike defaultTitle, which is saved and restored in the GeneratedUiShellRenderState, no default html/body attributes are preserved.

Additional Locations (1)
Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit 2ffd963. Configure here.

@kentcdodds
kentcdodds merged commit 6779066 into main Apr 10, 2026
7 of 9 checks passed
@kentcdodds
kentcdodds deleted the cursor/mcp-shell-dom-renderer-2540 branch April 10, 2026 04:36
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants