Merge remote-tracking branch 'origin/main' into ochafik/readme-mcp-config
This commit is contained in:
commit
5ef3120e0c
58 changed files with 2711 additions and 217 deletions
2
.gitignore
vendored
2
.gitignore
vendored
|
|
@ -11,3 +11,5 @@ intermediate-findings/
|
|||
# Playwright
|
||||
playwright-report/
|
||||
test-results/
|
||||
__pycache__/
|
||||
*.pyc
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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) |
|
||||
| [](examples/system-monitor-server) | [](examples/transcript-server) | [](examples/video-resource-server) |
|
||||
| [**System Monitor**](examples/system-monitor-server) | [**Transcript**](examples/transcript-server) | [**Video Resource**](examples/video-resource-server) |
|
||||
| [](examples/pdf-server) | [](examples/qr-server) | |
|
||||
| [**PDF Server**](examples/pdf-server) | [**QR Code (Python)**](examples/qr-server) | |
|
||||
| [](examples/pdf-server) | [](examples/qr-server) | [](examples/say-server) |
|
||||
| [**PDF Server**](examples/pdf-server) | [**QR Code (Python)**](examples/qr-server) | [**Say Demo**](examples/say-server) |
|
||||
|
||||
### Starter Templates
|
||||
|
||||
|
|
|
|||
|
|
@ -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 |
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 |
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
Loading…
Reference in a new issue