Skip to content

Commit 1f3bc52

Browse files
committed
add simple-host example
1 parent 535dae6 commit 1f3bc52

16 files changed

+1187
-13
lines changed
Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
<!DOCTYPE html>
2+
<html lang="en">
3+
<head>
4+
<meta charset="UTF-8">
5+
<meta name="viewport" content="width=device-width, initial-scale=1.0">
6+
<style>
7+
* {
8+
box-sizing: border-box;
9+
}
10+
body {
11+
margin: 0;
12+
font-family: system-ui, -apple-system, sans-serif;
13+
}
14+
</style>
15+
<title>Example MCP-UI React Host</title>
16+
</head>
17+
<body>
18+
<div id="root"></div>
19+
<script src="/src/example-host-react.tsx" type="module"></script>
20+
</body>
21+
</html>
Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
<!DOCTYPE html>
2+
<html lang="en">
3+
<head>
4+
<meta charset="UTF-8">
5+
<meta name="viewport" content="width=device-width, initial-scale=1.0">
6+
<style>
7+
#chat-root {
8+
display: flex;
9+
flex-direction: column;
10+
}
11+
</style>
12+
<script src="/src/example-host.ts" type="module"></script>
13+
<title>Example MCP View Host</title>
14+
</head>
15+
<body>
16+
<h1>Example MCP View Host</h1>
17+
18+
<div id="controls"></div>
19+
<div id="chat-root"></div>
20+
</body>
21+
</html>

examples/simple-host/package.json

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
1+
{
2+
"homepage": "https://github.com/modelcontextprotocol/ext-apps/tree/main/examples/simple-host",
3+
"name": "@modelcontextprotocol/ext-apps-host",
4+
"version": "1.0.0",
5+
"type": "module",
6+
"scripts": {
7+
"start:server": "python3 serve.py",
8+
"start:mcp-server": "cd ../simple-server && npm install && npm run start",
9+
"start:proxy": "python3 proxy/serve.py",
10+
"build": "concurrently 'INPUT=example-host.html vite build' 'INPUT=example-host-react.html vite build' 'INPUT=sandbox.html vite build'",
11+
"server": "bun server.ts",
12+
"start": "NODE_ENV=development npm run build && concurrently 'npm run start:server' 'npm run start:mcp-server' 'npm run start:proxy'"
13+
},
14+
"dependencies": {
15+
"@modelcontextprotocol/ext-apps": "../..",
16+
"@modelcontextprotocol/sdk": "../../../modelcontextprotocol-typescript-sdk",
17+
"react-dom": "^19.2.0",
18+
"react": "^19.2.0",
19+
"zod": "^3.25.0"
20+
},
21+
"devDependencies": {
22+
"@types/express": "^5.0.0",
23+
"@types/node": "^22.0.0",
24+
"@types/react-dom": "^19.2.2",
25+
"@types/react": "^19.2.2",
26+
"@vitejs/plugin-react": "^4.3.4",
27+
"concurrently": "^9.2.1",
28+
"cors": "^2.8.5",
29+
"esbuild": "~0.19.10",
30+
"express": "^5.1.0",
31+
"prettier": "^3.6.2",
32+
"tsx": "^4.20.6",
33+
"typescript": "^5.9.3",
34+
"vite-plugin-singlefile": "^2.3.0",
35+
"vite": "^6.0.0",
36+
"vitest": "^3.2.4"
37+
}
38+
}

examples/simple-host/sandbox.html

Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,48 @@
1+
<!doctype html>
2+
<html>
3+
<head>
4+
<meta charset="utf-8" />
5+
<!-- Permissive CSP so nested content is not constrained by host CSP -->
6+
<meta http-equiv="Content-Security-Policy" content="
7+
default-src 'self';
8+
img-src * data: blob: 'unsafe-inline';
9+
media-src * blob: data:;
10+
font-src * blob: data:;
11+
script-src 'self'
12+
'wasm-unsafe-eval'
13+
'unsafe-inline'
14+
'unsafe-eval'
15+
blob: data: http://localhost:* https://localhost:*;
16+
style-src * blob: data: 'unsafe-inline';
17+
connect-src *;
18+
frame-src * blob: data: http://localhost:* https://localhost:*;
19+
base-uri 'self';
20+
" />
21+
<title>MCP-UI Proxy</title>
22+
<style>
23+
html,
24+
body {
25+
margin: 0;
26+
height: 100vh;
27+
width: 100vw;
28+
}
29+
body {
30+
display: flex;
31+
flex-direction: column;
32+
}
33+
* {
34+
box-sizing: border-box;
35+
}
36+
iframe {
37+
background-color: transparent;
38+
border: 0px none transparent;
39+
padding: 0px;
40+
overflow: hidden;
41+
flex-grow: 1;
42+
}
43+
</style>
44+
</head>
45+
<body>
46+
<script type="module" src="/src/sandbox.ts"></script>
47+
</body>
48+
</html>

examples/simple-host/serve.py

Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,63 @@
1+
#!/usr/bin/env python3
2+
"""
3+
Simple HTTP server to serve the MCP-UI proxy on port 8765
4+
"""
5+
6+
import http.server
7+
import os
8+
import socketserver
9+
import sys
10+
from pathlib import Path
11+
12+
PORT = int(os.environ.get('PORT', '8080'))
13+
DIRECTORY = Path(__file__).parent / 'dist'
14+
15+
class CustomHTTPRequestHandler(http.server.SimpleHTTPRequestHandler):
16+
def __init__(self, *args, **kwargs):
17+
super().__init__(*args, directory=DIRECTORY.as_posix(), **kwargs)
18+
19+
def end_headers(self):
20+
# Apply custom headers only to sandbox.html
21+
if self.path == "/sandbox.html" or self.path == "/":
22+
# Add CORS headers to allow cross-origin requests
23+
self.send_header("Access-Control-Allow-Origin", "*")
24+
self.send_header("Access-Control-Allow-Methods", "GET, POST, OPTIONS")
25+
self.send_header("Access-Control-Allow-Headers", "*")
26+
27+
# Add permissive CSP to allow external resources (images, styles, scripts)
28+
csp = "; ".join(
29+
[
30+
"default-src 'self'",
31+
"img-src * data: blob: 'unsafe-inline'",
32+
"style-src * blob: data: 'unsafe-inline'",
33+
"script-src * blob: data: 'unsafe-inline' 'unsafe-eval'",
34+
"connect-src *",
35+
"font-src * blob: data:",
36+
"media-src * blob: data:",
37+
"frame-src * blob: data:",
38+
]
39+
)
40+
self.send_header("Content-Security-Policy", csp)
41+
42+
# Disable caching to ensure fresh content on every request
43+
self.send_header("Cache-Control", "no-cache, no-store, must-revalidate")
44+
self.send_header("Pragma", "no-cache")
45+
self.send_header("Expires", "0")
46+
47+
super().end_headers()
48+
49+
50+
def main():
51+
print(f"Server running on: http://localhost:{PORT}")
52+
print(f"Press Ctrl+C to stop the server\n")
53+
54+
with socketserver.TCPServer(("", PORT), CustomHTTPRequestHandler) as httpd:
55+
try:
56+
httpd.serve_forever()
57+
except KeyboardInterrupt:
58+
print("\n\nServer stopped.")
59+
sys.exit(0)
60+
61+
62+
if __name__ == "__main__":
63+
main()
Lines changed: 115 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,115 @@
1+
import { McpUiSandboxProxyReadyNotificationSchema } from "@modelcontextprotocol/ext-apps";
2+
import { Client } from "@modelcontextprotocol/sdk/client/index.js";
3+
import { Tool } from "@modelcontextprotocol/sdk/types.js";
4+
5+
const MCP_UI_RESOURCE_META_KEY = "ui/resourceUri";
6+
7+
export async function setupSandboxProxyIframe(sandboxProxyUrl: URL): Promise<{
8+
iframe: HTMLIFrameElement;
9+
onReady: Promise<void>;
10+
}> {
11+
const iframe = document.createElement("iframe");
12+
iframe.style.width = "100%";
13+
iframe.style.height = "600px";
14+
iframe.style.border = "none";
15+
iframe.style.backgroundColor = "transparent";
16+
// iframe.allow = 'microphone'
17+
iframe.setAttribute("sandbox", "allow-scripts allow-same-origin allow-forms");
18+
19+
const onReady = new Promise<void>((resolve, _reject) => {
20+
const initialListener = async (event: MessageEvent) => {
21+
if (event.source === iframe.contentWindow) {
22+
if (
23+
event.data &&
24+
event.data.method ===
25+
McpUiSandboxProxyReadyNotificationSchema.shape.method._def.value
26+
) {
27+
window.removeEventListener("message", initialListener);
28+
resolve();
29+
}
30+
}
31+
};
32+
window.addEventListener("message", initialListener);
33+
});
34+
35+
iframe.src = sandboxProxyUrl.href;
36+
37+
return { iframe, onReady };
38+
}
39+
40+
export type ToolUiResourceInfo = {
41+
uri: string;
42+
};
43+
44+
export async function getToolUiResourceUri(
45+
client: Client,
46+
toolName: string,
47+
): Promise<ToolUiResourceInfo | null> {
48+
let tool: Tool | undefined;
49+
let cursor: string | undefined = undefined;
50+
do {
51+
const toolsResult = await client.listTools({ cursor });
52+
tool = toolsResult.tools.find((t) => t.name === toolName);
53+
cursor = toolsResult.nextCursor;
54+
} while (!tool && cursor);
55+
if (!tool) {
56+
throw new Error(`tool ${toolName} not found`);
57+
}
58+
if (!tool._meta) {
59+
return null;
60+
}
61+
62+
let uri: string;
63+
if (MCP_UI_RESOURCE_META_KEY in tool._meta) {
64+
uri = String(tool._meta[MCP_UI_RESOURCE_META_KEY]);
65+
} else {
66+
return null;
67+
}
68+
if (!uri.startsWith("ui://")) {
69+
throw new Error(
70+
`tool ${toolName} has unsupported output template URI: ${uri}`,
71+
);
72+
}
73+
return { uri };
74+
}
75+
76+
export async function readToolUiResourceHtml(
77+
client: Client,
78+
opts: {
79+
uri: string;
80+
},
81+
): Promise<string> {
82+
const resource = await client.readResource({ uri: opts.uri });
83+
84+
if (!resource) {
85+
throw new Error("UI resource not found: " + opts.uri);
86+
}
87+
if (resource.contents.length !== 1) {
88+
throw new Error(
89+
"Unsupported UI resource content length: " + resource.contents.length,
90+
);
91+
}
92+
const content = resource.contents[0];
93+
let html: string;
94+
const isHtml = (t?: string) => t === "text/html+mcp";
95+
96+
if (
97+
"text" in content &&
98+
typeof content.text === "string" &&
99+
isHtml(content.mimeType)
100+
) {
101+
html = content.text;
102+
} else if (
103+
"blob" in content &&
104+
typeof content.blob === "string" &&
105+
isHtml(content.mimeType)
106+
) {
107+
html = atob(content.blob);
108+
} else {
109+
throw new Error(
110+
"Unsupported UI resource content format: " + JSON.stringify(content),
111+
);
112+
}
113+
114+
return html;
115+
}

0 commit comments

Comments
 (0)