From 0a5d6314559be4ddfb356eb4fe0c0ce7514ecd48 Mon Sep 17 00:00:00 2001 From: Ido Salomon Date: Fri, 18 Jul 2025 01:25:08 +0300 Subject: [PATCH 1/8] feat: add proxy option to externalUrl --- docs/src/guide/client/html-resource.md | 4 +- docs/src/guide/client/resource-renderer.md | 1 + examples/proxy-demo.html | 137 ++++++++++++++++++ packages/client/scripts/proxy.html | 36 +++++ .../src/components/HTMLResourceRenderer.tsx | 6 +- .../src/components/UIResourceRenderer.tsx | 6 +- .../__tests__/UIResourceRenderer.test.tsx | 14 ++ .../utils/__tests__/processResource.test.ts | 55 +++++++ packages/client/src/utils/processResource.ts | 19 ++- 9 files changed, 272 insertions(+), 6 deletions(-) create mode 100644 examples/proxy-demo.html create mode 100644 packages/client/scripts/proxy.html diff --git a/docs/src/guide/client/html-resource.md b/docs/src/guide/client/html-resource.md index 1fbe34c8..7869f188 100644 --- a/docs/src/guide/client/html-resource.md +++ b/docs/src/guide/client/html-resource.md @@ -11,6 +11,7 @@ export interface HTMLResourceRendererProps { resource: Partial; onUIAction?: (result: UIActionResult) => Promise; style?: React.CSSProperties; + proxy?: string; iframeProps?: Omit, 'src' | 'srcDoc' | 'ref' | 'style'>; } ``` @@ -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 service. When provided, external URLs will be appended to this URL. For example, if `proxy` is `https://my-proxy.com/`, the final URL will be `https://my-proxy.com/?url=`. - **`iframeProps`**: (Optional) Custom props for the iframe. ## How It Works @@ -41,7 +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 ` + + +
+

Proxied URL

+

This iframe loads the external URL through the proxy:

+
+ Resource: { mimeType: 'text/uri-list', text: 'https://example.com' }
+ Proxy: https://proxy.mcpui.dev/
+ Final URL: https://proxy.mcpui.dev/?url=https%3A%2F%2Fexample.com +
+ +
+ +
+

Interactive Demo

+

Toggle between direct and proxied loading:

+ +
+ +
+
+ Current URL: https://example.com +
+
+ +
+

Usage in React

+

Here's how you would use the proxy functionality in a React component:

+
+{`import { UIResourceRenderer } from '@mcp-ui/client'; + +function MyComponent() { + const resource = { + mimeType: 'text/uri-list', + text: 'https://example.com' + }; + + return ( +
+ {/* Without proxy */} + + + {/* With proxy */} + +
+ ); +}`} +
+
+ + + + \ No newline at end of file diff --git a/packages/client/scripts/proxy.html b/packages/client/scripts/proxy.html new file mode 100644 index 00000000..5ff53aec --- /dev/null +++ b/packages/client/scripts/proxy.html @@ -0,0 +1,36 @@ + + + + + MCP-UI Proxy + + + + + + \ No newline at end of file diff --git a/packages/client/src/components/HTMLResourceRenderer.tsx b/packages/client/src/components/HTMLResourceRenderer.tsx index e68846e1..0a2e7d38 100644 --- a/packages/client/src/components/HTMLResourceRenderer.tsx +++ b/packages/client/src/components/HTMLResourceRenderer.tsx @@ -7,6 +7,7 @@ export type HTMLResourceRendererProps = { resource: Partial; onUIAction?: (result: UIActionResult) => Promise; style?: React.CSSProperties; + proxy?: string; iframeProps?: Omit, 'src' | 'srcDoc' | 'style'> & { ref?: React.RefObject; }; @@ -16,14 +17,15 @@ export const HTMLResourceRenderer = ({ resource, onUIAction, style, + proxy, iframeProps, }: HTMLResourceRendererProps) => { const iframeRef = useRef(null); useImperativeHandle(iframeProps?.ref, () => iframeRef.current as HTMLIFrameElement); const { error, iframeSrc, iframeRenderMode, htmlString } = useMemo( - () => processHTMLResource(resource), - [resource], + () => processHTMLResource(resource, proxy), + [resource, proxy], ); useEffect(() => { diff --git a/packages/client/src/components/UIResourceRenderer.tsx b/packages/client/src/components/UIResourceRenderer.tsx index f2e3ff0a..4c1269aa 100644 --- a/packages/client/src/components/UIResourceRenderer.tsx +++ b/packages/client/src/components/UIResourceRenderer.tsx @@ -37,7 +37,11 @@ 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 ; + } case 'externalUrl': { return ; } diff --git a/packages/client/src/components/__tests__/UIResourceRenderer.test.tsx b/packages/client/src/components/__tests__/UIResourceRenderer.test.tsx index 05e34afe..31a7f5aa 100644 --- a/packages/client/src/components/__tests__/UIResourceRenderer.test.tsx +++ b/packages/client/src/components/__tests__/UIResourceRenderer.test.tsx @@ -77,4 +77,18 @@ describe('', () => { expect(RemoteDOMResourceRenderer).not.toHaveBeenCalled(); expect(HTMLResourceRenderer).toHaveBeenCalledWith({ resource }, {}); }); + + it('should pass proxy prop to HTMLResourceRenderer for external URLs', () => { + const resource = { ...baseResource, mimeType: 'text/uri-list' }; + render(); + expect(screen.getByTestId('html-resource')).toBeInTheDocument(); + expect(HTMLResourceRenderer).toHaveBeenCalledWith({ resource, proxy: 'https://proxy.mcpui.dev/' }, {}); + }); + + it('should not pass proxy prop to HTMLResourceRenderer for HTML content', () => { + const resource = { ...baseResource, mimeType: 'text/html' }; + render(); + expect(screen.getByTestId('html-resource')).toBeInTheDocument(); + expect(HTMLResourceRenderer).toHaveBeenCalledWith({ resource }, {}); + }); }); diff --git a/packages/client/src/utils/__tests__/processResource.test.ts b/packages/client/src/utils/__tests__/processResource.test.ts index c2546478..5b71598e 100644 --- a/packages/client/src/utils/__tests__/processResource.test.ts +++ b/packages/client/src/utils/__tests__/processResource.test.ts @@ -71,4 +71,59 @@ describe('text/uri-list', () => { const result = processHTMLResource(resource); expect(result.error).toBe('No valid URLs found in uri-list content.'); }); + + it('should use proxy when provided for external URLs', () => { + const resource = { + mimeType: 'text/uri-list', + text: 'https://example.com', + }; + const result = processHTMLResource(resource, 'https://proxy.mcpui.dev/'); + expect(result.error).toBeUndefined(); + expect(result.iframeSrc).toBe('https://proxy.mcpui.dev/?url=https%3A%2F%2Fexample.com'); + expect(result.iframeRenderMode).toBe('src'); + }); + + it('should handle proxy with existing query parameters', () => { + const resource = { + mimeType: 'text/uri-list', + text: 'https://example.com', + }; + const result = processHTMLResource(resource, 'https://proxy.mcpui.dev/?a=1&b=2'); + expect(result.error).toBeUndefined(); + expect(result.iframeSrc).toBe('https://proxy.mcpui.dev/?a=1&b=2&url=https%3A%2F%2Fexample.com'); + expect(result.iframeRenderMode).toBe('src'); + }); + + it('should fallback to direct URL if proxy is invalid', () => { + const resource = { + mimeType: 'text/uri-list', + text: 'https://example.com', + }; + const result = processHTMLResource(resource, 'not-a-valid-url'); + expect(result.error).toBeUndefined(); + expect(result.iframeSrc).toBe('https://example.com'); + expect(result.iframeRenderMode).toBe('src'); + }); + + it('should not use proxy when proxy is empty string', () => { + const resource = { + mimeType: 'text/uri-list', + text: 'https://example.com', + }; + const result = processHTMLResource(resource, ''); + expect(result.error).toBeUndefined(); + expect(result.iframeSrc).toBe('https://example.com'); + expect(result.iframeRenderMode).toBe('src'); + }); + + it('should not use proxy when proxy is not provided', () => { + const resource = { + mimeType: 'text/uri-list', + text: 'https://example.com', + }; + const result = processHTMLResource(resource); + expect(result.error).toBeUndefined(); + expect(result.iframeSrc).toBe('https://example.com'); + expect(result.iframeRenderMode).toBe('src'); + }); }); \ No newline at end of file diff --git a/packages/client/src/utils/processResource.ts b/packages/client/src/utils/processResource.ts index 9013411f..188fb540 100644 --- a/packages/client/src/utils/processResource.ts +++ b/packages/client/src/utils/processResource.ts @@ -18,7 +18,7 @@ function isValidHttpUrl(string: string): boolean { return url.protocol === 'http:' || url.protocol === 'https:'; } -export function processHTMLResource(resource: Partial): ProcessResourceResult { +export function processHTMLResource(resource: Partial, proxy?: string): ProcessResourceResult { if (resource.mimeType !== 'text/html' && resource.mimeType !== 'text/uri-list') { return { error: @@ -77,8 +77,23 @@ export function processHTMLResource(resource: Partial): ProcessResourc ); } + const originalUrl = lines[0]; + + if (proxy && proxy.trim() !== '') { + try { + const proxyUrl = new URL(proxy); + proxyUrl.searchParams.set('url', originalUrl); + return { + iframeSrc: proxyUrl.toString(), + iframeRenderMode: 'src', + }; + } catch (e) { + console.error(`Invalid proxy URL provided: "${proxy}". Falling back to direct URL.`, e); + } + } + return { - iframeSrc: lines[0], + iframeSrc: originalUrl, iframeRenderMode: 'src', }; } else if (resource.mimeType === 'text/html') { From 3155932b6353e892ac0c31485d4ddb61e4f00148 Mon Sep 17 00:00:00 2001 From: Ido Salomon Date: Fri, 18 Jul 2025 02:26:51 +0300 Subject: [PATCH 2/8] rename --- packages/client/scripts/{proxy.html => proxy/index.html} | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename packages/client/scripts/{proxy.html => proxy/index.html} (100%) diff --git a/packages/client/scripts/proxy.html b/packages/client/scripts/proxy/index.html similarity index 100% rename from packages/client/scripts/proxy.html rename to packages/client/scripts/proxy/index.html From 6c19784ae2111b1ce7921f59aebeb5aa7b8829f5 Mon Sep 17 00:00:00 2001 From: Ido Salomon Date: Fri, 18 Jul 2025 02:37:48 +0300 Subject: [PATCH 3/8] fix sizing --- packages/client/scripts/proxy/index.html | 86 +++++++++++++++--------- 1 file changed, 53 insertions(+), 33 deletions(-) diff --git a/packages/client/scripts/proxy/index.html b/packages/client/scripts/proxy/index.html index 5ff53aec..5c3d7ea9 100644 --- a/packages/client/scripts/proxy/index.html +++ b/packages/client/scripts/proxy/index.html @@ -1,36 +1,56 @@ - + - - - MCP-UI Proxy - - - - - + 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; + } + + + + + \ No newline at end of file From 4380774d7cc3d9b03c91c12a43c4f1d1742f481a Mon Sep 17 00:00:00 2001 From: Ido Salomon Date: Fri, 18 Jul 2025 03:01:26 +0300 Subject: [PATCH 4/8] add external url --- docs/src/guide/client/html-resource.md | 5 +- docs/src/guide/client/resource-renderer.md | 2 +- examples/external-url-demo/index.html | 13 ++ examples/external-url-demo/package.json | 29 ++++ examples/external-url-demo/src/App.css | 43 ++++++ examples/external-url-demo/src/App.tsx | 55 +++++++ examples/external-url-demo/src/index.css | 13 ++ examples/external-url-demo/src/main.tsx | 10 ++ examples/external-url-demo/tsconfig.app.json | 25 ++++ examples/external-url-demo/tsconfig.json | 4 + examples/external-url-demo/tsconfig.node.json | 11 ++ examples/external-url-demo/vite.config.ts | 7 + examples/proxy-demo.html | 137 ------------------ pnpm-lock.yaml | 52 +++++++ 14 files changed, 266 insertions(+), 140 deletions(-) create mode 100644 examples/external-url-demo/index.html create mode 100644 examples/external-url-demo/package.json create mode 100644 examples/external-url-demo/src/App.css create mode 100644 examples/external-url-demo/src/App.tsx create mode 100644 examples/external-url-demo/src/index.css create mode 100644 examples/external-url-demo/src/main.tsx create mode 100644 examples/external-url-demo/tsconfig.app.json create mode 100644 examples/external-url-demo/tsconfig.json create mode 100644 examples/external-url-demo/tsconfig.node.json create mode 100644 examples/external-url-demo/vite.config.ts delete mode 100644 examples/proxy-demo.html diff --git a/docs/src/guide/client/html-resource.md b/docs/src/guide/client/html-resource.md index 7869f188..9cfbf3ef 100644 --- a/docs/src/guide/client/html-resource.md +++ b/docs/src/guide/client/html-resource.md @@ -29,7 +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 service. When provided, external URLs will be appended to this URL. For example, if `proxy` is `https://my-proxy.com/`, the final URL will be `https://my-proxy.com/?url=`. +- **`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=`. For your convinience, 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 @@ -43,7 +43,8 @@ The component accepts the following props: - Ignores comment lines starting with `#` and empty lines - If using `blob`, it decodes it from Base64. - Renders an ` - - -
-

Proxied URL

-

This iframe loads the external URL through the proxy:

-
- Resource: { mimeType: 'text/uri-list', text: 'https://example.com' }
- Proxy: https://proxy.mcpui.dev/
- Final URL: https://proxy.mcpui.dev/?url=https%3A%2F%2Fexample.com -
- -
- -
-

Interactive Demo

-

Toggle between direct and proxied loading:

- -
- -
-
- Current URL: https://example.com -
-
- -
-

Usage in React

-

Here's how you would use the proxy functionality in a React component:

-
-{`import { UIResourceRenderer } from '@mcp-ui/client'; - -function MyComponent() { - const resource = { - mimeType: 'text/uri-list', - text: 'https://example.com' - }; - - return ( -
- {/* Without proxy */} - - - {/* With proxy */} - -
- ); -}`} -
-
- - - - \ No newline at end of file diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 12980d2f..7c68ad94 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -105,6 +105,49 @@ importers: specifier: ^1.0.4 version: 1.6.1(@types/node@22.15.33)(jsdom@23.2.0)(lightningcss@1.30.1) + examples/external-url-demo: + dependencies: + '@mcp-ui/client': + specifier: workspace:* + version: link:../../packages/client + react: + specifier: ^18.3.1 + version: 18.3.1 + react-dom: + specifier: ^18.3.1 + version: 18.3.1(react@18.3.1) + devDependencies: + '@types/react': + specifier: ^18.3.23 + version: 18.3.23 + '@types/react-dom': + specifier: ^18.3.7 + version: 18.3.7(@types/react@18.3.23) + '@typescript-eslint/eslint-plugin': + specifier: ^7.18.0 + version: 7.18.0(@typescript-eslint/parser@7.18.0(eslint@9.29.0(jiti@2.4.2))(typescript@5.8.3))(eslint@9.29.0(jiti@2.4.2))(typescript@5.8.3) + '@typescript-eslint/parser': + specifier: ^7.18.0 + version: 7.18.0(eslint@9.29.0(jiti@2.4.2))(typescript@5.8.3) + '@vitejs/plugin-react': + specifier: ^4.6.0 + version: 4.6.0(vite@6.3.5(@types/node@22.15.33)(jiti@2.4.2)(lightningcss@1.30.1)) + eslint: + specifier: ^9.29.0 + version: 9.29.0(jiti@2.4.2) + eslint-plugin-react-hooks: + specifier: ^5.2.0 + version: 5.2.0(eslint@9.29.0(jiti@2.4.2)) + eslint-plugin-react-refresh: + specifier: 0.4.7 + version: 0.4.7(eslint@9.29.0(jiti@2.4.2)) + typescript: + specifier: ~5.8.3 + version: 5.8.3 + vite: + specifier: ^6.3.5 + version: 6.3.5(@types/node@22.15.33)(jiti@2.4.2)(lightningcss@1.30.1) + examples/remote-dom-demo: dependencies: '@mcp-ui/client': @@ -4010,6 +4053,11 @@ packages: peerDependencies: eslint: '>=8.40' + eslint-plugin-react-refresh@0.4.7: + resolution: {integrity: sha512-yrj+KInFmwuQS2UQcg1SF83ha1tuHC1jMQbRNyuWtlEzzKRDgAl7L4Yp4NlDUZTZNlWvHEzOtJhMi40R7JxcSw==} + peerDependencies: + eslint: '>=7' + eslint-plugin-react@7.37.5: resolution: {integrity: sha512-Qteup0SqU15kdocexFNAJMvCJEfa2xUKNV4CC1xsVMrIIqEy3SQ/rqyxCWNzfrd3/ldy6HMlD2e0JDVpDg2qIA==} engines: {node: '>=4'} @@ -11395,6 +11443,10 @@ snapshots: dependencies: eslint: 9.29.0(jiti@2.4.2) + eslint-plugin-react-refresh@0.4.7(eslint@9.29.0(jiti@2.4.2)): + dependencies: + eslint: 9.29.0(jiti@2.4.2) + eslint-plugin-react@7.37.5(eslint@8.57.1): dependencies: array-includes: 3.1.9 From 3a3f4b5df8aacefdd81c631e3cdc0292ccc22392 Mon Sep 17 00:00:00 2001 From: Ido Salomon Date: Fri, 18 Jul 2025 03:27:14 +0300 Subject: [PATCH 5/8] fix docs --- docs/src/guide/client/html-resource.md | 2 +- docs/src/guide/client/resource-renderer.md | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/src/guide/client/html-resource.md b/docs/src/guide/client/html-resource.md index 9cfbf3ef..10090977 100644 --- a/docs/src/guide/client/html-resource.md +++ b/docs/src/guide/client/html-resource.md @@ -29,7 +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=`. For your convinience, 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`). +- **`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=`. 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 diff --git a/docs/src/guide/client/resource-renderer.md b/docs/src/guide/client/resource-renderer.md index f6ed8c08..3ae72787 100644 --- a/docs/src/guide/client/resource-renderer.md +++ b/docs/src/guide/client/resource-renderer.md @@ -45,7 +45,7 @@ interface UIResourceRendererProps { - **`supportedContentTypes`**: Optional array to restrict which content types are allowed (`['rawHtml', 'externalUrl', 'remoteDom']`) - **`htmlProps`**: Optional props for the `` - **`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=`. For your convinience, 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`). + - **`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=`. 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 script on the host's origin. - **`iframeProps`**: Optional props passed to iframe elements (for HTML/URL resources) - **`remoteDomProps`**: Optional props for the `` - **`library`**: Optional component library for Remote DOM resources (defaults to `basicComponentLibrary`) From 136d72cd0cdf6b44ecd0390ebac148648835a926 Mon Sep 17 00:00:00 2001 From: Ido Salomon Date: Fri, 18 Jul 2025 03:30:27 +0300 Subject: [PATCH 6/8] security --- packages/client/src/utils/processResource.ts | 17 ++++++++++++----- 1 file changed, 12 insertions(+), 5 deletions(-) diff --git a/packages/client/src/utils/processResource.ts b/packages/client/src/utils/processResource.ts index 188fb540..f32159b9 100644 --- a/packages/client/src/utils/processResource.ts +++ b/packages/client/src/utils/processResource.ts @@ -82,11 +82,18 @@ export function processHTMLResource(resource: Partial, proxy?: string) if (proxy && proxy.trim() !== '') { try { const proxyUrl = new URL(proxy); - proxyUrl.searchParams.set('url', originalUrl); - return { - iframeSrc: proxyUrl.toString(), - iframeRenderMode: 'src', - }; + // The proxy host MUST NOT be the host URL, or the proxy can escape the sandbox + if (typeof window !== 'undefined' && proxyUrl.host === window.location.host) { + console.error( + 'For security, the proxy host must not be the same as the application host. Using original URL instead.', + ); + } else { + proxyUrl.searchParams.set('url', originalUrl); + return { + iframeSrc: proxyUrl.toString(), + iframeRenderMode: 'src', + }; + } } catch (e) { console.error(`Invalid proxy URL provided: "${proxy}". Falling back to direct URL.`, e); } From 401d8b7e47084e3ab7dae61e949ab2413d99025c Mon Sep 17 00:00:00 2001 From: Ido Salomon Date: Fri, 18 Jul 2025 03:34:29 +0300 Subject: [PATCH 7/8] fix docs --- docs/src/guide/client/resource-renderer.md | 2 +- packages/client/src/utils/processResource.ts | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/src/guide/client/resource-renderer.md b/docs/src/guide/client/resource-renderer.md index 3ae72787..a747d85a 100644 --- a/docs/src/guide/client/resource-renderer.md +++ b/docs/src/guide/client/resource-renderer.md @@ -45,7 +45,7 @@ interface UIResourceRendererProps { - **`supportedContentTypes`**: Optional array to restrict which content types are allowed (`['rawHtml', 'externalUrl', 'remoteDom']`) - **`htmlProps`**: Optional props for the `` - **`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=`. 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 script on the host's origin. + - **`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=`. 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 `` - **`library`**: Optional component library for Remote DOM resources (defaults to `basicComponentLibrary`) diff --git a/packages/client/src/utils/processResource.ts b/packages/client/src/utils/processResource.ts index f32159b9..81d6adc9 100644 --- a/packages/client/src/utils/processResource.ts +++ b/packages/client/src/utils/processResource.ts @@ -85,7 +85,7 @@ export function processHTMLResource(resource: Partial, proxy?: string) // The proxy host MUST NOT be the host URL, or the proxy can escape the sandbox if (typeof window !== 'undefined' && proxyUrl.host === window.location.host) { console.error( - 'For security, the proxy host must not be the same as the application host. Using original URL instead.', + 'For security, the proxy origin must not be the same as the host origin. Using original URL instead.', ); } else { proxyUrl.searchParams.set('url', originalUrl); From 86715018cfdae62b7c4995f191d1338d57e31634 Mon Sep 17 00:00:00 2001 From: Ido Salomon Date: Fri, 18 Jul 2025 03:37:24 +0300 Subject: [PATCH 8/8] prettier --- examples/external-url-demo/index.html | 7 ++- examples/external-url-demo/package.json | 2 +- examples/external-url-demo/src/App.css | 60 +++++++++---------- examples/external-url-demo/src/App.tsx | 58 ++++++++++++------ examples/external-url-demo/src/index.css | 11 ++-- examples/external-url-demo/src/main.tsx | 10 ++-- examples/external-url-demo/tsconfig.app.json | 2 +- examples/external-url-demo/tsconfig.json | 2 +- examples/external-url-demo/tsconfig.node.json | 2 +- examples/external-url-demo/vite.config.ts | 2 +- packages/client/scripts/proxy/index.html | 2 +- .../src/components/UIResourceRenderer.tsx | 4 +- .../__tests__/HTMLResourceRenderer.test.tsx | 4 +- .../__tests__/UIResourceRenderer.test.tsx | 13 +++- .../utils/__tests__/processResource.test.ts | 2 +- packages/client/src/utils/processResource.ts | 12 +++- packages/server/src/index.ts | 4 +- 17 files changed, 119 insertions(+), 78 deletions(-) diff --git a/examples/external-url-demo/index.html b/examples/external-url-demo/index.html index 4da24661..e9ca4cf8 100644 --- a/examples/external-url-demo/index.html +++ b/examples/external-url-demo/index.html @@ -3,11 +3,14 @@ - + MCP-UI Proxy Demo
- \ No newline at end of file + diff --git a/examples/external-url-demo/package.json b/examples/external-url-demo/package.json index ed9f622d..6b879c52 100644 --- a/examples/external-url-demo/package.json +++ b/examples/external-url-demo/package.json @@ -26,4 +26,4 @@ "typescript": "~5.8.3", "vite": "^6.3.5" } -} \ No newline at end of file +} diff --git a/examples/external-url-demo/src/App.css b/examples/external-url-demo/src/App.css index 2df35a4a..6b80dc6d 100644 --- a/examples/external-url-demo/src/App.css +++ b/examples/external-url-demo/src/App.css @@ -1,43 +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; + 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; + margin: 2rem 0; + padding: 1rem; + border: 1px solid #ddd; + border-radius: 8px; } .demo-section h2 { - margin-top: 0; - color: #333; + margin-top: 0; + color: #333; } iframe { - width: 100%; - min-height: 400px; - border: 1px solid #ccc; - border-radius: 4px; + 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; + 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; + background: #007bff; + color: white; + border: none; + padding: 0.5rem 1rem; + border-radius: 4px; + cursor: pointer; + margin: 1rem 0; } .toggle:hover { - background: #0056b3; -} \ No newline at end of file + background: #0056b3; +} diff --git a/examples/external-url-demo/src/App.tsx b/examples/external-url-demo/src/App.tsx index 2a23b8fc..a2d21de6 100644 --- a/examples/external-url-demo/src/App.tsx +++ b/examples/external-url-demo/src/App.tsx @@ -14,42 +14,62 @@ function App() {

MCP-UI Proxy Demo

This demo shows how the proxy functionality works for external URLs in MCP-UI.

-

CSP Simulation: This page includes a Content Security Policy (frame-src 'self' https://proxy.mcpui.dev;) that only allows iframes from this origin and https://proxy.mcpui.dev. This demonstrates how the proxy prop can be used to display external content on hosts with strict security policies.

-

proxy.mcpui.dev 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.

+

+ CSP Simulation: This page includes a Content Security Policy ( + frame-src 'self' https://proxy.mcpui.dev;) that only allows iframes from this + origin and https://proxy.mcpui.dev. This demonstrates how the{' '} + proxy prop can be used to display external content on hosts with strict + security policies. +

+

+ proxy.mcpui.dev 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. +

-

Direct URL (No Proxy)

-

This iframe attempts to load an external URL directly. It should be blocked by the browser's Content Security Policy.

-
- Resource: {`{ mimeType: 'text/uri-list', text: 'https://example.com' }`} -
- +

Direct URL (No Proxy)

+

+ This iframe attempts to load an external URL directly.{' '} + It should be blocked by the browser's Content Security Policy. +

+
+ Resource: {`{ mimeType: 'text/uri-list', text: 'https://example.com' }`} +
+
-

Proxied URL

-

This iframe loads the external URL through the proxy:

-
- Resource: {`{ mimeType: 'text/uri-list', text: 'https://example.com' }`}
- Proxy: https://proxy.mcpui.dev/
- Final URL: {`${proxy}?url=${encodeURIComponent(resource.text)}`} -
- +

Proxied URL

+

This iframe loads the external URL through the proxy:

+
+ Resource: {`{ mimeType: 'text/uri-list', text: 'https://example.com' }`} +
+ Proxy: https://proxy.mcpui.dev/ +
+ Final URL: {`${proxy}?url=${encodeURIComponent(resource.text)}`} +
+

Interactive Demo

Toggle between direct and proxied loading:

- +
- Current URL: {useProxy ? `${proxy}?url=${encodeURIComponent(resource.text)}` : resource.text} + Current URL:{' '} + {useProxy ? `${proxy}?url=${encodeURIComponent(resource.text)}` : resource.text}
); } -export default App; \ No newline at end of file +export default App; diff --git a/examples/external-url-demo/src/index.css b/examples/external-url-demo/src/index.css index a89fbfed..99dd0500 100644 --- a/examples/external-url-demo/src/index.css +++ b/examples/external-url-demo/src/index.css @@ -1,13 +1,12 @@ body { margin: 0; - font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen', - 'Ubuntu', 'Cantarell', 'Fira Sans', 'Droid Sans', 'Helvetica Neue', - sans-serif; + 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; -} \ No newline at end of file + font-family: source-code-pro, Menlo, Monaco, Consolas, 'Courier New', monospace; +} diff --git a/examples/external-url-demo/src/main.tsx b/examples/external-url-demo/src/main.tsx index 53e88c85..9bb419d3 100644 --- a/examples/external-url-demo/src/main.tsx +++ b/examples/external-url-demo/src/main.tsx @@ -1,10 +1,10 @@ -import React from 'react' -import ReactDOM from 'react-dom/client' -import App from './App.tsx' -import './index.css' +import React from 'react'; +import ReactDOM from 'react-dom/client'; +import App from './App.tsx'; +import './index.css'; ReactDOM.createRoot(document.getElementById('root')!).render( , -) \ No newline at end of file +); diff --git a/examples/external-url-demo/tsconfig.app.json b/examples/external-url-demo/tsconfig.app.json index 4ae5f773..0a68247e 100644 --- a/examples/external-url-demo/tsconfig.app.json +++ b/examples/external-url-demo/tsconfig.app.json @@ -22,4 +22,4 @@ "noFallthroughCasesInSwitch": true }, "include": ["src"] -} \ No newline at end of file +} diff --git a/examples/external-url-demo/tsconfig.json b/examples/external-url-demo/tsconfig.json index 42023ce9..d32ff682 100644 --- a/examples/external-url-demo/tsconfig.json +++ b/examples/external-url-demo/tsconfig.json @@ -1,4 +1,4 @@ { "files": [], "references": [{ "path": "./tsconfig.app.json" }, { "path": "./tsconfig.node.json" }] -} \ No newline at end of file +} diff --git a/examples/external-url-demo/tsconfig.node.json b/examples/external-url-demo/tsconfig.node.json index 7e53654d..97ede7ee 100644 --- a/examples/external-url-demo/tsconfig.node.json +++ b/examples/external-url-demo/tsconfig.node.json @@ -8,4 +8,4 @@ "strict": true }, "include": ["vite.config.ts"] -} \ No newline at end of file +} diff --git a/examples/external-url-demo/vite.config.ts b/examples/external-url-demo/vite.config.ts index 0c13b95b..627a3196 100644 --- a/examples/external-url-demo/vite.config.ts +++ b/examples/external-url-demo/vite.config.ts @@ -4,4 +4,4 @@ import react from '@vitejs/plugin-react'; // https://vitejs.dev/config/ export default defineConfig({ plugins: [react()], -}); \ No newline at end of file +}); diff --git a/packages/client/scripts/proxy/index.html b/packages/client/scripts/proxy/index.html index 5c3d7ea9..1e40ae0b 100644 --- a/packages/client/scripts/proxy/index.html +++ b/packages/client/scripts/proxy/index.html @@ -53,4 +53,4 @@ } - \ No newline at end of file + diff --git a/packages/client/src/components/UIResourceRenderer.tsx b/packages/client/src/components/UIResourceRenderer.tsx index 4c1269aa..5de26023 100644 --- a/packages/client/src/components/UIResourceRenderer.tsx +++ b/packages/client/src/components/UIResourceRenderer.tsx @@ -40,7 +40,9 @@ export const UIResourceRenderer = (props: UIResourceRendererProps) => { case 'rawHtml': { // eslint-disable-next-line @typescript-eslint/no-unused-vars const { proxy, ...otherHtmlProps } = htmlProps || {}; - return ; + return ( + + ); } case 'externalUrl': { return ; diff --git a/packages/client/src/components/__tests__/HTMLResourceRenderer.test.tsx b/packages/client/src/components/__tests__/HTMLResourceRenderer.test.tsx index 3133ed01..668e656d 100644 --- a/packages/client/src/components/__tests__/HTMLResourceRenderer.test.tsx +++ b/packages/client/src/components/__tests__/HTMLResourceRenderer.test.tsx @@ -291,7 +291,9 @@ describe('HTMLResource iframe communication', () => { it('should pass ref to iframe', () => { const ref = React.createRef(); - render(); + render( + , + ); expect(ref.current).toBeInTheDocument(); }); diff --git a/packages/client/src/components/__tests__/UIResourceRenderer.test.tsx b/packages/client/src/components/__tests__/UIResourceRenderer.test.tsx index 31a7f5aa..ed96f63f 100644 --- a/packages/client/src/components/__tests__/UIResourceRenderer.test.tsx +++ b/packages/client/src/components/__tests__/UIResourceRenderer.test.tsx @@ -80,14 +80,21 @@ describe('', () => { it('should pass proxy prop to HTMLResourceRenderer for external URLs', () => { const resource = { ...baseResource, mimeType: 'text/uri-list' }; - render(); + render( + , + ); expect(screen.getByTestId('html-resource')).toBeInTheDocument(); - expect(HTMLResourceRenderer).toHaveBeenCalledWith({ resource, proxy: 'https://proxy.mcpui.dev/' }, {}); + expect(HTMLResourceRenderer).toHaveBeenCalledWith( + { resource, proxy: 'https://proxy.mcpui.dev/' }, + {}, + ); }); it('should not pass proxy prop to HTMLResourceRenderer for HTML content', () => { const resource = { ...baseResource, mimeType: 'text/html' }; - render(); + render( + , + ); expect(screen.getByTestId('html-resource')).toBeInTheDocument(); expect(HTMLResourceRenderer).toHaveBeenCalledWith({ resource }, {}); }); diff --git a/packages/client/src/utils/__tests__/processResource.test.ts b/packages/client/src/utils/__tests__/processResource.test.ts index 5b71598e..ff096c9f 100644 --- a/packages/client/src/utils/__tests__/processResource.test.ts +++ b/packages/client/src/utils/__tests__/processResource.test.ts @@ -126,4 +126,4 @@ describe('text/uri-list', () => { expect(result.iframeSrc).toBe('https://example.com'); expect(result.iframeRenderMode).toBe('src'); }); -}); \ No newline at end of file +}); diff --git a/packages/client/src/utils/processResource.ts b/packages/client/src/utils/processResource.ts index 81d6adc9..7fba442b 100644 --- a/packages/client/src/utils/processResource.ts +++ b/packages/client/src/utils/processResource.ts @@ -18,7 +18,10 @@ function isValidHttpUrl(string: string): boolean { return url.protocol === 'http:' || url.protocol === 'https:'; } -export function processHTMLResource(resource: Partial, proxy?: string): ProcessResourceResult { +export function processHTMLResource( + resource: Partial, + proxy?: string, +): ProcessResourceResult { if (resource.mimeType !== 'text/html' && resource.mimeType !== 'text/uri-list') { return { error: @@ -94,8 +97,11 @@ export function processHTMLResource(resource: Partial, proxy?: string) iframeRenderMode: 'src', }; } - } catch (e) { - console.error(`Invalid proxy URL provided: "${proxy}". Falling back to direct URL.`, e); + } catch (e: unknown) { + console.error( + `Invalid proxy URL provided: "${proxy}". Falling back to direct URL.`, + e instanceof Error ? e.message : String(e), + ); } } diff --git a/packages/server/src/index.ts b/packages/server/src/index.ts index fb821e63..948bf93f 100644 --- a/packages/server/src/index.ts +++ b/packages/server/src/index.ts @@ -70,7 +70,9 @@ export function createUIResource(options: CreateUIResourceOptions): UIResource { mimeType = 'text/html'; } else if (options.content.type === 'externalUrl') { if (!options.uri.startsWith('ui://')) { - throw new Error("MCP-UI SDK: URI must start with 'ui://' when content.type is 'externalUrl'."); + throw new Error( + "MCP-UI SDK: URI must start with 'ui://' when content.type is 'externalUrl'.", + ); } actualContentString = options.content.iframeUrl; if (typeof actualContentString !== 'string') {