Skip to content

Commit 2836c7f

Browse files
committed
Merge remote-tracking branch 'origin/main' into feat/virtual-desktop-server
2 parents 3d629c3 + b6fc151 commit 2836c7f

File tree

55 files changed

+2219
-2075
lines changed

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

55 files changed

+2219
-2075
lines changed

.prettierignore

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,3 +2,4 @@ examples/basic-host/**/*.ts
22
examples/basic-host/**/*.tsx
33
examples/basic-server-*/**/*.ts
44
examples/basic-server-*/**/*.tsx
5+
**/vendor/**

AGENTS.md

Lines changed: 87 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,87 @@
1+
# MCP Apps SDK
2+
3+
## Project Overview
4+
5+
MCP Apps SDK (`@modelcontextprotocol/ext-apps`) enables MCP servers to display interactive UIs in conversational clients.
6+
7+
Key abstractions:
8+
9+
- **Guest** - UI running in an iframe, uses `App` class with `PostMessageTransport` to communicate with host
10+
- **Host** - Chat client embedding the iframe, uses `AppBridge` class to proxy MCP requests
11+
- **Server** - MCP server that registers tools/resources with UI metadata
12+
13+
Specification (draft): `specification/draft/apps.mdx`
14+
15+
## Commands
16+
17+
```bash
18+
# Install dependencies
19+
npm install
20+
21+
# Build the SDK only (generates schemas + bundles, does not build examples)
22+
npm run build
23+
24+
# Build everything (SDK + all examples)
25+
npm run build:all
26+
27+
# Type check + build a single example
28+
npm run --workspace examples/<example-name> build
29+
30+
# Run all examples (starts server at http://localhost:8080)
31+
npm start
32+
33+
# Run E2E tests (primary testing mechanism - starts examples server automatically)
34+
npm run test:e2e
35+
36+
# Run unit tests (E2E tests have broader coverage; unit tests cover specific modules)
37+
npm test
38+
39+
# Check JSDoc comment syntax and `{@link}` references
40+
npm exec typedoc -- --treatValidationWarningsAsErrors --emit none
41+
42+
# Regenerate package-lock.json (especially on setups w/ custom npm registry)
43+
rm -fR package-lock.json node_modules && \
44+
docker run --rm -it --platform linux/amd64 -v $PWD:/src:rw -w /src node:latest npm i && \
45+
rm -fR node_modules && \
46+
npm i --cache=~/.npm-mcp-apps --registry=https://registry.npmjs.org/
47+
```
48+
49+
## Architecture
50+
51+
### SDK Entry Points
52+
53+
- `@modelcontextprotocol/ext-apps` - Main SDK for Apps (`App` class, `PostMessageTransport`)
54+
- `@modelcontextprotocol/ext-apps/react` - React hooks (`useApp`, `useHostStyleVariables`, etc.)
55+
- `@modelcontextprotocol/ext-apps/app-bridge` - SDK for hosts (`AppBridge` class)
56+
- `@modelcontextprotocol/ext-apps/server` - Server helpers (`registerAppTool`, `registerAppResource`)
57+
58+
### Key Source Files
59+
60+
- `src/app.ts` - `App` class extends MCP Protocol, handles guest initialization, tool calls, messaging
61+
- `src/app-bridge.ts` - `AppBridge` class for hosts, proxies MCP requests, sends tool input/results to guests
62+
- `src/server/index.ts` - Helpers for MCP servers to register tools/resources with UI metadata
63+
- `src/types.ts` - Protocol types re-exported from `spec.types.ts` and Zod schemas from `generated/schema.ts` (auto-generated during build)
64+
- `src/message-transport.ts` - `PostMessageTransport` for iframe communication
65+
- `src/react/` - React hooks: `useApp`, `useHostStyles`, `useAutoResize`, `useDocumentTheme`
66+
67+
### Protocol Flow
68+
69+
```
70+
Guest UI (App) <--PostMessageTransport--> Host (AppBridge) <--MCP Client--> MCP Server
71+
```
72+
73+
1. Host creates iframe with Guest UI HTML
74+
2. Guest UI creates `App` instance and calls `connect()` with `PostMessageTransport`
75+
3. App sends `ui/initialize` request, receives host capabilities and context
76+
4. Host sends `sendToolInput()` with tool arguments after initialization
77+
5. Guest UI can call server tools via `app.callServerTool()` or send messages via `app.sendMessage()`
78+
6. Host sends `sendToolResult()` when tool execution completes
79+
7. Host calls `teardownResource()` before unmounting iframe
80+
81+
## Examples
82+
83+
Uses npm workspaces. Examples in `examples/` are separate packages:
84+
85+
- `basic-server-*` - Starter templates (vanillajs, react, vue, svelte, preact, solid). Use these as the basis for new examples.
86+
- `basic-host` - Reference host implementation
87+
- Other examples showcase specific features (charts, 3D, video, etc.)

CLAUDE.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
AGENTS.md

README.md

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,17 @@ This repo contains the SDK and [specification](https://github.com/modelcontextpr
66

77
MCP Apps are a proposed standard inspired by [MCP-UI](https://mcpui.dev/) and [OpenAI's Apps SDK](https://developers.openai.com/apps-sdk/) to allow MCP Servers to display interactive UI elements in conversational MCP clients / chatbots.
88

9+
## How It Works
10+
11+
MCP Apps extend the Model Context Protocol to let servers deliver **interactive UIs** to MCP hosts. Here's how it works:
12+
13+
1. **Tool call** — The LLM calls a tool on your server
14+
2. **UI Resource** — The tool's definition links to a predeclared `ui://` resource containing its HTML interface
15+
3. **Host renders** — The host fetches the resource and displays it in a sandboxed iframe
16+
4. **Bidirectional communication** — The host passes tool data to the UI via notifications, and the UI can call other tools through the host
17+
18+
This enables dashboards, forms, visualizations, and other rich experiences inside chat interfaces.
19+
920
## Overview
1021

1122
This SDK serves two audiences:

docs/quickstart.md

Lines changed: 34 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -15,8 +15,12 @@ A simple app that fetches the current server time and displays it in a clickable
1515
1616
## Prerequisites
1717

18-
- Node.js 18+
18+
- Familiarity with MCP concepts, especially [Tools](https://modelcontextprotocol.io/docs/learn/server-concepts#tools) and [Resources](https://modelcontextprotocol.io/docs/learn/server-concepts#resources)
1919
- Familiarity with the [MCP TypeScript SDK](https://github.com/modelcontextprotocol/typescript-sdk)
20+
- Node.js 18+
21+
22+
> [!TIP]
23+
> New to building MCP servers? Start with the [official MCP quickstart guide](https://modelcontextprotocol.io/docs/develop/build-server) to learn the core concepts first.
2024
2125
## 1. Project Setup
2226

@@ -30,7 +34,7 @@ npm init -y
3034
Install dependencies:
3135

3236
```bash
33-
npm install github:modelcontextprotocol/ext-apps @modelcontextprotocol/sdk zod
37+
npm install @modelcontextprotocol/ext-apps @modelcontextprotocol/sdk
3438
npm install -D typescript vite vite-plugin-singlefile express cors @types/express @types/cors tsx
3539
```
3640

@@ -98,64 +102,68 @@ Create `server.ts`:
98102
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
99103
import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/streamableHttp.js";
100104
import {
105+
registerAppTool,
106+
registerAppResource,
101107
RESOURCE_MIME_TYPE,
102-
type McpUiToolMeta,
103-
} from "@modelcontextprotocol/ext-apps";
108+
} from "@modelcontextprotocol/ext-apps/server";
104109
import cors from "cors";
105110
import express from "express";
106111
import fs from "node:fs/promises";
107112
import path from "node:path";
108-
import * as z from "zod";
109113

110114
const server = new McpServer({
111115
name: "My MCP App Server",
112116
version: "1.0.0",
113117
});
114118

115-
// Two-part registration: tool + resource
119+
// Two-part registration: tool + resource, tied together by the resource URI.
116120
const resourceUri = "ui://get-time/mcp-app.html";
117121

118-
server.registerTool(
122+
// Register a tool with UI metadata. When the host calls this tool, it reads
123+
// `_meta.ui.resourceUri` to know which resource to fetch and render as an
124+
// interactive UI.
125+
registerAppTool(
126+
server,
119127
"get-time",
120128
{
121129
title: "Get Time",
122130
description: "Returns the current server time.",
123131
inputSchema: {},
124-
outputSchema: { time: z.string() },
125-
_meta: { ui: { resourceUri } as McpUiToolMeta }, // Links tool to UI
132+
_meta: { ui: { resourceUri } },
126133
},
127134
async () => {
128135
const time = new Date().toISOString();
129136
return {
130137
content: [{ type: "text", text: time }],
131-
structuredContent: { time },
132138
};
133139
},
134140
);
135141

136-
server.registerResource(
142+
// Register the resource, which returns the bundled HTML/JavaScript for the UI.
143+
registerAppResource(
144+
server,
137145
resourceUri,
138146
resourceUri,
139-
{ mimeType: "text/html;profile=mcp-app" },
147+
{ mimeType: RESOURCE_MIME_TYPE },
140148
async () => {
141149
const html = await fs.readFile(
142150
path.join(import.meta.dirname, "dist", "mcp-app.html"),
143151
"utf-8",
144152
);
145153
return {
146154
contents: [
147-
{ uri: resourceUri, mimeType: "text/html;profile=mcp-app", text: html },
155+
{ uri: resourceUri, mimeType: RESOURCE_MIME_TYPE, text: html },
148156
],
149157
};
150158
},
151159
);
152160

153-
// Express server for MCP endpoint
154-
const app = express();
155-
app.use(cors());
156-
app.use(express.json());
161+
// Start an Express server that exposes the MCP endpoint.
162+
const expressApp = express();
163+
expressApp.use(cors());
164+
expressApp.use(express.json());
157165

158-
app.post("/mcp", async (req, res) => {
166+
expressApp.post("/mcp", async (req, res) => {
159167
const transport = new StreamableHTTPServerTransport({
160168
sessionIdGenerator: undefined,
161169
enableJsonResponse: true,
@@ -165,7 +173,7 @@ app.post("/mcp", async (req, res) => {
165173
await transport.handleRequest(req, res, req.body);
166174
});
167175

168-
app.listen(3001, (err) => {
176+
expressApp.listen(3001, (err) => {
169177
if (err) {
170178
console.error("Error starting server:", err);
171179
process.exit(1);
@@ -209,7 +217,7 @@ Create `mcp-app.html`:
209217
Create `src/mcp-app.ts`:
210218

211219
```typescript
212-
import { App, PostMessageTransport } from "@modelcontextprotocol/ext-apps";
220+
import { App } from "@modelcontextprotocol/ext-apps";
213221

214222
// Get element references
215223
const serverTimeEl = document.getElementById("server-time")!;
@@ -220,30 +228,30 @@ const app = new App({ name: "Get Time App", version: "1.0.0" });
220228

221229
// Register handlers BEFORE connecting
222230
app.ontoolresult = (result) => {
223-
const { time } = (result.structuredContent as { time?: string }) ?? {};
231+
const time = result.content?.find((c) => c.type === "text")?.text;
224232
serverTimeEl.textContent = time ?? "[ERROR]";
225233
};
226234

227235
// Wire up button click
228236
getTimeBtn.addEventListener("click", async () => {
229237
const result = await app.callServerTool({ name: "get-time", arguments: {} });
230-
const { time } = (result.structuredContent as { time?: string }) ?? {};
238+
const time = result.content?.find((c) => c.type === "text")?.text;
231239
serverTimeEl.textContent = time ?? "[ERROR]";
232240
});
233241

234242
// Connect to host
235-
app.connect(new PostMessageTransport(window.parent));
243+
app.connect();
236244
```
237245

246+
> [!NOTE]
247+
> **Full files:** [`mcp-app.html`](https://github.com/modelcontextprotocol/ext-apps/blob/main/examples/basic-server-vanillajs/mcp-app.html), [`src/mcp-app.ts`](https://github.com/modelcontextprotocol/ext-apps/blob/main/examples/basic-server-vanillajs/src/mcp-app.ts)
248+
238249
Build the UI:
239250

240251
```bash
241252
npm run build
242253
```
243254

244-
> [!NOTE]
245-
> **Full files:** [`mcp-app.html`](https://github.com/modelcontextprotocol/ext-apps/blob/main/examples/basic-server-vanillajs/mcp-app.html), [`src/mcp-app.ts`](https://github.com/modelcontextprotocol/ext-apps/blob/main/examples/basic-server-vanillajs/src/mcp-app.ts)
246-
247255
This produces `dist/mcp-app.html` which contains your bundled app:
248256

249257
```console

examples/basic-host/src/implementation.ts

Lines changed: 33 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { RESOURCE_MIME_TYPE, getToolUiResourceUri, type McpUiSandboxProxyReadyNotification, AppBridge, PostMessageTransport, type McpUiResourceCsp, type McpUiResourcePermissions } from "@modelcontextprotocol/ext-apps/app-bridge";
1+
import { RESOURCE_MIME_TYPE, getToolUiResourceUri, type McpUiSandboxProxyReadyNotification, AppBridge, PostMessageTransport, type McpUiResourceCsp, type McpUiResourcePermissions, buildAllowAttribute, type McpUiUpdateModelContextRequest, type McpUiMessageRequest } from "@modelcontextprotocol/ext-apps/app-bridge";
22
import { Client } from "@modelcontextprotocol/sdk/client/index.js";
33
import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/streamableHttp.js";
44
import type { CallToolResult, Tool } from "@modelcontextprotocol/sdk/types.js";
@@ -122,12 +122,19 @@ async function getUiResource(serverInfo: ServerInfo, uri: string): Promise<UiRes
122122
export function loadSandboxProxy(
123123
iframe: HTMLIFrameElement,
124124
csp?: McpUiResourceCsp,
125+
permissions?: McpUiResourcePermissions,
125126
): Promise<boolean> {
126127
// Prevent reload
127128
if (iframe.src) return Promise.resolve(false);
128129

129130
iframe.setAttribute("sandbox", "allow-scripts allow-same-origin allow-forms");
130131

132+
// Set Permission Policy allow attribute based on requested permissions
133+
const allowAttribute = buildAllowAttribute(permissions);
134+
if (allowAttribute) {
135+
iframe.setAttribute("allow", allowAttribute);
136+
}
137+
131138
const readyNotification: McpUiSandboxProxyReadyNotification["method"] =
132139
"ui/notifications/sandbox-proxy-ready";
133140

@@ -215,12 +222,26 @@ function hookInitializedCallback(appBridge: AppBridge): Promise<void> {
215222
}
216223

217224

218-
export function newAppBridge(serverInfo: ServerInfo, iframe: HTMLIFrameElement): AppBridge {
225+
export type ModelContext = McpUiUpdateModelContextRequest["params"];
226+
export type AppMessage = McpUiMessageRequest["params"];
227+
228+
export interface AppBridgeCallbacks {
229+
onContextUpdate?: (context: ModelContext | null) => void;
230+
onMessage?: (message: AppMessage) => void;
231+
}
232+
233+
export function newAppBridge(
234+
serverInfo: ServerInfo,
235+
iframe: HTMLIFrameElement,
236+
callbacks?: AppBridgeCallbacks,
237+
): AppBridge {
219238
const serverCapabilities = serverInfo.client.getServerCapabilities();
220239
const appBridge = new AppBridge(serverInfo.client, IMPLEMENTATION, {
221240
openLinks: {},
222241
serverTools: serverCapabilities?.tools,
223242
serverResources: serverCapabilities?.resources,
243+
// Declare support for model context updates
244+
updateModelContext: { text: {} },
224245
});
225246

226247
// Register all handlers before calling connect(). The Guest UI can start
@@ -229,6 +250,7 @@ export function newAppBridge(serverInfo: ServerInfo, iframe: HTMLIFrameElement):
229250

230251
appBridge.onmessage = async (params, _extra) => {
231252
log.info("Message from MCP App:", params);
253+
callbacks?.onMessage?.(params);
232254
return {};
233255
};
234256

@@ -242,6 +264,15 @@ export function newAppBridge(serverInfo: ServerInfo, iframe: HTMLIFrameElement):
242264
log.info("Log message from MCP App:", params);
243265
};
244266

267+
appBridge.onupdatemodelcontext = async (params) => {
268+
log.info("Model context update from MCP App:", params);
269+
// Normalize: empty content array means clear context
270+
const hasContent = params.content && params.content.length > 0;
271+
const hasStructured = params.structuredContent && Object.keys(params.structuredContent).length > 0;
272+
callbacks?.onContextUpdate?.(hasContent || hasStructured ? params : null);
273+
return {};
274+
};
275+
245276
appBridge.onsizechange = async ({ width, height }) => {
246277
// The MCP App has requested a `width` and `height`, but if
247278
// `box-sizing: border-box` is applied to the outer iframe element, then we

0 commit comments

Comments
 (0)