pmcp 2.4.0

High-quality Rust SDK for Model Context Protocol (MCP) with full TypeScript SDK compatibility
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
# Building MCP Apps with PMCP SDK

Guide for MCP server developers building interactive widget UIs.

## Architecture Overview

Building an MCP App involves two parts:

1. **Server side (Rust, PMCP SDK)** — registers tools with UI metadata, serves widget HTML as resources, returns `structuredContent` from tool calls
2. **Widget side (JS/TS, ext-apps SDK)** — the interactive UI that runs in the host's iframe, communicates with the host via the `App` class

```
┌─────────────────────────────────────────────────────┐
│  Host (Claude Desktop, ChatGPT, VS Code, etc.)      │
│                                                      │
│  tools/list ─── _meta.ui.resourceUri ──► knows which │
│                                          tool has UI │
│  tools/call ─── structuredContent ─────► data for UI │
│  resources/read ── HTML ───────────────► widget code │
│                                                      │
│  ┌────────────────────────────────────────────────┐  │
│  │  Widget iframe                                  │  │
│  │  @modelcontextprotocol/ext-apps (App class)     │  │
│  │  ← hostContext (theme, toolInput, toolOutput) → │  │
│  │  ← app.callServerTool() → tools/call            │  │
│  └────────────────────────────────────────────────┘  │
└─────────────────────────────────────────────────────┘
```

## Server Side (Rust)

### 1. Enable ChatGPT host layer

Register the ChatGPT host layer so the server enriches `_meta` with `openai/*` descriptor keys. This is required for ChatGPT and harmless for other hosts:

```rust
use pmcp::types::mcp_apps::HostType;

Server::builder()
    .name("my-server")
    .version("1.0.0")
    .with_host_layer(HostType::ChatGpt)  // adds openai/* keys to _meta
    // ... tools, resources, etc.
    .build()
```

Without this, `tools/list` and `resources/read` will be missing `openai/outputTemplate`, `openai/widgetAccessible`, and `openai/toolInvocation/*` keys that ChatGPT requires.

### 2. Register tools with UI metadata

Associate a tool with its widget using `ToolInfo::with_ui()`:

```rust
use pmcp::types::protocol::ToolInfo;
use serde_json::json;

let tool = ToolInfo::with_ui(
    "search_images",
    Some("Search for images by class name".to_string()),
    json!({
        "type": "object",
        "properties": {
            "class_name": { "type": "string" }
        },
        "required": ["class_name"]
    }),
    "ui://my-app/explorer.html",  // points to the widget resource
);
```

This produces `_meta: { "ui": { "resourceUri": "ui://my-app/explorer.html" } }` in the `tools/list` response, which tells hosts like Claude Desktop and ChatGPT that this tool has a widget.

For ChatGPT-specific metadata (e.g., border preference, invoking messages), use `WidgetMeta`:

```rust
use pmcp::types::mcp_apps::WidgetMeta;

let tool = ToolInfo::with_ui("my_tool", None, schema, "ui://my-app/widget.html")
    .with_widget_meta(WidgetMeta::new().prefers_border(true));
```

### 3. Return structuredContent from tool calls

Return data alongside text content so the widget can render it:

```rust
use pmcp::types::protocol::{CallToolResult, Content};
use serde_json::json;

let result = CallToolResult::new(vec![
    Content::text("Found 42 images of dogs"),
])
.with_structured_content(json!({
    "columns": [
        { "name": "image_id", "data_type": "varchar" },
        { "name": "thumbnail_url", "data_type": "varchar" }
    ],
    "rows": [
        { "image_id": "abc123", "thumbnail_url": "https://..." }
    ]
}));
```

- `content` — text for the AI model to understand
- `structuredContent` — data for the widget to render (also visible to the model)

### 4. Register the widget HTML as a resource

Use `UIResource` and `UIResourceContents` to register with the correct MIME type:

```rust
use pmcp::types::ui::{UIResource, UIResourceContents};

// Create the resource declaration (for resources/list)
let resource = UIResource::html_mcp_app(
    "ui://my-app/explorer.html",
    "Image Explorer",
);

// Create the resource content (for resources/read)
let contents = UIResourceContents::html(
    "ui://my-app/explorer.html",
    &html_content,  // your widget HTML string
);
// contents.mime_type = "text/html;profile=mcp-app"

// Register with ResourceCollection
resources.add_ui_resource(resource, contents);
```

Both `UIResource::html_mcp_app()` and `UIResourceContents::html()` produce `mimeType: "text/html;profile=mcp-app"` — the standard MIME type recognized by Claude Desktop, ChatGPT, and other MCP hosts.

> **Important:** Do not use the legacy `UIResource::html_mcp()` constructor — it produces `text/html+mcp` which is not recognized by Claude Desktop.

### 5. Declare CSP for external domains

If your widget loads external resources (images, API calls, fonts), you **must** declare them in `_meta.ui.csp` on the resource contents. Without this, hosts like Claude.ai block all external domains via Content-Security-Policy.

```rust
use pmcp::types::mcp_apps::{WidgetCSP, WidgetMeta};

let csp = WidgetCSP::new()
    .resources("https://*.staticflickr.com")  // img-src: images, scripts, fonts
    .connect("https://*.staticflickr.com");   // connect-src: fetch/XHR

let meta = WidgetMeta::new()
    .resource_uri("ui://my-app/explorer.html")
    .prefers_border(true)
    .csp(csp);
```

This produces `_meta.ui.csp` with `connectDomains` and `resourceDomains` arrays on the `resources/read` response, which the host merges into its iframe CSP.

> **Important:** CSP metadata goes on the **resource contents** (returned by `resources/read`), not just the resource listing. See the [ext-apps CSP spec]https://apps.extensions.modelcontextprotocol.io/api/documents/csp-and-cors.html for details.

### 6. Add outputSchema (optional but recommended)

`outputSchema` tells the host the shape of `structuredContent`, enabling validation:

```rust
let tool = ToolInfo::with_ui("search_images", None, input_schema, "ui://my-app/explorer.html")
    .with_output_schema(json!({
        "type": "object",
        "properties": {
            "columns": { "type": "array" },
            "rows": { "type": "array" }
        }
    }));
```

## Widget Side (JavaScript/TypeScript)

Widgets use the `@modelcontextprotocol/ext-apps` SDK and **must be bundled into self-contained HTML** using Vite + vite-plugin-singlefile. This is required because Claude Desktop's iframe CSP blocks external script loading — CDN imports will fail silently.

### Widget lifecycle

1. **Create `App`** — no capabilities needed (do not pass `tools`)
2. **Register ALL protocol handlers before `connect()`** — missing handlers cause connection teardown
3. **Call `app.connect()`** — performs the `ui/initialize` handshake with the host
4. **Read `app.getHostContext()`** — some hosts deliver data only at init time
5. **Use `app.callServerTool()`** — for interactive widgets that call back to the server

### Required protocol handlers

> **Critical:** You MUST register `onteardown`, `ontoolinput`, `ontoolcancelled`, and `onerror` handlers before calling `connect()`. Without these, hosts like Claude Desktop and Claude.ai will **tear down the entire MCP connection** after the first tool result — the widget briefly appears then everything dies.

```js
// ALL of these are required — not just ontoolresult
app.onteardown = async () => { return {}; };
app.ontoolinput = (params) => { console.debug("Tool input:", params); };
app.ontoolcancelled = (params) => { console.debug("Tool cancelled:", params.reason); };
app.onerror = (err) => { console.error("App error:", err); };
app.ontoolresult = (result) => { /* your data handler */ };
```

### Capabilities declaration

Do **not** pass `tools` capability to `new App()`. ChatGPT's adapter rejects it with a Zod validation error (`-32603: expected "object"`). Widgets still receive tool results via `hostContext.toolOutput` and `ontoolresult` notifications without this capability. The `callServerTool()` API also works without it on hosts that support it (mcp-preview, Claude Desktop) — add a `.catch()` fallback for hosts that don't.

```js
// Standard widget — receives tool results, can call server tools
const app = new App({ name: "my-widget", version: "1.0.0" });
```

> **Warning:** Do NOT pass `{ tools: true }` or `{ tools: {} }` as capabilities. ChatGPT's adapter validates `appCapabilities` with a strict Zod schema that rejects the `tools` key entirely, causing the widget to fail to initialize.

### Minimal widget example

Create your source HTML with a bare import (Vite resolves it from `node_modules`):

```html
<!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="UTF-8">
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
  <title>My Widget</title>
</head>
<body>
  <div id="output">Waiting for data...</div>

  <script type="module">
    import { App } from "@modelcontextprotocol/ext-apps";

    const app = new App({ name: "my-widget", version: "1.0.0" });

    // 1. Register ALL handlers BEFORE connecting (required by protocol)
    app.onteardown = async () => { return {}; };
    app.ontoolinput = (params) => { console.debug("Tool input:", params); };
    app.ontoolcancelled = (params) => { console.debug("Cancelled:", params.reason); };
    app.onerror = (err) => { console.error("App error:", err); };

    app.ontoolresult = (result) => {
      if (result.structuredContent) {
        renderData(result.structuredContent);
      }
    };

    // 2. Connect to host
    await app.connect();

    // 3. Read initial data from hostContext
    const ctx = app.getHostContext();
    if (ctx?.toolOutput) {
      renderData(ctx.toolOutput);
    }

    // 4. Optionally call server tools from the widget
    async function refresh() {
      const result = await app.callServerTool({
        name: "search_images",
        arguments: { class_name: "Dog" },
      });
      if (result.structuredContent) {
        renderData(result.structuredContent);
      }
    }

    function renderData(data) {
      document.getElementById("output").textContent = JSON.stringify(data);
    }
  </script>
</body>
</html>
```

### React widget

```tsx
import { useApp, useHostStyles } from "@modelcontextprotocol/ext-apps/react";

export default function MyWidget() {
  const { app, isConnected } = useApp({
    appInfo: { name: "my-widget", version: "1.0.0" },
    // Do not pass capabilities.tools — ChatGPT rejects it
    onAppCreated: (app) => {
      app.ontoolresult = (result) => {
        // handle new tool results
      };
    },
  });

  // Apply host theme, CSS variables, and fonts
  useHostStyles(app, app?.getHostContext());

  if (!isConnected) return <div>Connecting...</div>;

  const handleSearch = async () => {
    const result = await app.callServerTool({
      name: "search_images",
      arguments: { class_name: "Dog" },
    });
    // use result.structuredContent
  };

  return <button onClick={handleSearch}>Search</button>;
}
```

### Loading external images

Hosts enforce strict CSP on widget iframes. To load external images reliably across all hosts:

1. **Server side:** Declare the image CDN in `WidgetCSP` (see section 5 above)
2. **Widget side:** Use fetch→blob as defense-in-depth for hosts that don't support `_meta.ui.csp`:

```js
function loadImage(img, url) {
    fetch(url, { mode: 'cors' }).then(function(r) {
        if (!r.ok) throw new Error(r.status);
        return r.blob();
    }).then(function(blob) {
        img.src = URL.createObjectURL(blob);
        // Revoke blob URL after render to prevent memory leaks
        img.onload = function() { URL.revokeObjectURL(img.src); img.onload = null; };
    }).catch(function() {
        // Fallback to direct URL — works on permissive hosts (ChatGPT, mcp-preview).
        // On strict hosts, onerror fires and shows a placeholder.
        img.src = url;
    });
}
```

> **Why both?** The `_meta.ui.csp` declaration tells the host to relax its CSP for the declared domains. The fetch→blob approach works even if the host ignores `_meta.ui.csp``blob:` URLs are typically allowed in `img-src`. Together they maximize cross-host compatibility.

> **Memory:** Always call `URL.revokeObjectURL()` after the image loads. Without this, each fetched image leaks a blob URL that persists for the page lifetime.

## Bundling widgets with Vite

Widgets must be self-contained HTML files with all JavaScript inlined. Use **Vite + vite-plugin-singlefile** to bundle the ext-apps SDK into each widget.

### Setup

```bash
cd widget/  # or widgets/
npm init -y
npm install @modelcontextprotocol/ext-apps
npm install -D vite vite-plugin-singlefile
```

### vite.config.ts

`vite-plugin-singlefile` uses `inlineDynamicImports` which only supports a single input per build. Use the `WIDGET` env var to select which widget to build:

```ts
import { defineConfig } from "vite";
import { viteSingleFile } from "vite-plugin-singlefile";

const widget = process.env.WIDGET || "mcp-app";

export default defineConfig({
  plugins: [viteSingleFile()],
  build: {
    target: "esnext",  // required — ext-apps SDK uses top-level await
    rollupOptions: {
      input: `${widget}.html`,
    },
    outDir: "dist",
    emptyOutDir: false,  // preserve other widgets' output
  },
});
```

### package.json scripts

For a single widget:
```json
{
  "scripts": {
    "build": "vite build"
  }
}
```

For multiple widgets:
```json
{
  "scripts": {
    "build": "rm -rf dist && WIDGET=image-explorer vite build && WIDGET=relationship-viewer vite build && WIDGET=tree-browser vite build"
  }
}
```

### Build and embed

```bash
npm run build
# → dist/image-explorer.html (~130KB, self-contained)
# → dist/relationship-viewer.html
# → dist/tree-browser.html
```

Embed the built output in your Rust binary:

```rust
const WIDGET_HTML: &str = include_str!("../../widget/dist/image-explorer.html");
```

### Build order

Widget HTML must exist before `cargo build` (since `include_str!` runs at compile time):

```bash
cd widget && npm ci && npm run build && cd ..
cargo lambda build --release --arm64 -p my-server-lambda
```

## Debugging with mcp-preview

### Protocol tab

Run `cargo pmcp preview` and check the Protocol tab. All checks should show PASS:

| Check | Expected | Source |
|-------|----------|--------|
| `tools/list` has `_meta.ui` | `{ "ui": { "resourceUri": "ui://..." } }` | `ToolInfo::with_ui()` |
| `tools/list` has openai keys | `openai/outputTemplate`, `openai/widgetAccessible`, etc. | `.with_host_layer(HostType::ChatGpt)` |
| `tools/call` returns `structuredContent` | JSON data object | `TypedToolWithOutput` or `CallToolResult::with_structured_content()` |
| `tools/call` has `_meta` | `openai/toolInvocation/*` keys | `with_widget_enrichment()` |
| `resources/read` mimeType | `"text/html;profile=mcp-app"` | `UIResourceContents::html()` |
| `resources/read` has `_meta` | `ui/resourceUri` + openai keys | `ResourceCollection` + `uri_to_tool_meta` |
| `resources/read` has CSP (if external resources) | `_meta.ui.csp.resourceDomains` / `connectDomains` | `WidgetMeta::csp()` |

### Common failures

**Widget shows briefly then connection drops (Claude Desktop/Claude.ai):**
- The widget is missing protocol handlers (`onteardown`, `ontoolinput`, `ontoolcancelled`). ALL handlers must be registered before `connect()`, even if they only log a debug message.
- After receiving a tool result, if the widget doesn't respond to `ui/teardown` properly, the host kills the entire MCP connection.
- This is the #1 issue when porting widgets from mcp-preview (which is more forgiving) to real hosts.

**Widget loads but never shows tool results:**
- Do NOT pass `tools` capability to `new App()`. ChatGPT rejects it. Tool results are delivered via `hostContext.toolOutput` and `ontoolresult` without it.
- Check `ontoolresult` is registered BEFORE `connect()`, not after.

**"Received a response for an unknown message ID" (mcp-preview) / dual-App conflict:**
- Two App instances on the same postMessage channel. This happens when:
  - `McpAppsAdapter::transform()` is applied to a widget that already bundles the ext-apps SDK via Vite — do NOT use `McpAppsAdapter` on pre-bundled widgets
  - mcp-preview's wrapper injects its own App, AND the widget bundles its own App (mcp-preview auto-detects this and skips, but `McpAppsAdapter` does not)
  - If your Vite-bundled widget still triggers this, check that the build output is being used (not the source HTML)
- **Fix:** For Vite-bundled widgets, use `UIResource::html_mcp_app()` + `UIResourceContents::html()` directly, or set `mime_type: "text/html;profile=mcp-app"` and serve the HTML as-is.

**resources/read missing openai/* keys (ChatGPT mode):**
- Server needs `.with_host_layer(HostType::ChatGpt)` in the builder
- Without this, the `uri_to_tool_meta` propagation index has no openai keys to propagate

**Images/external resources blocked (Claude.ai):**
- Claude.ai enforces strict CSP on widget iframes: `img-src 'self' data: blob:` — external image URLs are blocked
- **Fix:** Declare external domains in `WidgetCSP` on the resource metadata (see section 5). Also use `fetch→blob→createObjectURL` in the widget as a fallback (see "Loading external images")
- Always use HTTPS — `http://` URLs are blocked by mixed-content policy even with CSP declarations

**Widget not rendered at all (Claude Desktop):**
- Check `resources/read` returns `_meta.ui.resourceUri` — required by Claude Desktop
- Check MIME type is `text/html;profile=mcp-app` (not `text/html+mcp`)
- Check widget is self-contained (no external script imports — CSP blocks them)

## Reference implementations

- **Open Images** (`built-in/sql-api/servers/open-images/`) — multi-widget server with image grid, relationship viewer, tree browser
- **Calculator** (`examples/mcp-apps-calculator/`) — single-widget TypeScript example
- **ext-apps examples:** https://github.com/modelcontextprotocol/ext-apps/tree/main/examples

## SDK links

- **ext-apps SDK (widget):** `npm install @modelcontextprotocol/ext-apps` ([GitHub]https://github.com/modelcontextprotocol/ext-apps)
- **PMCP SDK (server):** `cargo add pmcp --features mcp-apps` ([crates.io]https://crates.io/crates/pmcp)