diff --git a/bun.lock b/bun.lock
index f19cacbe3d5..a4ce100e280 100644
--- a/bun.lock
+++ b/bun.lock
@@ -478,6 +478,7 @@
"version": "1.2.21",
"dependencies": {
"@kobalte/core": "catalog:",
+ "@modelcontextprotocol/ext-apps": "^1.1.2",
"@opencode-ai/sdk": "workspace:*",
"@opencode-ai/util": "workspace:*",
"@pierre/diffs": "catalog:",
@@ -1300,6 +1301,8 @@
"@mixmark-io/domino": ["@mixmark-io/domino@2.2.0", "", {}, "sha512-Y28PR25bHXUg88kCV7nivXrP2Nj2RueZ3/l/jdx6J9f8J4nsEGcgX0Qe6lt7Pa+J79+kPiJU3LguR6O/6zrLOw=="],
+ "@modelcontextprotocol/ext-apps": ["@modelcontextprotocol/ext-apps@1.1.2", "", { "optionalDependencies": { "@oven/bun-darwin-aarch64": "^1.2.21", "@oven/bun-darwin-x64": "^1.2.21", "@oven/bun-darwin-x64-baseline": "^1.2.21", "@oven/bun-linux-aarch64": "^1.2.21", "@oven/bun-linux-aarch64-musl": "^1.2.21", "@oven/bun-linux-x64": "^1.2.21", "@oven/bun-linux-x64-baseline": "^1.2.21", "@oven/bun-linux-x64-musl": "^1.2.21", "@oven/bun-linux-x64-musl-baseline": "^1.2.21", "@oven/bun-windows-x64": "^1.2.21", "@oven/bun-windows-x64-baseline": "^1.2.21", "@rollup/rollup-darwin-arm64": "^4.53.3", "@rollup/rollup-darwin-x64": "^4.53.3", "@rollup/rollup-linux-arm64-gnu": "^4.53.3", "@rollup/rollup-linux-x64-gnu": "^4.53.3", "@rollup/rollup-win32-arm64-msvc": "^4.53.3", "@rollup/rollup-win32-x64-msvc": "^4.53.3" }, "peerDependencies": { "@modelcontextprotocol/sdk": "^1.24.0", "react": "^17.0.0 || ^18.0.0 || ^19.0.0", "react-dom": "^17.0.0 || ^18.0.0 || ^19.0.0", "zod": "^3.25.0 || ^4.0.0" }, "optionalPeers": ["react", "react-dom"] }, "sha512-Gx4TEo3/F8yq1Ix6LdgLwMrKqfZqD7++eakZdbMUewrYtHeeJn3nKpeNhgEfO7nYRwonqWYomOAszWZWJS0IbA=="],
+
"@modelcontextprotocol/sdk": ["@modelcontextprotocol/sdk@1.25.2", "", { "dependencies": { "@hono/node-server": "^1.19.7", "ajv": "^8.17.1", "ajv-formats": "^3.0.1", "content-type": "^1.0.5", "cors": "^2.8.5", "cross-spawn": "^7.0.5", "eventsource": "^3.0.2", "eventsource-parser": "^3.0.0", "express": "^5.0.1", "express-rate-limit": "^7.5.0", "jose": "^6.1.1", "json-schema-typed": "^8.0.2", "pkce-challenge": "^5.0.0", "raw-body": "^3.0.0", "zod": "^3.25 || ^4.0", "zod-to-json-schema": "^3.25.0" }, "peerDependencies": { "@cfworker/json-schema": "^4.1.1" }, "optionalPeers": ["@cfworker/json-schema"] }, "sha512-LZFeo4F9M5qOhC/Uc1aQSrBHxMrvxett+9KLHt7OhcExtoiRN9DKgbZffMP/nxjutWDQpfMDfP3nkHI4X9ijww=="],
"@motionone/animation": ["@motionone/animation@10.18.0", "", { "dependencies": { "@motionone/easing": "^10.18.0", "@motionone/types": "^10.17.1", "@motionone/utils": "^10.18.0", "tslib": "^2.3.1" } }, "sha512-9z2p5GFGCm0gBsZbi8rVMOAJCtw1WqBTIPw3ozk06gDvZInBPIsQcHgYogEJ4yuHJ+akuW8g1SEIOpTOvYs8hw=="],
@@ -1438,6 +1441,28 @@
"@oslojs/jwt": ["@oslojs/jwt@0.2.0", "", { "dependencies": { "@oslojs/encoding": "0.4.1" } }, "sha512-bLE7BtHrURedCn4Mco3ma9L4Y1GR2SMBuIvjWr7rmQ4/W/4Jy70TIAgZ+0nIlk0xHz1vNP8x8DCns45Sb2XRbg=="],
+ "@oven/bun-darwin-aarch64": ["@oven/bun-darwin-aarch64@1.3.10", "", { "os": "darwin", "cpu": "arm64" }, "sha512-PXgg5gqcS/rHwa1hF0JdM1y5TiyejVrMHoBmWY/DjtfYZoFTXie1RCFOkoG0b5diOOmUcuYarMpH7CSNTqwj+w=="],
+
+ "@oven/bun-darwin-x64": ["@oven/bun-darwin-x64@1.3.10", "", { "os": "darwin", "cpu": "x64" }, "sha512-Nhssuh7GBpP5PiDSOl3+qnoIG7PJo+ec2oomDevnl9pRY6x6aD2gRt0JE+uf+A8Om2D6gjeHCxjEdrw5ZHE8mA=="],
+
+ "@oven/bun-darwin-x64-baseline": ["@oven/bun-darwin-x64-baseline@1.3.10", "", { "os": "darwin", "cpu": "x64" }, "sha512-w1gaTlqU0IJCmJ1X+PGHkdNU1n8Gemx5YKkjhkJIguvFINXEBB5U1KG82QsT65Tk4KyNMfbLTlmy4giAvUoKfA=="],
+
+ "@oven/bun-linux-aarch64": ["@oven/bun-linux-aarch64@1.3.10", "", { "os": "linux", "cpu": "arm64" }, "sha512-OUgPHfL6+PM2Q+tFZjcaycN3D7gdQdYlWnwMI31DXZKY1r4HINWk9aEz9t/rNaHg65edwNrt7dsv9TF7xK8xIA=="],
+
+ "@oven/bun-linux-aarch64-musl": ["@oven/bun-linux-aarch64-musl@1.3.10", "", { "os": "linux", "cpu": "arm64" }, "sha512-Ui5pAgM7JE9MzHokF0VglRMkbak3lTisY4Mf1AZutPACXWgKJC5aGrgnHBfkl7QS6fEeYb0juy1q4eRznRHOsw=="],
+
+ "@oven/bun-linux-x64": ["@oven/bun-linux-x64@1.3.10", "", { "os": "linux", "cpu": "x64" }, "sha512-bzUgYj/PIZziB/ZesIP9HUyfvh6Vlf3od+TrbTTyVEuCSMKzDPQVW/yEbRp0tcHO3alwiEXwJDrWrHAguXlgiQ=="],
+
+ "@oven/bun-linux-x64-baseline": ["@oven/bun-linux-x64-baseline@1.3.10", "", { "os": "linux", "cpu": "x64" }, "sha512-oqvMDYpX6dGJO03HgO5bXuccEsH3qbdO3MaAiAlO4CfkBPLUXz3N0DDElg5hz0L6ktdDVKbQVE5lfe+LAUISQg=="],
+
+ "@oven/bun-linux-x64-musl": ["@oven/bun-linux-x64-musl@1.3.10", "", { "os": "linux", "cpu": "x64" }, "sha512-poVXvOShekbexHq45b4MH/mRjQKwACAC8lHp3Tz/hEDuz0/20oncqScnmKwzhBPEpqJvydXficXfBYuSim8opw=="],
+
+ "@oven/bun-linux-x64-musl-baseline": ["@oven/bun-linux-x64-musl-baseline@1.3.10", "", { "os": "linux", "cpu": "x64" }, "sha512-/hOZ6S1VsTX6vtbhWVL9aAnOrdpuO54mAGUWpTdMz7dFG5UBZ/VUEiK0pBkq9A1rlBk0GeD/6Y4NBFl8Ha7cRA=="],
+
+ "@oven/bun-windows-x64": ["@oven/bun-windows-x64@1.3.10", "", { "os": "win32", "cpu": "x64" }, "sha512-qaS1In3yfC/Z/IGQriVmF8GWwKuNqiw7feTSJWaQhH5IbL6ENR+4wGNPniZSJFaM/SKUO0e/YCRdoVBvgU4C1g=="],
+
+ "@oven/bun-windows-x64-baseline": ["@oven/bun-windows-x64-baseline@1.3.10", "", { "os": "win32", "cpu": "x64" }, "sha512-gh3UAHbUdDUG6fhLc1Csa4IGdtghue6U8oAIXWnUqawp6lwb3gOCRvp25IUnLF5vUHtgfMxuEUYV7YA2WxVutw=="],
+
"@oxc-minify/binding-android-arm64": ["@oxc-minify/binding-android-arm64@0.96.0", "", { "os": "android", "cpu": "arm64" }, "sha512-lzeIEMu/v6Y+La5JSesq4hvyKtKBq84cgQpKYTYM/yGuNk2tfd5Ha31hnC+mTh48lp/5vZH+WBfjVUjjINCfug=="],
"@oxc-minify/binding-darwin-arm64": ["@oxc-minify/binding-darwin-arm64@0.96.0", "", { "os": "darwin", "cpu": "arm64" }, "sha512-i0LkJAUXb4BeBFrJQbMKQPoxf8+cFEffDyLSb7NEzzKuPcH8qrVsnEItoOzeAdYam8Sr6qCHVwmBNEQzl7PWpw=="],
diff --git a/packages/app/src/pages/directory-layout.tsx b/packages/app/src/pages/directory-layout.tsx
index fdf321f2dc3..1f68d153c31 100644
--- a/packages/app/src/pages/directory-layout.tsx
+++ b/packages/app/src/pages/directory-layout.tsx
@@ -1,7 +1,7 @@
import { batch, createEffect, createMemo, Show, type ParentProps } from "solid-js"
import { createStore } from "solid-js/store"
import { useLocation, useNavigate, useParams } from "@solidjs/router"
-import { SDKProvider } from "@/context/sdk"
+import { SDKProvider, useSDK } from "@/context/sdk"
import { SyncProvider, useSync } from "@/context/sync"
import { LocalProvider } from "@/context/local"
import { useGlobalSDK } from "@/context/global-sdk"
@@ -14,6 +14,7 @@ import { useLanguage } from "@/context/language"
function DirectoryDataProvider(props: ParentProps<{ directory: string }>) {
const navigate = useNavigate()
const sync = useSync()
+ const sdk = useSDK()
const slug = createMemo(() => base64Encode(props.directory))
return (
@@ -22,6 +23,15 @@ function DirectoryDataProvider(props: ParentProps<{ directory: string }>) {
directory={props.directory}
onNavigateToSession={(sessionID: string) => navigate(`/${slug()}/session/${sessionID}`)}
onSessionHref={(sessionID: string) => `/${slug()}/session/${sessionID}`}
+ onFetchAppResource={async (server, uri) => {
+ const res = await fetch(
+ `${sdk.url}/experimental/mcp-app/resource?uri=${encodeURIComponent(uri)}&server=${encodeURIComponent(server)}`,
+ ).catch(() => undefined)
+ if (!res?.ok) return undefined
+ const data = (await res.json()) as { html: string } | undefined
+ if (typeof data?.html === "string" && data.html.includes("export{")) return undefined
+ return data
+ }}
>
{props.children}
diff --git a/packages/opencode/src/flag/flag.ts b/packages/opencode/src/flag/flag.ts
index c743cd18d1c..c86da2b90c5 100644
--- a/packages/opencode/src/flag/flag.ts
+++ b/packages/opencode/src/flag/flag.ts
@@ -58,6 +58,7 @@ export namespace Flag {
export const OPENCODE_DISABLE_FILETIME_CHECK = truthy("OPENCODE_DISABLE_FILETIME_CHECK")
export const OPENCODE_EXPERIMENTAL_PLAN_MODE = OPENCODE_EXPERIMENTAL || truthy("OPENCODE_EXPERIMENTAL_PLAN_MODE")
export const OPENCODE_EXPERIMENTAL_MARKDOWN = !falsy("OPENCODE_EXPERIMENTAL_MARKDOWN")
+ export declare const OPENCODE_EXPERIMENTAL_MCP_APPS: boolean
export const OPENCODE_MODELS_URL = process.env["OPENCODE_MODELS_URL"]
export const OPENCODE_MODELS_PATH = process.env["OPENCODE_MODELS_PATH"]
export const OPENCODE_DISABLE_CHANNEL_DB = truthy("OPENCODE_DISABLE_CHANNEL_DB")
@@ -113,3 +114,11 @@ Object.defineProperty(Flag, "OPENCODE_CLIENT", {
enumerable: true,
configurable: false,
})
+
+Object.defineProperty(Flag, "OPENCODE_EXPERIMENTAL_MCP_APPS", {
+ get() {
+ return truthy("OPENCODE_EXPERIMENTAL") || truthy("OPENCODE_EXPERIMENTAL_MCP_APPS")
+ },
+ enumerable: true,
+ configurable: false,
+})
diff --git a/packages/opencode/src/mcp/index.ts b/packages/opencode/src/mcp/index.ts
index 0dca27d6512..1be10e39303 100644
--- a/packages/opencode/src/mcp/index.ts
+++ b/packages/opencode/src/mcp/index.ts
@@ -23,6 +23,7 @@ import { BusEvent } from "../bus/bus-event"
import { Bus } from "@/bus"
import { TuiEvent } from "@/cli/cmd/tui/event"
import open from "open"
+import { Flag } from "../flag/flag"
export namespace MCP {
const log = Log.create({ service: "mcp" })
@@ -147,7 +148,38 @@ export namespace MCP {
})
}
- // Store transports for OAuth servers to allow finishing auth
+ export const AppMeta = z
+ .object({
+ resourceUri: z.string(),
+ visibility: z.array(z.enum(["model", "app"])).optional(),
+ maxHeight: z.number().optional(),
+ })
+ .meta({ ref: "McpAppMeta" })
+ export type AppMeta = z.infer
+
+ export const AppResource = z
+ .object({
+ html: z.string(),
+ server: z.string(),
+ })
+ .meta({ ref: "McpAppResource" })
+ export type AppResource = z.infer
+
+ const toolMetaRegistry = new Map()
+ const appResourceCache = new Map()
+
+ function extractAppMeta(mcpTool: MCPToolDef): AppMeta | undefined {
+ const ui = (mcpTool._meta as Record | undefined)?.ui as Record | undefined
+ if (!ui) return undefined
+ const resourceUri =
+ (ui.resourceUri as string | undefined) ??
+ ((mcpTool._meta as Record)?.["ui/resourceUri"] as string | undefined)
+ if (!resourceUri || !resourceUri.startsWith("ui://")) return undefined
+ const visibility = ui.visibility as Array<"model" | "app"> | undefined
+ const maxHeight = ui.maxHeight as number | undefined
+ return { resourceUri, ...(visibility ? { visibility } : {}), ...(maxHeight ? { maxHeight } : {}) }
+ }
+
type TransportWithAuth = StreamableHTTPClientTransport | SSEClientTransport
const pendingOAuthTransports = new Map()
@@ -598,6 +630,10 @@ export namespace MCP {
delete s.clients[name]
}
s.status[name] = { status: "disabled" }
+ for (const key of appResourceCache.keys()) {
+ const entry = appResourceCache.get(key)
+ if (entry?.server === name) appResourceCache.delete(key)
+ }
}
export async function tools() {
@@ -636,7 +672,11 @@ export namespace MCP {
for (const mcpTool of toolsResult.tools) {
const sanitizedClientName = clientName.replace(/[^a-zA-Z0-9_-]/g, "_")
const sanitizedToolName = mcpTool.name.replace(/[^a-zA-Z0-9_-]/g, "_")
- result[sanitizedClientName + "_" + sanitizedToolName] = await convertMcpTool(mcpTool, client, timeout)
+ const key = sanitizedClientName + "_" + sanitizedToolName
+ const meta = extractAppMeta(mcpTool)
+ if (meta) toolMetaRegistry.set(key, { ...meta, server: sanitizedClientName })
+ if (meta?.visibility && !meta.visibility.includes("model")) continue
+ result[key] = await convertMcpTool(mcpTool, client, timeout)
}
}
return result
@@ -739,6 +779,31 @@ export namespace MCP {
return result
}
+ export function toolMeta(key: string) {
+ return toolMetaRegistry.get(key)
+ }
+
+ export async function apps() {
+ if (!Flag.OPENCODE_EXPERIMENTAL_MCP_APPS) return {}
+ await tools()
+ return Object.fromEntries(toolMetaRegistry)
+ }
+
+ export async function appResource(server: string, resourceUri: string, force = false) {
+ if (!Flag.OPENCODE_EXPERIMENTAL_MCP_APPS) return undefined
+ if (!force) {
+ const cached = appResourceCache.get(resourceUri)
+ if (cached && !cached.html.includes("export{")) return cached
+ }
+ const result = await readResource(server, resourceUri)
+ if (!result) return undefined
+ const content = result.contents.find((c) => c.mimeType === "text/html;profile=mcp-app" && "text" in c)
+ if (!content || !("text" in content)) return undefined
+ const entry: AppResource = { html: content.text as string, server }
+ appResourceCache.set(resourceUri, entry)
+ return entry
+ }
+
/**
* Start OAuth authentication flow for an MCP server.
* Returns the authorization URL that should be opened in a browser.
diff --git a/packages/opencode/src/server/routes/experimental.ts b/packages/opencode/src/server/routes/experimental.ts
index 98c7ece1052..6346fa8daef 100644
--- a/packages/opencode/src/server/routes/experimental.ts
+++ b/packages/opencode/src/server/routes/experimental.ts
@@ -266,5 +266,98 @@ export const ExperimentalRoutes = lazy(() =>
async (c) => {
return c.json(await MCP.resources())
},
+ )
+ .get(
+ "/mcp-app",
+ describeRoute({
+ summary: "List MCP App tools",
+ description: "List all MCP tools that declare a UI resource via _meta.ui.resourceUri.",
+ operationId: "experimental.mcp-app.list",
+ responses: {
+ 200: {
+ description: "MCP App tools",
+ content: {
+ "application/json": {
+ schema: resolver(
+ z.record(z.string(), MCP.AppMeta.extend({ server: z.string() }).meta({ ref: "McpAppToolEntry" })),
+ ),
+ },
+ },
+ },
+ },
+ }),
+ async (c) => {
+ return c.json(await MCP.apps())
+ },
+ )
+ .get(
+ "/mcp-app/resource",
+ describeRoute({
+ summary: "Get MCP App HTML resource",
+ description: "Fetch and cache the HTML bundle for a ui:// resource URI.",
+ operationId: "experimental.mcp-app.resource",
+ responses: {
+ 200: {
+ description: "HTML bundle",
+ content: {
+ "application/json": {
+ schema: resolver(MCP.AppResource),
+ },
+ },
+ },
+ ...errors(400),
+ },
+ }),
+ validator(
+ "query",
+ z.object({
+ uri: z.string(),
+ server: z.string(),
+ force: z.coerce.boolean().optional(),
+ }),
+ ),
+ async (c) => {
+ const query = c.req.valid("query")
+ const resource = await MCP.appResource(query.server, query.uri, query.force)
+ if (!resource) return c.json({ error: "resource not found" }, 400)
+ return c.json(resource)
+ },
+ )
+ .post(
+ "/mcp-app/tool-call",
+ describeRoute({
+ summary: "Proxy tool call from MCP App iframe",
+ description: "Forward a tools/call request from an MCP App iframe to the originating MCP server.",
+ operationId: "experimental.mcp-app.tool-call",
+ responses: {
+ 200: {
+ description: "Tool call result",
+ content: {
+ "application/json": {
+ schema: resolver(z.record(z.string(), z.any())),
+ },
+ },
+ },
+ ...errors(400),
+ },
+ }),
+ validator(
+ "json",
+ z.object({
+ server: z.string(),
+ name: z.string(),
+ arguments: z.record(z.string(), z.unknown()).optional(),
+ }),
+ ),
+ async (c) => {
+ const body = c.req.valid("json")
+ const snap = await MCP.clients()
+ const client = snap[body.server]
+ if (!client) return c.json({ error: "server not found" }, 400)
+ const result = await client
+ .callTool({ name: body.name, arguments: body.arguments ?? {} })
+ .catch((e: Error) => ({ error: e.message }))
+ return c.json(result)
+ },
),
)
diff --git a/packages/opencode/src/session/prompt.ts b/packages/opencode/src/session/prompt.ts
index 4f77920cc98..2ad654b9766 100644
--- a/packages/opencode/src/session/prompt.ts
+++ b/packages/opencode/src/session/prompt.ts
@@ -898,10 +898,19 @@ export namespace SessionPrompt {
}
const truncated = await Truncate.output(textParts.join("\n\n"), {}, input.agent)
+ const appMeta = MCP.toolMeta(key)
const metadata = {
...(result.metadata ?? {}),
truncated: truncated.truncated,
...(truncated.truncated && { outputPath: truncated.outputPath }),
+ ...(result.structuredContent ? { structuredContent: result.structuredContent } : {}),
+ ...(appMeta
+ ? {
+ resourceUri: appMeta.resourceUri,
+ server: appMeta.server,
+ ...(appMeta.maxHeight ? { maxHeight: appMeta.maxHeight } : {}),
+ }
+ : {}),
}
return {
diff --git a/packages/opencode/test/fixture/mcp-app-demo/README.md b/packages/opencode/test/fixture/mcp-app-demo/README.md
new file mode 100644
index 00000000000..95d10d1dba8
--- /dev/null
+++ b/packages/opencode/test/fixture/mcp-app-demo/README.md
@@ -0,0 +1,55 @@
+# MCP App Demo
+
+A minimal demo of the MCP Apps integration in opencode.
+
+## What it shows
+
+- A Python MCP server with a `demo_dashboard` tool that declares `_meta.ui.resourceUri`
+- An HTML app (using the standard `App` class from `@modelcontextprotocol/ext-apps`) that renders metrics, bar charts, and action buttons
+- The full round-trip: tool call → `structuredContent` → iframe → button click → `_demo_action` → agent
+
+## Setup
+
+**1. Enable the feature flag** — add to your shell or `.env`:
+
+```sh
+export OPENCODE_EXPERIMENTAL_MCP_APPS=true
+```
+
+**2. Register the MCP server** — add to `~/.config/opencode/config.json` (global) or `./opencode.json` (project):
+
+```json
+{
+ "mcp": {
+ "demo": {
+ "type": "local",
+ "command": ["python3", "/path/to/opencode/packages/opencode/test/fixture/mcp-app-demo/server.py"]
+ }
+ }
+}
+```
+
+**3. Start the dev backend + app:**
+
+```sh
+# terminal 1 — backend
+cd packages/opencode
+bun run --conditions=browser ./src/index.ts serve --port 4096
+
+# terminal 2 — frontend
+cd packages/app
+bun dev -- --port 4444
+```
+
+**4. Open `http://localhost:4444`** and ask the agent to call the tool:
+
+> "Use demo_dashboard to show me a dashboard"
+
+You should see the iframe render inside the tool result with live metrics, bar charts, and buttons.
+
+## Files
+
+| File | Purpose |
+| ----------- | --------------------------------------------------------------------------------- |
+| `server.py` | MCP server — registers tools with `_meta.ui`, returns `structuredContent` |
+| `app.html` | Self-contained HTML app — receives tool result via `App` class postMessage bridge |
diff --git a/packages/opencode/test/fixture/mcp-app-demo/app.html b/packages/opencode/test/fixture/mcp-app-demo/app.html
new file mode 100644
index 00000000000..7ac8d3f57a7
--- /dev/null
+++ b/packages/opencode/test/fixture/mcp-app-demo/app.html
@@ -0,0 +1,631 @@
+
+
+
+
+
+ MCP App Demo
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Connecting…
+
+
+
+
diff --git a/packages/opencode/test/fixture/mcp-app-demo/server.py b/packages/opencode/test/fixture/mcp-app-demo/server.py
new file mode 100644
index 00000000000..655bc03019c
--- /dev/null
+++ b/packages/opencode/test/fixture/mcp-app-demo/server.py
@@ -0,0 +1,283 @@
+#!/usr/bin/env python3
+"""
+MCP App Demo Server
+
+A minimal MCP server that demonstrates the MCP Apps integration in opencode.
+It registers a `demo_dashboard` tool with `_meta.ui.resourceUri` pointing to
+the bundled app.html, and returns `structuredContent` with dashboard data.
+
+Run via opencode config:
+ mcp:
+ demo:
+ type: local
+ command: [python3, packages/opencode/test/fixture/mcp-app-demo/server.py]
+
+Or add OPENCODE_EXPERIMENTAL_MCP_APPS=true to your environment.
+"""
+
+import asyncio
+import json
+import re
+import sys
+from pathlib import Path
+
+from mcp import types # type: ignore[import]
+from mcp.server import Server
+from mcp.server.stdio import stdio_server
+
+RESOURCE_URI = "ui://mcp-app-demo/dashboard-v3"
+# Aliases accepted for backward compat with cached tool parts from older sessions
+RESOURCE_URI_ALIASES = {
+ "ui://mcp-app-demo/dashboard",
+ "ui://mcp-app-demo/dashboard-v2",
+ "ui://mcp-app-demo/dashboard-v3",
+}
+HTML_PATH = Path(__file__).parent / "app.html"
+# server.py -> mcp-app-demo -> fixture -> test -> opencode -> packages -> repo root
+BUNDLE_PATH = (
+ Path(__file__).parent.parent.parent.parent.parent.parent
+ / "packages/ui/node_modules/@modelcontextprotocol/ext-apps/dist/src/app-with-deps.js"
+)
+
+server = Server("mcp-app-demo")
+
+
+@server.list_resources()
+async def list_resources() -> list[types.Resource]:
+ return [
+ types.Resource(
+ uri=RESOURCE_URI,
+ name="Demo Dashboard",
+ mimeType="text/html;profile=mcp-app",
+ )
+ ]
+
+
+@server.read_resource()
+async def read_resource(uri) -> types.TextResourceContents: # type: ignore[override]
+ if str(uri) not in RESOURCE_URI_ALIASES:
+ raise ValueError(f"Unknown resource: {uri}")
+ html = HTML_PATH.read_text()
+ bundle = BUNDLE_PATH.read_text() if BUNDLE_PATH.exists() else ""
+ if bundle:
+ # Strip the trailing ESM export{...} block so the bundle can run as a
+ # classic (non-module) script inside a sandboxed iframe.
+ clean = re.sub(r"export\{[^}]+\};?\s*$", "", bundle)
+ # Wrap in an IIFE and expose the two classes as globals.
+ # _c = App class, O$ = PostMessageTransport class (minified names in bundle).
+ wrapped = f"(function(){{\n{clean}\nwindow.App=_c;\nwindow.PostMessageTransport=O$;\n}})();"
+ inline = f""
+ # Inject bundle before the first