Skip to content
Merged
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
3 changes: 3 additions & 0 deletions docs/src/guide/client/html-resource.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ export interface HTMLResourceRendererProps {
resource: Partial<Resource>;
onUIAction?: (result: UIActionResult) => Promise<any>;
style?: React.CSSProperties;
proxy?: string;
iframeProps?: Omit<React.HTMLAttributes<HTMLIFrameElement>, 'src' | 'srcDoc' | 'ref' | 'style'>;
}
```
Expand All @@ -28,6 +29,7 @@ The component accepts the following props:
```
If you don't provide a callback for a specific type, the default handler will be used.
- **`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.

## How It Works
Expand All @@ -41,6 +43,7 @@ The component accepts the following props:
- Ignores comment lines starting with `#` and empty lines
- If using `blob`, it decodes it from Base64.
- Renders an `<iframe>` with its `src` set to the first valid URL.
- If a valid URL is passed to the `proxy` prop, it will be used as the source for the iframe, which then renders the external URL in a nested iframe. For example, if `proxy` is `https://my-proxy.com/`, the final URL will be `https://my-proxy.com/?url=<encoded_original_url>`.
- Sandbox: `allow-scripts allow-same-origin` (needed for some external sites; be mindful of security).
- For resources with `mimeType: 'text/html'`:
- Expects `resource.text` or `resource.blob` to contain HTML.
Expand Down
1 change: 1 addition & 0 deletions docs/src/guide/client/resource-renderer.md
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,7 @@ interface UIResourceRendererProps {
- **`supportedContentTypes`**: Optional array to restrict which content types are allowed (`['rawHtml', 'externalUrl', 'remoteDom']`)
- **`htmlProps`**: Optional props for the `<HTMLResourceRenderer>`
- **`style`**: Optional custom styles for iframe-based resources
- **`proxy`**: Optional. A URL to a proxy script, which is useful for hosts with a strict Content Security Policy (CSP). When provided, external URLs (`text/uri-list`) 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`). IMPORTANT: for security reasons, you MUST NOT host the proxy script on the host's origin.
- **`iframeProps`**: Optional props passed to iframe elements (for HTML/URL resources)
- **`remoteDomProps`**: Optional props for the `<RemoteDOMResourceRenderer>`
- **`library`**: Optional component library for Remote DOM resources (defaults to `basicComponentLibrary`)
Expand Down
16 changes: 16 additions & 0 deletions examples/external-url-demo/index.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta
http-equiv="Content-Security-Policy"
content="frame-src 'self' https://proxy.mcpui.dev;"
/>
<title>MCP-UI Proxy Demo</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>
29 changes: 29 additions & 0 deletions examples/external-url-demo/package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
{
"name": "external-url-demo",
"private": true,
"version": "0.0.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "tsc -b && vite build",
"lint": "eslint . --ext ts,tsx --report-unused-disable-directives --max-warnings 0",
"preview": "vite preview"
},
"dependencies": {
"@mcp-ui/client": "workspace:*",
"react": "^18.3.1",
"react-dom": "^18.3.1"
},
"devDependencies": {
"@types/react": "^18.3.23",
"@types/react-dom": "^18.3.7",
"@typescript-eslint/eslint-plugin": "^7.18.0",
"@typescript-eslint/parser": "^7.18.0",
"@vitejs/plugin-react": "^4.6.0",
"eslint": "^9.29.0",
"eslint-plugin-react-hooks": "^5.2.0",
"eslint-plugin-react-refresh": "0.4.7",
"typescript": "~5.8.3",
"vite": "^6.3.5"
}
}
43 changes: 43 additions & 0 deletions examples/external-url-demo/src/App.css
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
max-width: 1200px;
margin: 0 auto;
padding: 2rem;
line-height: 1.6;
}
.demo-section {
margin: 2rem 0;
padding: 1rem;
border: 1px solid #ddd;
border-radius: 8px;
}
.demo-section h2 {
margin-top: 0;
color: #333;
}
iframe {
width: 100%;
min-height: 400px;
border: 1px solid #ccc;
border-radius: 4px;
}
.code {
background: #f5f5f5;
padding: 1rem;
border-radius: 4px;
font-family: 'Courier New', monospace;
font-size: 0.9em;
margin: 1rem 0;
}
.toggle {
background: #007bff;
color: white;
border: none;
padding: 0.5rem 1rem;
border-radius: 4px;
cursor: pointer;
margin: 1rem 0;
}
.toggle:hover {
background: #0056b3;
}
75 changes: 75 additions & 0 deletions examples/external-url-demo/src/App.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,75 @@
import { useState } from 'react';
import './App.css';
import { UIResourceRenderer } from '@mcp-ui/client';

function App() {
const [useProxy, setUseProxy] = useState(false);
const resource = {
mimeType: 'text/uri-list',
text: 'https://example.com',
};
const proxy = 'https://proxy.mcpui.dev/';

return (
<div>
<h1>MCP-UI Proxy Demo</h1>
<p>This demo shows how the proxy functionality works for external URLs in MCP-UI.</p>
<p>
<strong>CSP Simulation:</strong> This page includes a Content Security Policy (
<code>frame-src 'self' https://proxy.mcpui.dev;</code>) that only allows iframes from this
origin and <code>https://proxy.mcpui.dev</code>. This demonstrates how the{' '}
<code>proxy</code> prop can be used to display external content on hosts with strict
security policies.
</p>
<p>
<code>proxy.mcpui.dev</code> hosts a simple script that renders the provided URL in a nested
iframe. Hosts can use this script or host their own to achieve the same result.
</p>

<div className="demo-section">
<h2>Direct URL (No Proxy)</h2>
<p>
This iframe attempts to load an external URL directly.{' '}
<strong>It should be blocked by the browser's Content Security Policy.</strong>
</p>
<div className="code">
Resource: {`{ mimeType: 'text/uri-list', text: 'https://example.com' }`}
</div>
<UIResourceRenderer resource={{ mimeType: 'text/uri-list', text: 'https://example.com' }} />
</div>

<div className="demo-section">
<h2>Proxied URL</h2>
<p>This iframe loads the external URL through the proxy:</p>
<div className="code">
Resource: {`{ mimeType: 'text/uri-list', text: 'https://example.com' }`}
<br />
Proxy: https://proxy.mcpui.dev/
<br />
Final URL: {`${proxy}?url=${encodeURIComponent(resource.text)}`}
</div>
<UIResourceRenderer
resource={resource}
htmlProps={{ proxy, style: { width: '500px', height: '500px' } }}
/>
</div>

<div className="demo-section">
<h2>Interactive Demo</h2>
<p>Toggle between direct and proxied loading:</p>
<button className="toggle" onClick={() => setUseProxy(!useProxy)}>
Toggle Proxy
</button>
<div id="demo-container">
<UIResourceRenderer resource={resource} htmlProps={useProxy ? { proxy } : {}} />
</div>
<div className="code" id="url-display">
Current URL:{' '}
{useProxy ? `${proxy}?url=${encodeURIComponent(resource.text)}` : resource.text}
</div>
</div>
</div>
);
}

export default App;
12 changes: 12 additions & 0 deletions examples/external-url-demo/src/index.css
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
body {
margin: 0;
font-family:
-apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen', 'Ubuntu', 'Cantarell',
'Fira Sans', 'Droid Sans', 'Helvetica Neue', sans-serif;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}

code {
font-family: source-code-pro, Menlo, Monaco, Consolas, 'Courier New', monospace;
}
10 changes: 10 additions & 0 deletions examples/external-url-demo/src/main.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
import React from 'react';
import ReactDOM from 'react-dom/client';
import App from './App.tsx';
import './index.css';

ReactDOM.createRoot(document.getElementById('root')!).render(
<React.StrictMode>
<App />
</React.StrictMode>,
);
25 changes: 25 additions & 0 deletions examples/external-url-demo/tsconfig.app.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
{
"compilerOptions": {
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo",
"target": "ES2020",
"useDefineForClassFields": true,
"lib": ["ES2020", "DOM", "DOM.Iterable"],
"module": "ESNext",
"skipLibCheck": true,

/* Bundler mode */
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"verbatimModuleSyntax": true,
"moduleDetection": "force",
"noEmit": true,
"jsx": "react-jsx",

/* Linting */
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"noFallthroughCasesInSwitch": true
},
"include": ["src"]
}
4 changes: 4 additions & 0 deletions examples/external-url-demo/tsconfig.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
{
"files": [],
"references": [{ "path": "./tsconfig.app.json" }, { "path": "./tsconfig.node.json" }]
}
11 changes: 11 additions & 0 deletions examples/external-url-demo/tsconfig.node.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
{
"compilerOptions": {
"composite": true,
"skipLibCheck": true,
"module": "ESNext",
"moduleResolution": "bundler",
"allowSyntheticDefaultImports": true,
"strict": true
},
"include": ["vite.config.ts"]
}
7 changes: 7 additions & 0 deletions examples/external-url-demo/vite.config.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
import { defineConfig } from 'vite';
import react from '@vitejs/plugin-react';

// https://vitejs.dev/config/
export default defineConfig({
plugins: [react()],
});
56 changes: 56 additions & 0 deletions packages/client/scripts/proxy/index.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
<!doctype html>
<html>
<head>
<meta charset="utf-8" />
<title>MCP-UI Proxy</title>
<style>
html,
body {
margin: 0;
height: 100vh;
width: 100vw;
}
body {
display: flex;
flex-direction: column;
}
* {
box-sizing: border-box;
}
iframe {
background-color: transparent;
border: 0px none transparent;
padding: 0px;
overflow: hidden;
flex-grow: 1;
}
</style>
</head>
<body>
<script>
const target = new URLSearchParams(location.search).get('url');

// Validate that the URL is a valid HTTP or HTTPS URL
function isValidHttpUrl(string) {
try {
const url = new URL(string);
return url.protocol === 'http:' || url.protocol === 'https:';
} catch (_) {
return false;
}
}

if (!target) {
document.body.textContent = 'Error: missing url parameter';
} else if (!isValidHttpUrl(target)) {
document.body.textContent = 'Error: invalid URL. Only HTTP and HTTPS URLs are allowed.';
} else {
const inner = document.createElement('iframe');
inner.src = target;
inner.style = 'width:100%; height:100%; border:none;';
inner.sandbox = 'allow-same-origin allow-scripts';
document.body.appendChild(inner);
}
</script>
</body>
</html>
6 changes: 4 additions & 2 deletions packages/client/src/components/HTMLResourceRenderer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ export type HTMLResourceRendererProps = {
resource: Partial<Resource>;
onUIAction?: (result: UIActionResult) => Promise<unknown>;
style?: React.CSSProperties;
proxy?: string;
iframeProps?: Omit<React.HTMLAttributes<HTMLIFrameElement>, 'src' | 'srcDoc' | 'style'> & {
ref?: React.RefObject<HTMLIFrameElement>;
};
Expand All @@ -16,14 +17,15 @@ export const HTMLResourceRenderer = ({
resource,
onUIAction,
style,
proxy,
iframeProps,
}: HTMLResourceRendererProps) => {
const iframeRef = useRef<HTMLIFrameElement | null>(null);
useImperativeHandle(iframeProps?.ref, () => iframeRef.current as HTMLIFrameElement);

const { error, iframeSrc, iframeRenderMode, htmlString } = useMemo(
() => processHTMLResource(resource),
[resource],
() => processHTMLResource(resource, proxy),
[resource, proxy],
);

useEffect(() => {
Expand Down
8 changes: 7 additions & 1 deletion packages/client/src/components/UIResourceRenderer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -37,7 +37,13 @@ export const UIResourceRenderer = (props: UIResourceRendererProps) => {
}

switch (contentType) {
case 'rawHtml':
case 'rawHtml': {
// eslint-disable-next-line @typescript-eslint/no-unused-vars
const { proxy, ...otherHtmlProps } = htmlProps || {};
return (
<HTMLResourceRenderer resource={resource} onUIAction={onUIAction} {...otherHtmlProps} />
);
}
case 'externalUrl': {
return <HTMLResourceRenderer resource={resource} onUIAction={onUIAction} {...htmlProps} />;
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -291,7 +291,9 @@ describe('HTMLResource iframe communication', () => {

it('should pass ref to iframe', () => {
const ref = React.createRef<HTMLIFrameElement>();
render(<HTMLResourceRenderer resource={mockResourceBaseForUIActionTests} iframeProps={{ ref }} />);
render(
<HTMLResourceRenderer resource={mockResourceBaseForUIActionTests} iframeProps={{ ref }} />,
);
expect(ref.current).toBeInTheDocument();
});

Expand Down
Loading