Add basic-host and basic-server-react examples
Introduce new example implementations to replace simple-host and simple-server: - basic-host: Reference host implementation with double-iframe sandbox pattern for secure UI isolation (outer iframe validates and relays messages, inner iframe renders untrusted tool UI via srcdoc) - basic-server-react: MCP server example demonstrating tool registration with linked UI resources and React UI using the useApp() hook Build infrastructure updates: - Add build:all script and simplify CI/pre-commit to use it - Add examples:dev for watch-mode development workflow Also change PostMessageTransport logging from info to debug level. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
parent
0af15322af
commit
97310d50c5
6 changed files with 1808 additions and 25 deletions
12
examples/basic-host/src/global.css
Normal file
12
examples/basic-host/src/global.css
Normal file
|
|
@ -0,0 +1,12 @@
|
|||
* {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
html, body {
|
||||
font-family: system-ui, -apple-system, sans-serif;
|
||||
font-size: 1rem;
|
||||
}
|
||||
|
||||
code {
|
||||
font-size: 1em;
|
||||
}
|
||||
257
examples/basic-host/src/implementation.ts
Normal file
257
examples/basic-host/src/implementation.ts
Normal file
|
|
@ -0,0 +1,257 @@
|
|||
import { RESOURCE_URI_META_KEY, type McpUiSandboxProxyReadyNotification } from "@modelcontextprotocol/ext-apps";
|
||||
import { AppBridge, PostMessageTransport } from "@modelcontextprotocol/ext-apps/app-bridge";
|
||||
import { Client } from "@modelcontextprotocol/sdk/client/index.js";
|
||||
import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/streamableHttp.js";
|
||||
import type { CallToolResult, Tool } from "@modelcontextprotocol/sdk/types.js";
|
||||
|
||||
|
||||
const SANDBOX_PROXY_URL = new URL("http://localhost:8081/sandbox.html");
|
||||
const IMPLEMENTATION = { name: "MCP Apps Host", version: "1.0.0" };
|
||||
|
||||
|
||||
export const log = {
|
||||
info: console.log.bind(console, "[HOST]"),
|
||||
warn: console.warn.bind(console, "[HOST]"),
|
||||
error: console.error.bind(console, "[HOST]"),
|
||||
};
|
||||
|
||||
|
||||
export interface ServerInfo {
|
||||
client: Client;
|
||||
tools: Map<string, Tool>;
|
||||
appHtmlCache: Map<string, string>;
|
||||
}
|
||||
|
||||
|
||||
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 toolsList = await client.listTools();
|
||||
const tools = new Map(toolsList.tools.map((tool) => [tool.name, tool]));
|
||||
log.info("Server tools:", Array.from(tools.keys()));
|
||||
|
||||
return { client, tools, appHtmlCache: new Map() };
|
||||
}
|
||||
|
||||
|
||||
export interface ToolCallInfo {
|
||||
serverInfo: ServerInfo;
|
||||
tool: Tool;
|
||||
input: Record<string, unknown>;
|
||||
resultPromise: Promise<CallToolResult>;
|
||||
appHtmlPromise?: Promise<string>;
|
||||
}
|
||||
|
||||
|
||||
export function hasAppHtml(toolCallInfo: ToolCallInfo): toolCallInfo is Required<ToolCallInfo> {
|
||||
return !!toolCallInfo.appHtmlPromise;
|
||||
}
|
||||
|
||||
|
||||
export function callTool(
|
||||
serverInfo: ServerInfo,
|
||||
name: string,
|
||||
input: Record<string, unknown>,
|
||||
): ToolCallInfo {
|
||||
log.info("Calling tool", name, "with input", input);
|
||||
const resultPromise = serverInfo.client.callTool({ name, arguments: input }) as Promise<CallToolResult>;
|
||||
|
||||
const tool = serverInfo.tools.get(name);
|
||||
if (!tool) {
|
||||
throw new Error(`Unknown tool: ${name}`);
|
||||
}
|
||||
|
||||
const toolCallInfo: ToolCallInfo = { serverInfo, tool, input, resultPromise };
|
||||
|
||||
const uiResourceUri = getUiResourceUri(tool);
|
||||
if (uiResourceUri) {
|
||||
toolCallInfo.appHtmlPromise = getUiResourceHtml(serverInfo, uiResourceUri);
|
||||
}
|
||||
|
||||
return toolCallInfo;
|
||||
}
|
||||
|
||||
|
||||
function getUiResourceUri(tool: Tool): string | undefined {
|
||||
const uri = tool._meta?.[RESOURCE_URI_META_KEY];
|
||||
if (typeof uri === "string" && uri.startsWith("ui://")) {
|
||||
return uri;
|
||||
} else if (uri !== undefined) {
|
||||
throw new Error(`Invalid UI resource URI: ${JSON.stringify(uri)}`);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
async function getUiResourceHtml(serverInfo: ServerInfo, uri: string): Promise<string> {
|
||||
let html = serverInfo.appHtmlCache.get(uri);
|
||||
if (html) {
|
||||
log.info("Read UI resource from cache:", uri);
|
||||
return html;
|
||||
}
|
||||
|
||||
log.info("Reading UI resource:", uri);
|
||||
const resource = await serverInfo.client.readResource({ uri });
|
||||
|
||||
if (!resource) {
|
||||
throw new Error(`Resource not found: ${uri}`);
|
||||
}
|
||||
|
||||
if (resource.contents.length !== 1) {
|
||||
throw new Error(`Unexpected contents count: ${resource.contents.length}`);
|
||||
}
|
||||
|
||||
const content = resource.contents[0];
|
||||
|
||||
// Per the MCP App specification, "text/html+mcp" signals this resource is
|
||||
// indeed for an MCP App UI.
|
||||
if (content.mimeType !== "text/html+mcp") {
|
||||
throw new Error(`Unsupported MIME type: ${content.mimeType}`);
|
||||
}
|
||||
|
||||
html = "blob" in content ? atob(content.blob) : content.text;
|
||||
serverInfo.appHtmlCache.set(uri, html);
|
||||
return html;
|
||||
}
|
||||
|
||||
|
||||
export function loadSandboxProxy(iframe: HTMLIFrameElement): Promise<boolean> {
|
||||
// Prevent reload
|
||||
if (iframe.src) return Promise.resolve(false);
|
||||
|
||||
iframe.setAttribute("sandbox", "allow-scripts allow-same-origin allow-forms");
|
||||
|
||||
const readyNotification: McpUiSandboxProxyReadyNotification["method"] =
|
||||
"ui/notifications/sandbox-proxy-ready";
|
||||
|
||||
const readyPromise = new Promise<boolean>((resolve) => {
|
||||
const listener = ({ source, data }: MessageEvent) => {
|
||||
if (source === iframe.contentWindow && data?.method === readyNotification) {
|
||||
log.info("Sandbox proxy loaded")
|
||||
window.removeEventListener("message", listener);
|
||||
resolve(true);
|
||||
}
|
||||
};
|
||||
window.addEventListener("message", listener);
|
||||
});
|
||||
|
||||
log.info("Loading sandbox proxy...");
|
||||
iframe.src = SANDBOX_PROXY_URL.href;
|
||||
|
||||
return readyPromise;
|
||||
}
|
||||
|
||||
|
||||
export async function initializeApp(
|
||||
iframe: HTMLIFrameElement,
|
||||
appBridge: AppBridge,
|
||||
{ input, resultPromise, appHtmlPromise }: Required<ToolCallInfo>,
|
||||
): Promise<void> {
|
||||
const appInitializedPromise = hookInitializedCallback(appBridge);
|
||||
|
||||
// Connect app bridge (triggers MCP initialization handshake)
|
||||
//
|
||||
// IMPORTANT: Pass `iframe.contentWindow` as BOTH target and source to ensure
|
||||
// this proxy only responds to messages from its specific iframe.
|
||||
await appBridge.connect(
|
||||
new PostMessageTransport(iframe.contentWindow!, iframe.contentWindow!),
|
||||
);
|
||||
|
||||
// Load inner iframe HTML
|
||||
log.info("Sending UI resource HTML to MCP App");
|
||||
await appBridge.sendSandboxResourceReady({ html: await appHtmlPromise });
|
||||
|
||||
// Wait for inner iframe to be ready
|
||||
log.info("Waiting for MCP App to initialize...");
|
||||
await appInitializedPromise;
|
||||
log.info("MCP App initialized");
|
||||
|
||||
// Send tool call input to iframe
|
||||
log.info("Sending tool call input to MCP App:", input);
|
||||
appBridge.sendToolInput({ arguments: input });
|
||||
|
||||
// Schedule tool call result to be sent to MCP App
|
||||
resultPromise.then((result) => {
|
||||
log.info("Sending tool call result to MCP App:", result);
|
||||
appBridge.sendToolResult(result);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Hooks into `AppBridge.oninitialized` and returns a Promise that resolves when
|
||||
* the MCP App is initialized (i.e., when the inner iframe is ready).
|
||||
*/
|
||||
function hookInitializedCallback(appBridge: AppBridge): Promise<void> {
|
||||
const oninitialized = appBridge.oninitialized;
|
||||
return new Promise<void>((resolve) => {
|
||||
appBridge.oninitialized = (...args) => {
|
||||
resolve();
|
||||
appBridge.oninitialized = oninitialized;
|
||||
appBridge.oninitialized?.(...args);
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
export function newAppBridge(serverInfo: ServerInfo, iframe: HTMLIFrameElement): AppBridge {
|
||||
const serverCapabilities = serverInfo.client.getServerCapabilities();
|
||||
const appBridge = new AppBridge(serverInfo.client, IMPLEMENTATION, {
|
||||
openLinks: {},
|
||||
serverTools: serverCapabilities?.tools,
|
||||
serverResources: serverCapabilities?.resources,
|
||||
});
|
||||
|
||||
// Register all handlers before calling connect(). The Guest UI can start
|
||||
// sending requests immediately after the initialization handshake, so any
|
||||
// handlers registered after connect() might miss early requests.
|
||||
|
||||
appBridge.onmessage = async (params, _extra) => {
|
||||
log.info("Message from MCP App:", params);
|
||||
return {};
|
||||
};
|
||||
|
||||
appBridge.onopenlink = async (params, _extra) => {
|
||||
log.info("Open link request:", params);
|
||||
window.open(params.url, "_blank", "noopener,noreferrer");
|
||||
return {};
|
||||
};
|
||||
|
||||
appBridge.onloggingmessage = (params) => {
|
||||
log.info("Log message from MCP App:", params);
|
||||
};
|
||||
|
||||
appBridge.onsizechange = async ({ width, height }) => {
|
||||
// The MCP App has requested a `width` and `height`, but if
|
||||
// `box-sizing: border-box` is applied to the outer iframe element, then we
|
||||
// must add border thickness to `width` and `height` to compute the actual
|
||||
// necessary width and height (in order to prevent a resize feedback loop).
|
||||
const style = getComputedStyle(iframe);
|
||||
const isBorderBox = style.boxSizing === "border-box";
|
||||
|
||||
// Animate the change for a smooth transition.
|
||||
const from: Keyframe = {};
|
||||
const to: Keyframe = {};
|
||||
|
||||
if (width !== undefined) {
|
||||
if (isBorderBox) {
|
||||
width += parseFloat(style.borderLeftWidth) + parseFloat(style.borderRightWidth);
|
||||
}
|
||||
from.width = `${iframe.offsetWidth}px`;
|
||||
iframe.style.width = to.width = `${width}px`;
|
||||
}
|
||||
if (height !== undefined) {
|
||||
if (isBorderBox) {
|
||||
height += parseFloat(style.borderTopWidth) + parseFloat(style.borderBottomWidth);
|
||||
}
|
||||
from.height = `${iframe.offsetHeight}px`;
|
||||
iframe.style.height = to.height = `${height}px`;
|
||||
}
|
||||
|
||||
iframe.animate([from, to], { duration: 300, easing: "ease-out" });
|
||||
};
|
||||
|
||||
return appBridge;
|
||||
}
|
||||
129
examples/basic-host/src/index.module.css
Normal file
129
examples/basic-host/src/index.module.css
Normal file
|
|
@ -0,0 +1,129 @@
|
|||
.callToolPanel, .toolCallInfoPanel {
|
||||
margin: 0 auto;
|
||||
padding: 1rem;
|
||||
border: 1px solid #ddd;
|
||||
border-radius: 4px;
|
||||
|
||||
* + & {
|
||||
margin-top: 1rem;
|
||||
}
|
||||
}
|
||||
|
||||
.callToolPanel {
|
||||
max-width: 480px;
|
||||
|
||||
form {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
label {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.25rem;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
select,
|
||||
textarea {
|
||||
padding: 0.5rem;
|
||||
border: 1px solid #ccc;
|
||||
border-radius: 4px;
|
||||
font-family: monospace;
|
||||
font-size: inherit;
|
||||
}
|
||||
|
||||
textarea {
|
||||
min-height: 6rem;
|
||||
resize: vertical;
|
||||
|
||||
&[aria-invalid="true"] {
|
||||
background-color: #fdd;
|
||||
}
|
||||
}
|
||||
|
||||
button {
|
||||
align-self: center;
|
||||
min-width: 200px;
|
||||
padding: 0.75rem 1.5rem;
|
||||
border: none;
|
||||
border-radius: 4px;
|
||||
background-color: #1e3a5f;
|
||||
font-size: inherit;
|
||||
font-weight: 600;
|
||||
color: white;
|
||||
cursor: pointer;
|
||||
|
||||
&:hover:not(:disabled) {
|
||||
background-color: #2d4a7c;
|
||||
}
|
||||
|
||||
&:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.toolCallInfoPanel {
|
||||
display: flex;
|
||||
gap: 1rem;
|
||||
max-width: 1120px;
|
||||
animation: slideDown 0.3s ease-out;
|
||||
}
|
||||
|
||||
@keyframes slideDown {
|
||||
from { opacity: 0; transform: translateY(-12px); }
|
||||
}
|
||||
|
||||
.inputInfoPanel {
|
||||
display: flex;
|
||||
flex: 3;
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
min-width: 0;
|
||||
|
||||
.toolName {
|
||||
margin: 0;
|
||||
font-family: monospace;
|
||||
font-size: 1.5rem;
|
||||
}
|
||||
}
|
||||
|
||||
.outputInfoPanel {
|
||||
flex: 4;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.jsonBlock {
|
||||
flex-grow: 1;
|
||||
min-height: 0;
|
||||
margin: 0;
|
||||
padding: 1rem;
|
||||
border-radius: 4px;
|
||||
background-color: #f5f5f5;
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
.appIframePanel {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
width: 100%;
|
||||
min-height: 200px;
|
||||
|
||||
iframe {
|
||||
flex-shrink: 0;
|
||||
height: 100%;
|
||||
max-width: 100%;
|
||||
box-sizing: border-box;
|
||||
border: 3px dashed #888;
|
||||
border-radius: 4px;
|
||||
}
|
||||
}
|
||||
|
||||
.error {
|
||||
padding: 1.5rem;
|
||||
background-color: #ddd;
|
||||
color: #d00;
|
||||
}
|
||||
195
examples/basic-host/src/index.tsx
Normal file
195
examples/basic-host/src/index.tsx
Normal file
|
|
@ -0,0 +1,195 @@
|
|||
import { Component, type ErrorInfo, type ReactNode, StrictMode, Suspense, use, useEffect, useMemo, useRef, useState } from "react";
|
||||
import { createRoot } from "react-dom/client";
|
||||
import { callTool, connectToServer, hasAppHtml, initializeApp, loadSandboxProxy, log, newAppBridge, type ServerInfo, type ToolCallInfo } from "./implementation";
|
||||
import styles from "./index.module.css";
|
||||
|
||||
|
||||
const MCP_SERVER_URL = new URL("http://localhost:3001/mcp");
|
||||
|
||||
|
||||
interface HostProps {
|
||||
serverInfoPromise: Promise<ServerInfo>;
|
||||
}
|
||||
function Host({ serverInfoPromise }: HostProps) {
|
||||
const serverInfo = use(serverInfoPromise);
|
||||
const [toolCallInfos, setToolCallInfos] = useState<ToolCallInfo[]>([]);
|
||||
|
||||
return (
|
||||
<>
|
||||
{toolCallInfos.map((info, i) => (
|
||||
<ToolCallInfoPanel key={i} toolCallInfo={info} />
|
||||
))}
|
||||
<CallToolPanel
|
||||
serverInfo={serverInfo}
|
||||
addToolCallInfo={(info) => setToolCallInfos([...toolCallInfos, info])}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
interface CallToolPanelProps {
|
||||
serverInfo: ServerInfo;
|
||||
addToolCallInfo: (toolCallInfo: ToolCallInfo) => void;
|
||||
}
|
||||
function CallToolPanel({ serverInfo, addToolCallInfo }: CallToolPanelProps) {
|
||||
const toolNames = Array.from(serverInfo.tools.keys());
|
||||
const [selectedTool, setSelectedTool] = useState(toolNames[0] ?? "");
|
||||
const [inputJson, setInputJson] = useState("{}");
|
||||
|
||||
const isValidJson = useMemo(() => {
|
||||
try {
|
||||
JSON.parse(inputJson);
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}, [inputJson]);
|
||||
|
||||
const handleSubmit = () => {
|
||||
const toolCallInfo = callTool(serverInfo, selectedTool, JSON.parse(inputJson));
|
||||
addToolCallInfo(toolCallInfo);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={styles.callToolPanel}>
|
||||
<form onSubmit={(e) => { e.preventDefault(); handleSubmit(); }}>
|
||||
<label>
|
||||
Tool Name
|
||||
<select
|
||||
value={selectedTool}
|
||||
onChange={(e) => setSelectedTool(e.target.value)}
|
||||
>
|
||||
{toolNames.map((name) => (
|
||||
<option key={name} value={name}>{name}</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
<label>
|
||||
Tool Input
|
||||
<textarea
|
||||
aria-invalid={!isValidJson}
|
||||
value={inputJson}
|
||||
onChange={(e) => setInputJson(e.target.value)}
|
||||
/>
|
||||
</label>
|
||||
<button type="submit" disabled={!selectedTool || !isValidJson}>
|
||||
Call Tool
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
interface ToolCallInfoPanelProps {
|
||||
toolCallInfo: ToolCallInfo;
|
||||
}
|
||||
function ToolCallInfoPanel({ toolCallInfo }: ToolCallInfoPanelProps) {
|
||||
return (
|
||||
<div className={styles.toolCallInfoPanel}>
|
||||
<div className={styles.inputInfoPanel}>
|
||||
<h2 className={styles.toolName}>{toolCallInfo.tool.name}</h2>
|
||||
<JsonBlock value={toolCallInfo.input} />
|
||||
</div>
|
||||
<div className={styles.outputInfoPanel}>
|
||||
<ErrorBoundary>
|
||||
<Suspense fallback="Loading...">
|
||||
{
|
||||
hasAppHtml(toolCallInfo)
|
||||
? <AppIFramePanel toolCallInfo={toolCallInfo} />
|
||||
: <ToolResultPanel toolCallInfo={toolCallInfo} />
|
||||
}
|
||||
</Suspense>
|
||||
</ErrorBoundary>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
function JsonBlock({ value }: { value: object }) {
|
||||
return (
|
||||
<pre className={styles.jsonBlock}>
|
||||
<code>{JSON.stringify(value, null, 2)}</code>
|
||||
</pre>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
interface AppIFramePanelProps {
|
||||
toolCallInfo: Required<ToolCallInfo>;
|
||||
}
|
||||
function AppIFramePanel({ toolCallInfo }: AppIFramePanelProps) {
|
||||
const iframeRef = useRef<HTMLIFrameElement | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const iframe = iframeRef.current!;
|
||||
loadSandboxProxy(iframe).then((firstTime) => {
|
||||
// The `firstTime` check guards against React Strict Mode's double
|
||||
// invocation (mount → unmount → remount simulation in development).
|
||||
// Outside of Strict Mode, this `useEffect` runs only once per
|
||||
// `toolCallInfo`.
|
||||
if (firstTime) {
|
||||
const appBridge = newAppBridge(toolCallInfo.serverInfo, iframe);
|
||||
initializeApp(iframe, appBridge, toolCallInfo);
|
||||
}
|
||||
});
|
||||
}, [toolCallInfo]);
|
||||
|
||||
return (
|
||||
<div className={styles.appIframePanel}>
|
||||
<iframe ref={iframeRef} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
interface ToolResultPanelProps {
|
||||
toolCallInfo: ToolCallInfo;
|
||||
}
|
||||
function ToolResultPanel({ toolCallInfo }: ToolResultPanelProps) {
|
||||
const result = use(toolCallInfo.resultPromise);
|
||||
return <JsonBlock value={result} />;
|
||||
}
|
||||
|
||||
|
||||
interface ErrorBoundaryProps {
|
||||
children: ReactNode;
|
||||
}
|
||||
interface ErrorBoundaryState {
|
||||
hasError: boolean;
|
||||
error: unknown;
|
||||
}
|
||||
class ErrorBoundary extends Component<ErrorBoundaryProps, ErrorBoundaryState> {
|
||||
state: ErrorBoundaryState = { hasError: false, error: undefined };
|
||||
|
||||
// Called during render phase - must be pure (no side effects)
|
||||
// Note: error is `unknown` because JS allows throwing any value
|
||||
static getDerivedStateFromError(error: unknown): ErrorBoundaryState {
|
||||
return { hasError: true, error };
|
||||
}
|
||||
|
||||
// Called during commit phase - can have side effects (logging, etc.)
|
||||
componentDidCatch(error: unknown, errorInfo: ErrorInfo): void {
|
||||
log.error("Caught:", error, errorInfo.componentStack);
|
||||
}
|
||||
|
||||
render(): ReactNode {
|
||||
if (this.state.hasError) {
|
||||
const { error } = this.state;
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
return <div className={styles.error}><strong>ERROR:</strong> {message}</div>;
|
||||
}
|
||||
return this.props.children;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
createRoot(document.getElementById("root")!).render(
|
||||
<StrictMode>
|
||||
<Suspense fallback={<p>Connecting to server ({MCP_SERVER_URL.href})...</p>}>
|
||||
<Host serverInfoPromise={connectToServer(MCP_SERVER_URL)} />
|
||||
</Suspense>
|
||||
</StrictMode>,
|
||||
);
|
||||
87
examples/basic-host/src/sandbox.ts
Normal file
87
examples/basic-host/src/sandbox.ts
Normal file
|
|
@ -0,0 +1,87 @@
|
|||
import type { McpUiSandboxProxyReadyNotification, McpUiSandboxResourceReadyNotification } from "../../../dist/src/types";
|
||||
|
||||
const ALLOWED_REFERRER_PATTERN = /^http:\/\/(localhost|127\.0\.0\.1)(:|\/|$)/;
|
||||
|
||||
if (window.self === window.top) {
|
||||
throw new Error("This file is only to be used in an iframe sandbox.");
|
||||
}
|
||||
|
||||
if (!document.referrer) {
|
||||
throw new Error("No referrer, cannot validate embedding site.");
|
||||
}
|
||||
|
||||
if (!document.referrer.match(ALLOWED_REFERRER_PATTERN)) {
|
||||
throw new Error(
|
||||
`Embedding domain not allowed in referrer ${document.referrer}. (Consider updating the validation logic to allow your domain.)`,
|
||||
);
|
||||
}
|
||||
|
||||
// Security self-test: verify iframe isolation is working correctly.
|
||||
// This MUST throw a SecurityError -- if `window.top` is accessible, the sandbox
|
||||
// configuration is dangerously broken and untrusted content could escape.
|
||||
try {
|
||||
window.top!.alert("If you see this, the sandbox is not setup securely.");
|
||||
throw "FAIL";
|
||||
} catch (e) {
|
||||
if (e === "FAIL") {
|
||||
throw new Error("The sandbox is not setup securely.");
|
||||
}
|
||||
|
||||
// Expected: SecurityError confirms proper sandboxing.
|
||||
}
|
||||
|
||||
// Double-iframe sandbox architecture: THIS file is the outer sandbox proxy
|
||||
// iframe on a separate origin. It creates an inner iframe for untrusted HTML
|
||||
// content. Per the specification, the Host and the Sandbox MUST have different
|
||||
// origins.
|
||||
const inner = document.createElement("iframe");
|
||||
inner.style = "width:100%; height:100%; border:none;";
|
||||
inner.setAttribute("sandbox", "allow-scripts allow-same-origin allow-forms");
|
||||
document.body.appendChild(inner);
|
||||
|
||||
const RESOURCE_READY_NOTIFICATION: McpUiSandboxResourceReadyNotification["method"] =
|
||||
"ui/notifications/sandbox-resource-ready";
|
||||
const PROXY_READY_NOTIFICATION: McpUiSandboxProxyReadyNotification["method"] =
|
||||
"ui/notifications/sandbox-proxy-ready";
|
||||
|
||||
// Message relay: This Sandbox (outer iframe) acts as a bidirectional bridge,
|
||||
// forwarding messages between:
|
||||
//
|
||||
// Host (parent window) ↔ Sandbox (outer frame) ↔ Guest UI (inner iframe)
|
||||
//
|
||||
// Reason: the parent window and inner iframe have different origins and can't
|
||||
// communicate directly, so the outer iframe forwards messages in both
|
||||
// directions to connect them.
|
||||
//
|
||||
// Special case: The "ui/notifications/sandbox-proxy-ready" message is
|
||||
// intercepted here (not relayed) because the Sandbox uses it to configure and
|
||||
// load the inner iframe with the Guest UI HTML content.
|
||||
window.addEventListener("message", async (event) => {
|
||||
if (event.source === window.parent) {
|
||||
// NOTE: In production you'll also want to validate `event.origin` against
|
||||
// your Host domain.
|
||||
if (event.data && event.data.method === RESOURCE_READY_NOTIFICATION) {
|
||||
const { html, sandbox } = event.data.params;
|
||||
if (typeof sandbox === "string") {
|
||||
inner.setAttribute("sandbox", sandbox);
|
||||
}
|
||||
if (typeof html === "string") {
|
||||
inner.srcdoc = html;
|
||||
}
|
||||
} else {
|
||||
if (inner && inner.contentWindow) {
|
||||
inner.contentWindow.postMessage(event.data, "*");
|
||||
}
|
||||
}
|
||||
} else if (event.source === inner.contentWindow) {
|
||||
// Relay messages from inner frame to parent window.
|
||||
window.parent.postMessage(event.data, "*");
|
||||
}
|
||||
});
|
||||
|
||||
// Notify the Host that the Sandbox is ready to receive Guest UI HTML.
|
||||
window.parent.postMessage({
|
||||
jsonrpc: "2.0",
|
||||
method: PROXY_READY_NOTIFICATION,
|
||||
params: {},
|
||||
}, "*");
|
||||
1
examples/basic-host/src/vite-env.d.ts
vendored
Normal file
1
examples/basic-host/src/vite-env.d.ts
vendored
Normal file
|
|
@ -0,0 +1 @@
|
|||
/// <reference types="vite/client" />
|
||||
Loading…
Reference in a new issue