From af15a3dd50deac6cd26c1f0b908cf031f0623e8d Mon Sep 17 00:00:00 2001 From: Tristan Stahnke Date: Tue, 3 Mar 2026 19:36:54 -0500 Subject: [PATCH 1/3] feat: add experimental MCP Apps support for rich iframe UIs Add MCP Apps protocol integration behind OPENCODE_EXPERIMENTAL_MCP_APPS flag, allowing MCP servers to render interactive UIs in sandboxed iframes. - Feature flag, AppMeta schema, tool metadata registry, and resource cache - Visibility filtering (_meta.ui.visibility: ["app"]) hides tools from LLM - structuredContent + resourceUri + server threaded into tool metadata - 3 experimental API routes (apps list, resource fetch, tool-call proxy) - McpAppTool renderer with AppBridge handshake and iframe auto-sizing - Configurable maxHeight (default 640px) via _meta.ui.maxHeight - FetchAppResourceFn wired through DataProvider - SDK regenerated - 15 tests across 3 test files - Demo fixture (server.py, app.html, README.md) --- bun.lock | 25 + packages/app/src/pages/directory-layout.tsx | 11 + packages/opencode/src/flag/flag.ts | 9 + packages/opencode/src/mcp/index.ts | 69 +- .../src/server/routes/experimental.ts | 93 +++ packages/opencode/src/session/prompt.ts | 9 + .../test/fixture/mcp-app-demo/README.md | 55 ++ .../test/fixture/mcp-app-demo/app.html | 631 ++++++++++++++++++ .../test/fixture/mcp-app-demo/server.py | 283 ++++++++ .../opencode/test/mcp/app-tool-result.test.ts | 105 +++ packages/opencode/test/mcp/apps.test.ts | 200 ++++++ .../opencode/test/server/mcp-apps.test.ts | 93 +++ packages/sdk/js/src/v2/gen/sdk.gen.ts | 129 ++++ packages/sdk/js/src/v2/gen/types.gen.ts | 102 +++ packages/ui/package.json | 1 + packages/ui/src/components/message-part.tsx | 105 ++- packages/ui/src/context/data.tsx | 4 + 17 files changed, 1921 insertions(+), 3 deletions(-) create mode 100644 packages/opencode/test/fixture/mcp-app-demo/README.md create mode 100644 packages/opencode/test/fixture/mcp-app-demo/app.html create mode 100644 packages/opencode/test/fixture/mcp-app-demo/server.py create mode 100644 packages/opencode/test/mcp/app-tool-result.test.ts create mode 100644 packages/opencode/test/mcp/apps.test.ts create mode 100644 packages/opencode/test/server/mcp-apps.test.ts diff --git a/bun.lock b/bun.lock index badb0410ab8..77f8ea70299 100644 --- a/bun.lock +++ b/bun.lock @@ -475,6 +475,7 @@ "version": "1.2.16", "dependencies": { "@kobalte/core": "catalog:", + "@modelcontextprotocol/ext-apps": "^1.1.2", "@opencode-ai/sdk": "workspace:*", "@opencode-ai/util": "workspace:*", "@pierre/diffs": "catalog:", @@ -1296,6 +1297,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=="], @@ -1434,6 +1437,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 71b52180f2e..f3eb50c457d 100644 --- a/packages/app/src/pages/directory-layout.tsx +++ b/packages/app/src/pages/directory-layout.tsx @@ -4,6 +4,7 @@ import { useNavigate, useParams } from "@solidjs/router" import { SDKProvider } from "@/context/sdk" import { SyncProvider, useSync } from "@/context/sync" import { LocalProvider } from "@/context/local" +import { useSDK } from "@/context/sdk" import { DataProvider } from "@opencode-ai/ui/context" import { decode64 } from "@/utils/base64" @@ -14,6 +15,7 @@ function DirectoryDataProvider(props: ParentProps<{ directory: string }>) { const params = useParams() const navigate = useNavigate() const sync = useSync() + const sdk = useSDK() return ( ) { directory={props.directory} onNavigateToSession={(sessionID: string) => navigate(`/${params.dir}/session/${sessionID}`)} onSessionHref={(sessionID: string) => `/${params.dir}/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 22eba6320e9..b2c7a9a5271 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"] @@ -112,3 +113,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 + + + +
+
+ MCP App + Dashboard +
+ +
+ +
+ +
+
+ + +
+ +
+
+
+ + Recent Events +
+
+
+
+ +
+
+
Raw Payload
+
+
+
+ +
+
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