Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
25 changes: 25 additions & 0 deletions bun.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

12 changes: 11 additions & 1 deletion packages/app/src/pages/directory-layout.tsx
Original file line number Diff line number Diff line change
@@ -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"
Expand All @@ -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 (
Expand All @@ -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
}}
>
<LocalProvider>{props.children}</LocalProvider>
</DataProvider>
Expand Down
9 changes: 9 additions & 0 deletions packages/opencode/src/flag/flag.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand Down Expand Up @@ -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,
})
69 changes: 67 additions & 2 deletions packages/opencode/src/mcp/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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" })
Expand Down Expand Up @@ -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<typeof AppMeta>

export const AppResource = z
.object({
html: z.string(),
server: z.string(),
})
.meta({ ref: "McpAppResource" })
export type AppResource = z.infer<typeof AppResource>

const toolMetaRegistry = new Map<string, AppMeta & { server: string }>()
const appResourceCache = new Map<string, AppResource>()

function extractAppMeta(mcpTool: MCPToolDef): AppMeta | undefined {
const ui = (mcpTool._meta as Record<string, unknown> | undefined)?.ui as Record<string, unknown> | undefined
if (!ui) return undefined
const resourceUri =
(ui.resourceUri as string | undefined) ??
((mcpTool._meta as Record<string, unknown>)?.["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<string, TransportWithAuth>()

Expand Down Expand Up @@ -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() {
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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.
Expand Down
93 changes: 93 additions & 0 deletions packages/opencode/src/server/routes/experimental.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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)
},
),
)
9 changes: 9 additions & 0 deletions packages/opencode/src/session/prompt.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
55 changes: 55 additions & 0 deletions packages/opencode/test/fixture/mcp-app-demo/README.md
Original file line number Diff line number Diff line change
@@ -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 |
Loading
Loading