Merge remote-tracking branch 'origin/main' into ochafik/readme-mcp-config

This commit is contained in:
Olivier Chafik 2026-01-20 10:17:45 +00:00
commit 5ef3120e0c
No known key found for this signature in database
58 changed files with 2711 additions and 217 deletions

2
.gitignore vendored
View file

@ -11,3 +11,5 @@ intermediate-findings/
# Playwright
playwright-report/
test-results/
__pycache__/
*.pyc

View file

@ -88,4 +88,7 @@ Uses npm workspaces. Examples in `examples/` are separate packages:
## Claude Code Plugin
The `plugins/mcp-apps/` directory contains a Claude Code plugin distributed via the plugin marketplace. It provides the "Create MCP App" skill (`plugins/mcp-apps/skills/create-mcp-app/SKILL.md`) that guides users through building MCP Apps with interactive UIs.
The `plugins/mcp-apps/` directory contains a Claude Code plugin distributed via the plugin marketplace. It provides the following Claude Code skills files:
- `plugins/mcp-apps/skills/create-mcp-app/SKILL.md` — for creating an MCP App
- `plugins/mcp-apps/skills/migrate-oai-app/SKILL.md` — for migrating an app from the OpenAI Apps SDK to the MCP Apps SDK

View file

@ -67,8 +67,8 @@ Or edit your `package.json` manually:
| [**Scenario Modeler**](examples/scenario-modeler-server) | [**Budget Allocator**](examples/budget-allocator-server) | [**Customer Segmentation**](examples/customer-segmentation-server) |
| [![System Monitor](examples/system-monitor-server/grid-cell.png "Real-time OS metrics")](examples/system-monitor-server) | [![Transcript](examples/transcript-server/grid-cell.png "Live speech transcription")](examples/transcript-server) | [![Video Resource](examples/video-resource-server/grid-cell.png "Binary video via MCP resources")](examples/video-resource-server) |
| [**System Monitor**](examples/system-monitor-server) | [**Transcript**](examples/transcript-server) | [**Video Resource**](examples/video-resource-server) |
| [![PDF Server](examples/pdf-server/grid-cell.png "Interactive PDF viewer with chunked loading")](examples/pdf-server) | [![QR Code](examples/qr-server/grid-cell.png "QR code generator")](examples/qr-server) | |
| [**PDF Server**](examples/pdf-server) | [**QR Code (Python)**](examples/qr-server) | |
| [![PDF Server](examples/pdf-server/grid-cell.png "Interactive PDF viewer with chunked loading")](examples/pdf-server) | [![QR Code](examples/qr-server/grid-cell.png "QR code generator")](examples/qr-server) | [![Say Demo](examples/say-server/grid-cell.png "Text-to-speech demo")](examples/say-server) |
| [**PDF Server**](examples/pdf-server) | [**QR Code (Python)**](examples/qr-server) | [**Say Demo**](examples/say-server) |
### Starter Templates

View file

@ -24,13 +24,13 @@ This guide helps you migrate from the OpenAI Apps SDK to the MCP Apps SDK (`@mod
### Resource Metadata
| OpenAI | MCP Apps | Notes |
| ------------------------------------- | ------------------------ | ---------------------------------------------------------------------------------- |
| `_meta["openai/widgetCSP"]` | `_meta.ui.csp` | `connect_domains` → `connectDomains`, `resource_domains` → `resourceDomains`, etc. |
| — | `_meta.ui.permissions` | MCP adds: permissions for camera, microphone, geolocation, clipboard |
| `_meta["openai/widgetDomain"]` | `_meta.ui.domain` | Dedicated sandbox origin |
| `_meta["openai/widgetPrefersBorder"]` | `_meta.ui.prefersBorder` | Visual boundary preference |
| `_meta["openai/widgetDescription"]` | — | Not yet implemented; use `app.updateModelContext()` for dynamic context |
| OpenAI | MCP Apps | Notes |
| ------------------------------------- | ------------------------ | ----------------------------------------------------------------------- |
| `_meta["openai/widgetCSP"]` | `_meta.ui.csp` | See [CSP field mapping](#csp-field-mapping) below |
| — | `_meta.ui.permissions` | MCP adds: permissions for camera, microphone, geolocation, clipboard |
| `_meta["openai/widgetDomain"]` | `_meta.ui.domain` | Dedicated sandbox origin |
| `_meta["openai/widgetPrefersBorder"]` | `_meta.ui.prefersBorder` | Visual boundary preference |
| `_meta["openai/widgetDescription"]` | — | Not yet implemented; use `app.updateModelContext()` for dynamic context |
### Resource MIME Type
@ -38,6 +38,16 @@ This guide helps you migrate from the OpenAI Apps SDK to the MCP Apps SDK (`@mod
| --------------------- | --------------------------- | -------------------------------------------------------------------------------- |
| `text/html+skybridge` | `text/html;profile=mcp-app` | Auto-set by `registerAppResource()`; use `RESOURCE_MIME_TYPE` constant if manual |
### CSP Field Mapping
| OpenAI | MCP Apps | Notes |
| ------------------ | ----------------- | ---------------------------------------------------------- |
| `resource_domains` | `resourceDomains` | Origins for static assets (images, fonts, styles, scripts) |
| `connect_domains` | `connectDomains` | Origins for fetch/XHR/WebSocket requests |
| `frame_domains` | `frameDomains` | Origins for nested iframes |
| `redirect_domains` | — | OpenAI-only: origins for `openExternal` redirects |
| — | `baseUriDomains` | MCP-only: `base-uri` CSP directive |
### Server-Side Migration Example
### Before (OpenAI)
@ -84,6 +94,13 @@ function createServer() {
uri: "ui://widget/cart.html",
mimeType: "text/html+skybridge",
text: getCartHtml(),
_meta: {
"openai/widgetCSP": {
resource_domains: ["https://cdn.example.com"],
connect_domains: ["https://api.example.com"],
frame_domains: ["https://embed.example.com"],
},
},
},
],
}),
@ -139,6 +156,15 @@ function createServer() {
uri: "ui://widget/cart.html",
mimeType: RESOURCE_MIME_TYPE,
text: getCartHtml(),
_meta: {
ui: {
csp: {
resourceDomains: ["https://cdn.example.com"],
connectDomains: ["https://api.example.com"],
frameDomains: ["https://embed.example.com"],
},
},
},
},
],
}),
@ -152,7 +178,7 @@ function createServer() {
1. **Metadata Structure**: OpenAI uses flat `_meta["openai/..."]` properties; MCP uses nested `_meta.ui.*` structure
2. **Tool Visibility**: OpenAI uses boolean/string (`true`/`"public"`); MCP uses string arrays (`["app", "model"]`)
3. **CSP Property Names**: snake_case → camelCase (`connect_domains` → `connectDomains`)
3. **CSP Field Names**: snake_case → camelCase (e.g., `connect_domains` → `connectDomains`)
4. **App Permissions**: MCP adds `_meta.ui.permissions` for camera, microphone, geolocation, clipboard (not in OpenAI)
5. **Resource MIME Type**: `text/html+skybridge` → `text/html;profile=mcp-app` (use `RESOURCE_MIME_TYPE` constant)
6. **Helper Functions**: MCP provides `registerAppTool()` and `registerAppResource()` helpers
@ -172,7 +198,7 @@ function createServer() {
| OpenAI | MCP Apps | Notes |
| -------------------------------- | -------------------------------------------------- | --------------------------------------------------------------------------------------------------------------- |
| `window.openai` (auto-available) | `const app = new App({name, version}, {})` | MCP requires explicit instantiation |
| `window.openai` (auto-available) | `const app = new App({name, version})` | MCP requires explicit instantiation |
| (implicit) | Vanilla: `await app.connect()` / React: `useApp()` | MCP requires async connection; auto-detects OpenAI env |
| — | `await app.connect(new OpenAITransport())` | Force OpenAI mode (not yet available, see [PR #172](https://github.com/modelcontextprotocol/ext-apps/pull/172)) |
| — | `await app.connect(new PostMessageTransport(...))` | Force MCP mode explicitly |

View file

@ -1,5 +1,6 @@
import { RESOURCE_MIME_TYPE, getToolUiResourceUri, type McpUiSandboxProxyReadyNotification, AppBridge, PostMessageTransport, type McpUiResourceCsp, type McpUiResourcePermissions, buildAllowAttribute, type McpUiUpdateModelContextRequest, type McpUiMessageRequest } from "@modelcontextprotocol/ext-apps/app-bridge";
import { Client } from "@modelcontextprotocol/sdk/client/index.js";
import { SSEClientTransport } from "@modelcontextprotocol/sdk/client/sse.js";
import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/streamableHttp.js";
import type { CallToolResult, Tool } from "@modelcontextprotocol/sdk/types.js";
@ -24,11 +25,8 @@ export interface ServerInfo {
export async function connectToServer(serverUrl: URL): Promise<ServerInfo> {
const client = new Client(IMPLEMENTATION);
log.info("Connecting to server:", serverUrl.href);
await client.connect(new StreamableHTTPClientTransport(serverUrl));
log.info("Connection successful");
const client = await connectWithFallback(serverUrl);
const name = client.getServerVersion()?.name ?? serverUrl.href;
@ -39,6 +37,28 @@ export async function connectToServer(serverUrl: URL): Promise<ServerInfo> {
return { name, client, tools, appHtmlCache: new Map() };
}
async function connectWithFallback(serverUrl: URL): Promise<Client> {
// Try Streamable HTTP first (modern transport)
try {
const client = new Client(IMPLEMENTATION);
await client.connect(new StreamableHTTPClientTransport(serverUrl));
log.info("Connected via Streamable HTTP transport");
return client;
} catch (streamableError) {
log.info("Streamable HTTP failed:", streamableError);
}
// Fall back to SSE (deprecated but needed for older servers)
try {
const client = new Client(IMPLEMENTATION);
await client.connect(new SSEClientTransport(serverUrl));
log.info("Connected via SSE transport");
return client;
} catch (sseError) {
throw new Error(`Could not connect with any transport. SSE error: ${sseError}`);
}
}
interface UiResourceData {
html: string;

View file

@ -10,3 +10,9 @@ html, body {
code {
font-size: 1em;
}
/* Server time fills remaining width for consistent E2E screenshot masking */
#server-time {
flex: 1;
min-width: 0;
}

View file

@ -28,6 +28,13 @@
margin-top: 0.5rem;
}
/* Server time row: flex layout for consistent mask width in E2E tests */
> p {
display: flex;
align-items: baseline;
gap: 0.25em;
}
/* Consistent font for form inputs (inherits from global.css) */
textarea,
input {

View file

@ -10,3 +10,9 @@ html, body {
code {
font-size: 1em;
}
/* Server time fills remaining width for consistent E2E screenshot masking */
#server-time {
flex: 1;
min-width: 0;
}

View file

@ -28,6 +28,13 @@
margin-top: 0.5rem;
}
/* Server time row: flex layout for consistent mask width in E2E tests */
> p {
display: flex;
align-items: baseline;
gap: 0.25em;
}
/* Consistent font for form inputs (inherits from global.css) */
textarea,
input {

View file

@ -10,3 +10,9 @@ html, body {
code {
font-size: 1em;
}
/* Server time fills remaining width for consistent E2E screenshot masking */
#server-time {
flex: 1;
min-width: 0;
}

View file

@ -28,6 +28,13 @@
margin-top: 0.5rem;
}
/* Server time row: flex layout for consistent mask width in E2E tests */
> p {
display: flex;
align-items: baseline;
gap: 0.25em;
}
/* Consistent font for form inputs (inherits from global.css) */
textarea,
input {

View file

@ -162,6 +162,13 @@ async function handleOpenLink() {
margin-top: 0.5rem;
}
/* Server time row: flex layout for consistent mask width in E2E tests */
> p {
display: flex;
align-items: baseline;
gap: 0.25em;
}
textarea,
input {
font-family: inherit;
@ -196,4 +203,10 @@ async function handleOpenLink() {
font-style: normal;
}
}
/* Server time fills remaining width for consistent E2E screenshot masking */
:global(#server-time) {
flex: 1;
min-width: 0;
}
</style>

View file

@ -10,3 +10,9 @@ html, body {
code {
font-size: 1em;
}
/* Server time fills remaining width for consistent E2E screenshot masking */
#server-time {
flex: 1;
min-width: 0;
}

View file

@ -28,6 +28,13 @@
margin-top: 0.5rem;
}
/* Server time row: flex layout for consistent mask width in E2E tests */
> p {
display: flex;
align-items: baseline;
gap: 0.25em;
}
/* Consistent font for form inputs (inherits from global.css) */
textarea,
input {

View file

@ -170,6 +170,13 @@ async function handleOpenLink() {
margin-top: 0.5rem;
}
/* Server time row: flex layout for consistent mask width in E2E tests */
> p {
display: flex;
align-items: baseline;
gap: 0.25em;
}
textarea,
input {
font-family: inherit;
@ -204,4 +211,10 @@ async function handleOpenLink() {
font-style: normal;
}
}
/* Server time fills remaining width for consistent E2E screenshot masking */
:deep(#server-time) {
flex: 1;
min-width: 0;
}
</style>

View file

@ -10,3 +10,9 @@ html, body {
code {
font-size: 1em;
}
/* Server time fills remaining width for consistent E2E screenshot masking */
#server-time {
flex: 1;
min-width: 0;
}

View file

@ -28,6 +28,13 @@
margin-top: 0.5rem;
}
/* Server time row: flex layout for consistent mask width in E2E tests */
> p {
display: flex;
align-items: baseline;
gap: 0.25em;
}
/* Consistent font for form inputs (inherits from global.css) */
textarea,
input {

Binary file not shown.

Before

Width:  |  Height:  |  Size: 39 KiB

After

Width:  |  Height:  |  Size: 27 KiB

Before After
Before After

View file

@ -2,7 +2,7 @@
# /// script
# requires-python = ">=3.10"
# dependencies = [
# "mcp>=1.9.0",
# "mcp @ git+https://github.com/modelcontextprotocol/python-sdk@main",
# "qrcode[pil]>=8.0",
# "uvicorn>=0.34.0",
# "starlette>=0.46.0",
@ -30,7 +30,10 @@ PORT = int(os.environ.get("PORT", "3108"))
mcp = FastMCP("QR Code Server", port=PORT, stateless_http=True)
@mcp.tool(meta={"ui/resourceUri": WIDGET_URI})
@mcp.tool(meta={
"ui":{"resourceUri": WIDGET_URI},
"ui/resourceUri": WIDGET_URI, # legacy support
})
def generate_qr(
text: str = "https://modelcontextprotocol.io",
box_size: int = 10,
@ -72,53 +75,17 @@ def generate_qr(
return [types.ImageContent(type="image", data=b64, mimeType="image/png")]
# Register widget resource using FastMCP decorator (returns HTML string)
@mcp.resource(WIDGET_URI, mime_type="text/html;profile=mcp-app")
# IMPORTANT: all the external domains used by app must be listed
# in the meta.ui.csp.resourceDomains - otherwise they will be blocked by CSP policy
@mcp.resource(
WIDGET_URI,
mime_type="text/html;profile=mcp-app",
meta={"ui": {"csp": {"resourceDomains": ["https://unpkg.com"]}}},
)
def widget() -> str:
"""Widget HTML resource with CSP metadata for external dependencies."""
return Path(__file__).parent.joinpath("widget.html").read_text()
# Override the read_resource handler to inject _meta into the response
# This is needed because FastMCP doesn't support custom _meta on resources
_low_level_server = mcp._mcp_server
async def _read_resource_with_meta(req: types.ReadResourceRequest):
"""Custom handler that injects CSP metadata for the widget resource."""
uri = str(req.params.uri)
html = Path(__file__).parent.joinpath("widget.html").read_text()
if uri == WIDGET_URI:
# NOTE: Must use model_validate with '_meta' key (not 'meta') due to Pydantic alias behavior
content = types.TextResourceContents.model_validate({
"uri": WIDGET_URI,
"mimeType": "text/html;profile=mcp-app",
"text": html,
# IMPORTANT: all the external domains used by app must be listed
# in the _meta.ui.csp.resourceDomains - otherwise they will be blocked by CSP policy
"_meta": {"ui": {"csp": {"resourceDomains": ["https://unpkg.com"]}}}
})
return types.ServerResult(
types.ReadResourceResult(contents=[content])
)
# Fallback for other resources (shouldn't happen for this server)
return types.ServerResult(
types.ReadResourceResult(
contents=[
types.TextResourceContents(
uri=uri,
mimeType="text/plain",
text="Resource not found"
)
]
)
)
# Replace the handler after FastMCP has registered its own
_low_level_server.request_handlers[types.ReadResourceRequest] = _read_resource_with_meta
if __name__ == "__main__":
if "--stdio" in sys.argv:
# Claude Desktop mode

View file

@ -6,6 +6,9 @@
* bun examples/run-all.ts start - Build and start all examples
* bun examples/run-all.ts dev - Run all examples in dev/watch mode
* bun examples/run-all.ts build - Build all examples
*
* Environment:
* EXAMPLE=<folder> - Run only a single example (e.g., EXAMPLE=say-server)
*/
import { readdirSync, statSync, existsSync } from "fs";
@ -14,21 +17,36 @@ import concurrently from "concurrently";
const BASE_PORT = 3101;
const BASIC_HOST = "basic-host";
// Optional: filter to a single example via EXAMPLE env var (folder name)
const EXAMPLE_FILTER = process.env.EXAMPLE;
// Find all example directories except basic-host that have a package.json,
// assign ports, and build URL list
const servers = readdirSync("examples")
const allServers = readdirSync("examples")
.filter(
(d) =>
d !== BASIC_HOST &&
statSync(`examples/${d}`).isDirectory() &&
existsSync(`examples/${d}/package.json`),
)
.sort() // Sort for consistent port assignment
.map((dir, i) => ({
dir,
port: BASE_PORT + i,
url: `http://localhost:${BASE_PORT + i}/mcp`,
}));
.sort(); // Sort for consistent port assignment
// Filter servers if EXAMPLE is specified
const filteredDirs = EXAMPLE_FILTER
? allServers.filter((d) => d === EXAMPLE_FILTER)
: allServers;
if (EXAMPLE_FILTER && filteredDirs.length === 0) {
console.error(`Error: No example found matching EXAMPLE=${EXAMPLE_FILTER}`);
console.error(`Available examples: ${allServers.join(", ")}`);
process.exit(1);
}
const servers = filteredDirs.map((dir, i) => ({
dir,
port: BASE_PORT + i,
url: `http://localhost:${BASE_PORT + i}/mcp`,
}));
const COMMANDS = ["start", "dev", "build"];
@ -43,6 +61,9 @@ if (!command || !COMMANDS.includes(command)) {
const serversEnv = JSON.stringify(servers.map((s) => s.url));
console.log(`Running command: ${command}`);
if (EXAMPLE_FILTER) {
console.log(`Filtering to single example: ${EXAMPLE_FILTER}`);
}
console.log(
`Server examples: ${servers.map((s) => `${s.dir}:${s.port}`).join(", ")}`,
);

Some files were not shown because too many files have changed in this diff Show more