Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,7 @@ web_modules/

# PNPM
.pnpm-store/
.pnpm-home/

# TypeScript cache
*.tsbuildinfo
Expand Down
2 changes: 2 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -98,6 +98,8 @@ It accepts the following props:
- **`iframeProps`**: Optional props passed to the iframe element
- **`iframeRenderData`**: Optional `Record<string, unknown>` to pass data to the iframe upon rendering. This enables advanced use cases where the parent application needs to provide initial state or configuration to the sandboxed iframe content.
- **`autoResizeIframe`**: Optional `boolean | { width?: boolean; height?: boolean }` to automatically resize the iframe to the size of the content.
- **`mcp`**: Optional MCP invocation context forwarded to HTML resources (e.g., `toolInput`, `toolOutput`, `toolName`, `toolResponseMetadata`). `toolOutput` values are injected into supported runtimes (like Skybridge) via the widget config and are not merged into `iframeRenderData`.
- **`host`**: Optional host context (e.g., `theme`, `locale`, `userAgent`, `model`, `displayMode`, `maxHeight`, `safeArea`, `capabilities`) exposed to sandboxed HTML content. `capabilities` defaults to `{ hover: true, touch: false }` when unspecified.
- **`remoteDomProps`**: Optional props for the internal `<RemoteDOMResourceRenderer>`
- **`library`**: Optional component library for Remote DOM resources (defaults to `basicComponentLibrary`)
- **`remoteElements`**: remote element definitions for Remote DOM resources.
Expand Down
6 changes: 6 additions & 0 deletions docs/src/guide/client/html-resource.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,13 +6,16 @@ The `<HTMLResourceRenderer />` component is an internal component used by `<UIRe

```typescript
import type { Resource } from '@modelcontextprotocol/sdk/types';
import type { MCPProps, HostProps } from '@mcp-ui/client';

export interface HTMLResourceRendererProps {
resource: Partial<Resource>;
onUIAction?: (result: UIActionResult) => Promise<any>;
style?: React.CSSProperties;
proxy?: string;
iframeRenderData?: Record<string, unknown>;
mcp?: MCPProps;
host?: HostProps;
autoResizeIframe?: boolean | { width?: boolean; height?: boolean };
sandboxPermissions?: string;
iframeProps?: Omit<React.HTMLAttributes<HTMLIFrameElement>, 'src' | 'srcDoc' | 'ref' | 'style'>;
Expand Down Expand Up @@ -40,6 +43,9 @@ The component accepts the following props:
- **`style`**: (Optional) Custom styles for the iframe.
- **`proxy`**: (Optional) A URL to a proxy script. This is useful for hosts with a strict Content Security Policy (CSP). When provided, external URLs will be rendered in a nested iframe hosted at this URL. For example, if `proxy` is `https://my-proxy.com/`, the final URL will be `https://my-proxy.com/?url=<encoded_original_url>`. For your convenience, mcp-ui hosts a proxy script at `https://proxy.mcpui.dev`, which you can use as a the prop value without any setup (see `examples/external-url-demo`).
- **`iframeProps`**: (Optional) Custom props for the iframe.
- **`iframeRenderData`**: (Optional) Additional data merged into the render payload forwarded to the iframe. This data combines with resource metadata but remains separate from `mcp.toolOutput`. When `mcp.toolOutput` is provided, supported runtimes (like Skybridge) treat that context as the iframe's initial render payload, overriding any metadata-derived data instead of merging with it.
- **`mcp`**: (Optional) MCP invocation context forwarded to HTML resources (e.g., `toolInput`, `toolOutput`, `toolName`, `toolResponseMetadata`). These values are exposed to supported runtimes such as Skybridge.
- **`host`**: (Optional) Host configuration forwarded to the sandboxed HTML (supports `theme`, `locale`, `userAgent`, `model`, `displayMode`, `maxHeight`, `safeArea`, and `capabilities`). When omitted, defaults are applied—`capabilities` defaults to `{ hover: true, touch: false }`.
- **`autoResizeIframe`**: (Optional) When enabled, the iframe will automatically resize based on messages from the iframe's content. This prop can be a boolean (to enable both width and height resizing) or an object (`{width?: boolean, height?: boolean}`) to control dimensions independently.
- **`sandboxPermissions`**: (Optional) Additional iframe sandbox permissions to add to the defaults. These are merged with:
- External URLs (`text/uri-list`): `'allow-scripts allow-same-origin'`
Expand Down
7 changes: 6 additions & 1 deletion docs/src/guide/client/resource-renderer.md
Original file line number Diff line number Diff line change
Expand Up @@ -42,13 +42,16 @@ The `UIResourceRenderer` automatically detects and uses metadata from resources

```typescript
import type { Resource } from '@modelcontextprotocol/sdk/types';
import type { MCPProps, HostProps } from '@mcp-ui/client';

interface UIResourceRendererProps {
resource: Partial<Resource>;
onUIAction?: (result: UIActionResult) => Promise<unknown>;
supportedContentTypes?: ResourceContentType[];
htmlProps?: Omit<HTMLResourceRendererProps, 'resource' | 'onUIAction'>;
htmlProps?: Omit<HTMLResourceRendererProps, 'resource' | 'onUIAction' | 'mcp' | 'host'>;
remoteDomProps?: Omit<RemoteDOMResourceProps, 'resource' | 'onUIAction'>;
mcp?: MCPProps;
host?: HostProps;
}
```

Expand Down Expand Up @@ -76,6 +79,8 @@ interface UIResourceRendererProps {
- **`ref`**: Optional React ref to access the underlying iframe element
- **`iframeRenderData`**: Optional `Record<string, unknown>` to pass data to the iframe upon rendering. This enables advanced use cases where the parent application needs to provide initial state or configuration to the sandboxed iframe content.
- **`autoResizeIframe`**: Optional `boolean | { width?: boolean; height?: boolean }` to automatically resize the iframe to the size of the content.
- **`mcp`**: Optional MCP invocation context forwarded to HTML resources (e.g., `toolInput`, `toolOutput`, `toolName`, `toolResponseMetadata`). The data is exposed to supported runtimes (like Skybridge) but is **not** merged into the iframe render payload. These can also be provided via `htmlProps` for HTML-only overrides.
- **`host`**: Optional host context for HTML resources (e.g., `theme`, `locale`, `userAgent`, `model`, `displayMode`, `maxHeight`, `safeArea`, `capabilities`). When unspecified, sensible defaults are used (`capabilities` defaults to `{ hover: true, touch: false }`). Like `mcp`, these can be supplied in `htmlProps` to scope them to HTML resources.
- **`remoteDomProps`**: Optional props for the `<RemoteDOMResourceRenderer>`
- **`library`**: Optional component library for Remote DOM resources (defaults to `basicComponentLibrary`)
- **`remoteElements`**: Optional remote element definitions for Remote DOM resources. REQUIRED for Remote DOM snippets.
Expand Down
9 changes: 5 additions & 4 deletions pnpm-workspace.yaml
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
packages:
# all packages in subdirs of sdks/typescript/
- 'sdks/typescript/*'
- 'examples/*'
- 'docs'
- sdks/typescript/*
- examples/*
- docs
onlyBuiltDependencies:
- esbuild
2 changes: 2 additions & 0 deletions sdks/typescript/client/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -98,6 +98,8 @@ It accepts the following props:
- **`iframeProps`**: Optional props passed to the iframe element
- **`iframeRenderData`**: Optional `Record<string, unknown>` to pass data to the iframe upon rendering. This enables advanced use cases where the parent application needs to provide initial state or configuration to the sandboxed iframe content.
- **`autoResizeIframe`**: Optional `boolean | { width?: boolean; height?: boolean }` to automatically resize the iframe to the size of the content.
- **`mcp`**: Optional MCP invocation context forwarded to HTML resources (e.g., `toolInput`, `toolOutput`, `toolName`, `toolResponseMetadata`).
- **`host`**: Optional host context (e.g., `theme`, `locale`, `userAgent`, `model`, `displayMode`, `maxHeight`, `safeArea`, `capabilities`) exposed to sandboxed HTML content. Both context props can also be supplied via `htmlProps` when you need HTML-specific overrides. `capabilities` defaults to `{ hover: true, touch: false }` when unspecified.
- **`remoteDomProps`**: Optional props for the internal `<RemoteDOMResourceRenderer>`
- **`library`**: Optional component library for Remote DOM resources (defaults to `basicComponentLibrary`)
- **`remoteElements`**: remote element definitions for Remote DOM resources.
Expand Down
35 changes: 35 additions & 0 deletions sdks/typescript/client/scripts/bundle-open-ai-script.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
import { build } from 'esbuild';
import { writeFileSync } from 'fs';
import { fileURLToPath } from 'url';
import { dirname, join } from 'path';

const __filename = fileURLToPath(import.meta.url);
const __dirname = dirname(__filename);

try {
const result = await build({
entryPoints: [join(__dirname, '../../client/src/adapters/appssdk/open-ai-runtime-script.ts')],
bundle: true,
write: false,
format: 'iife',
platform: 'browser',
target: 'es2020',
minify: false,
});

const bundledCode = result.outputFiles[0].text;
const serializedCode = JSON.stringify(bundledCode);

const outputContent = `// This file is auto-generated by scripts/bundle-open-ai-script.js
// Do not edit directly - modify client/src/adapters/appssdk/open-ai-runtime-script.ts instead

export const API_RUNTIME_SCRIPT = ${serializedCode};
`;

const outputTsPath = join(__dirname, '../../client/src/adapters/appssdk/open-ai-runtime-script.bundled.ts');
writeFileSync(outputTsPath, outputContent);
console.log('✅ Successfully bundled API runtime script');
} catch (error) {
console.error('❌ Failed to bundle API runtime script:', error);
process.exit(1);
}
45 changes: 27 additions & 18 deletions sdks/typescript/client/scripts/proxy/index.html
Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@idosal @liady this is the main change to support the double iframe exactly the same way as chatGTP.

also I think line 6 the meta CSP is not required, I had to remove it since the CSP can be set on the actual remote html and needs to be super relaxed otherwise nothing load

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@idosal you recently added the double proxy. what client was that for? I don't think this is a breaking change since it is the code clients need to add to their CDN anyway but curious if you tested the previous implementation

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It's not used in production yet, so we can change it.

Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,6 @@
<html>
<head>
<meta charset="utf-8" />
<!-- Permissive CSP so nested content is not constrained by host CSP -->
<meta http-equiv="Content-Security-Policy" content="default-src 'self'; script-src 'self' 'wasm-unsafe-eval' 'unsafe-inline' 'unsafe-eval' blob: https://cdn.tailwindcss.com https://cdn.jsdelivr.net https://unpkg.com; style-src * 'unsafe-inline'; connect-src *; frame-src 'none'; base-uri 'self'; upgrade-insecure-requests;" />
<title>MCP-UI Proxy</title>
<style>
html,
Expand Down Expand Up @@ -47,27 +45,38 @@
if (contentType === 'rawhtml') {
// Double-iframe raw HTML mode (HTML sent via postMessage)
const inner = document.createElement('iframe');

inner.id = "root";
let pendingHtml = null;
const renderHtmlInIframe = (markup) => {
const doc = inner.contentDocument || inner.contentWindow?.document;
if (!doc) return false;
try {
doc.open();
doc.write(markup);
doc.close();
return true;
} catch (error) {
return false;
}
};
inner.addEventListener('load', () => {
if (pendingHtml !== null && renderHtmlInIframe(pendingHtml)) {
pendingHtml = null;
}
});
inner.style = 'width:100%; height:100%; border:none;';
// sandbox will be set from postMessage payload; default minimal before html arrives
inner.setAttribute('sandbox', 'allow-scripts');
inner.src = 'about:blank';
document.body.appendChild(inner);

// Wait for HTML content from parent
window.addEventListener('message', (event) => {
if (event.source === window.parent) {
if (event.data && event.data.type === 'ui-html-content') {
const payload = event.data.payload || {};
const html = payload.html;
const sandbox = payload.sandbox;
if (typeof sandbox === 'string') {
inner.setAttribute('sandbox', sandbox);
}
if (typeof html === 'string') {
inner.srcdoc = html;
Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

the key was to use document.write() instead of srcdoc.

srcdoc changes the root url and breaks client side navigation.

inner.src: 'about:blank'

and document.write() do the trick

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Great catch!

}
} else {
if (inner && inner.contentWindow) {
inner.contentWindow.postMessage(event.data, '*');
if (event.source === window.parent && event.data && event.data.type === 'ui-html-content') {
const payload = event.data.payload || {};
const html = payload.html;
if (typeof html === 'string') {
if (!renderHtmlInIframe(html)) {
pendingHtml = html;
}
}
} else if (event.source === inner.contentWindow) {
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
// This file is auto-generated by scripts/bundle-open-ai-script.js
// Do not edit directly - modify client/src/adapters/appssdk/open-ai-runtime-script.ts instead

export const API_RUNTIME_SCRIPT = "\"use strict\";\n(() => {\n // src/adapters/appssdk/open-ai-runtime-script.ts\n var DEFAULT_SAFE_AREA = {\n insets: {\n top: 0,\n bottom: 0,\n left: 0,\n right: 0\n }\n };\n var DEFAULT_CAPABILITIES = {\n hover: true,\n touch: false\n };\n var TOOL_CALL_TIMEOUT_MS = 3e4;\n (function initializeOpenAIWidget() {\n const globalWindow = window;\n const config = globalWindow.__MCP_WIDGET_CONFIG__;\n if (!config || typeof config !== \"object\" || config.widgetStateKey == null) {\n console.warn(\"[OpenAI Widget] Missing widget configuration. Skipping initialization.\");\n return;\n }\n const {\n widgetStateKey,\n toolInput = null,\n toolOutput = null,\n toolResponseMetadata = null,\n toolName = null,\n theme,\n locale,\n userAgent = null,\n model = null,\n displayMode,\n maxHeight,\n safeArea,\n capabilities\n } = config;\n const resolvedDisplayMode = typeof displayMode === \"string\" ? displayMode : \"inline\";\n const resolvedMaxHeight = typeof maxHeight === \"number\" ? maxHeight : 600;\n const resolvedTheme = typeof theme === \"string\" ? theme : \"light\";\n const resolvedLocale = typeof locale === \"string\" ? locale : \"en-US\";\n const resolvedSafeArea = safeArea && typeof safeArea === \"object\" ? safeArea : DEFAULT_SAFE_AREA;\n const mergedCapabilities = {\n ...DEFAULT_CAPABILITIES,\n ...typeof capabilities === \"object\" && capabilities !== null ? capabilities : {}\n };\n const openaiAPI = {\n toolInput,\n toolOutput,\n toolResponseMetadata,\n toolName,\n displayMode: resolvedDisplayMode,\n maxHeight: resolvedMaxHeight,\n theme: resolvedTheme,\n locale: resolvedLocale,\n safeArea: resolvedSafeArea,\n userAgent,\n capabilities: mergedCapabilities,\n model,\n widgetState: null,\n async setWidgetState(state) {\n this.widgetState = state;\n try {\n localStorage.setItem(widgetStateKey, JSON.stringify(state));\n } catch (err) {\n console.error(\"[OpenAI Widget] Failed to save widget state:\", err);\n }\n window.parent.postMessage(\n {\n type: \"openai:setWidgetState\",\n state\n },\n \"*\"\n );\n },\n async callTool(tool, params = {}) {\n return new Promise((resolve, reject) => {\n const requestId = `tool_${Date.now()}_${Math.random()}`;\n const handler = (event) => {\n const data = event.data;\n if (data?.type === \"openai:callTool:response\" && data?.requestId === requestId) {\n window.removeEventListener(\"message\", handler);\n if (data.error) {\n reject(new Error(data.error));\n } else {\n resolve(data.result);\n }\n }\n };\n window.addEventListener(\"message\", handler);\n window.parent.postMessage(\n {\n type: \"openai:callTool\",\n requestId,\n toolName: tool,\n params\n },\n \"*\"\n );\n setTimeout(() => {\n window.removeEventListener(\"message\", handler);\n reject(new Error(\"Tool call timeout\"));\n }, TOOL_CALL_TIMEOUT_MS);\n });\n },\n async sendFollowupTurn(message) {\n const payload = typeof message === \"string\" ? { prompt: message } : message;\n const value = payload?.prompt ?? payload;\n window.parent.postMessage(\n {\n type: \"openai:sendFollowup\",\n message: value\n },\n \"*\"\n );\n },\n async requestDisplayMode(options = {}) {\n const mode = typeof options.mode === \"string\" ? options.mode : this.displayMode || \"inline\";\n this.displayMode = mode;\n window.parent.postMessage(\n {\n type: \"openai:requestDisplayMode\",\n mode\n },\n \"*\"\n );\n return { mode };\n },\n async sendFollowUpMessage(args) {\n const prompt = typeof args === \"string\" ? args : args?.prompt ?? \"\";\n await this.sendFollowupTurn(prompt);\n },\n async openExternal(options) {\n const href = typeof options === \"string\" ? options : options?.href;\n if (!href) {\n throw new Error(\"href is required for openExternal\");\n }\n window.parent.postMessage(\n {\n type: \"openai:openExternal\",\n href\n },\n \"*\"\n );\n window.open(href, \"_blank\", \"noopener,noreferrer\");\n }\n };\n if (openaiAPI.userAgent == null && typeof navigator !== \"undefined\") {\n try {\n openaiAPI.userAgent = navigator.userAgent || \"\";\n } catch {\n openaiAPI.userAgent = \"\";\n }\n }\n Object.defineProperty(window, \"openai\", {\n value: openaiAPI,\n writable: false,\n configurable: false,\n enumerable: true\n });\n Object.defineProperty(window, \"webplus\", {\n value: openaiAPI,\n writable: false,\n configurable: false,\n enumerable: true\n });\n setTimeout(() => {\n try {\n const globalsEvent = new CustomEvent(\"webplus:set_globals\", {\n detail: {\n globals: {\n displayMode: openaiAPI.displayMode,\n maxHeight: openaiAPI.maxHeight,\n theme: openaiAPI.theme,\n locale: openaiAPI.locale,\n safeArea: openaiAPI.safeArea,\n userAgent: openaiAPI.userAgent,\n capabilities: openaiAPI.capabilities\n }\n }\n });\n window.dispatchEvent(globalsEvent);\n } catch {\n }\n }, 0);\n setTimeout(() => {\n try {\n const stored = localStorage.getItem(widgetStateKey);\n if (stored) {\n openaiAPI.widgetState = JSON.parse(stored);\n }\n } catch {\n }\n }, 0);\n try {\n delete globalWindow.__MCP_WIDGET_CONFIG__;\n } catch {\n globalWindow.__MCP_WIDGET_CONFIG__ = void 0;\n }\n })();\n})();\n";
Loading