From 6bd1ee7135caeb24467fc859f6f5dfb37ac548cf Mon Sep 17 00:00:00 2001 From: HOW2AI <112258381+ivan-meer@users.noreply.github.com> Date: Sat, 7 Jun 2025 23:28:01 +0700 Subject: [PATCH 1/5] docs: remove TODO in getting started guide --- docs/src/guide/getting-started.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/src/guide/getting-started.md b/docs/src/guide/getting-started.md index f1edddd..9edb326 100644 --- a/docs/src/guide/getting-started.md +++ b/docs/src/guide/getting-started.md @@ -12,7 +12,7 @@ This guide will walk you through setting up your development environment and usi 1. **Clone the Monorepo**: ```bash - git clone https://github.com/idosal/mcp-ui.git # TODO: Update this link + git clone https://github.com/idosal/mcp-ui.git cd mcp-ui ``` From 012209d7350110f5e8d7f41ed64f3a00413eff55 Mon Sep 17 00:00:00 2001 From: HOW2AI <112258381+ivan-meer@users.noreply.github.com> Date: Sat, 7 Jun 2025 23:29:35 +0700 Subject: [PATCH 2/5] feat(generator): add JSON schema form generator --- README.md | 22 +++++ packages/generator/package.json | 44 ++++++++++ .../src/__tests__/generateUI.test.tsx | 39 ++++++++ packages/generator/src/index.tsx | 88 +++++++++++++++++++ packages/generator/tsconfig.json | 8 ++ packages/generator/vite.config.ts | 25 ++++++ pnpm-lock.yaml | 31 +++++++ 7 files changed, 257 insertions(+) create mode 100644 packages/generator/package.json create mode 100644 packages/generator/src/__tests__/generateUI.test.tsx create mode 100644 packages/generator/src/index.tsx create mode 100644 packages/generator/tsconfig.json create mode 100644 packages/generator/vite.config.ts diff --git a/README.md b/README.md index 1776ea4..00e464b 100644 --- a/README.md +++ b/README.md @@ -9,6 +9,7 @@ What's mcp-ui?InstallationQuickstart • + JSON Schema GeneratorCore ConceptsExamplesRoadmap • @@ -127,6 +128,27 @@ yarn add @mcp-ui/server @mcp-ui/client ``` 3. **Enjoy** interactive MCP UIs — no extra configuration required. +## 🧩 JSON Schema Generator + +Generate simple React forms from JSON Schema using the `generateUI` API. + +```tsx +import { generateUI } from "@mcp-ui/generator"; + +const schema = { + type: "object", + properties: { + name: { type: "string" }, + age: { type: "number" }, + color: { type: "string", enum: ["red", "green"] }, + }, +}; + +export default function MyForm() { + return generateUI(schema); +} +``` + ## 🌍 Examples diff --git a/packages/generator/package.json b/packages/generator/package.json new file mode 100644 index 0000000..6e23a9d --- /dev/null +++ b/packages/generator/package.json @@ -0,0 +1,44 @@ +{ + "name": "@mcp-ui/generator", + "version": "0.1.0", + "private": false, + "description": "Generate React forms from JSON Schema", + "type": "module", + "main": "./dist/index.js", + "module": "./dist/index.mjs", + "types": "./dist/index.d.ts", + "exports": { + ".": { + "types": "./dist/index.d.ts", + "import": "./dist/index.mjs", + "require": "./dist/index.js" + } + }, + "files": [ + "dist" + ], + "dependencies": { + "react": "^18.2.0" + }, + "devDependencies": { + "@types/react": "^18.2.0", + "typescript": "^5.0.0", + "vite": "^5.0.0", + "vite-plugin-dts": "^3.6.0", + "vitest": "^1.0.0", + "@testing-library/react": "^14.0.0", + "@testing-library/jest-dom": "^6.0.0", + "jsdom": "^22.0.0" + }, + "scripts": { + "dev": "vite", + "build": "vite build", + "test": "vitest run", + "test:watch": "vitest watch", + "lint": "eslint . --ext .ts,.tsx" + }, + "publishConfig": { + "access": "public" + }, + "license": "Apache-2.0" +} diff --git a/packages/generator/src/__tests__/generateUI.test.tsx b/packages/generator/src/__tests__/generateUI.test.tsx new file mode 100644 index 0000000..691405c --- /dev/null +++ b/packages/generator/src/__tests__/generateUI.test.tsx @@ -0,0 +1,39 @@ +import { render, screen, fireEvent } from '@testing-library/react'; +import '@testing-library/jest-dom'; +import React from 'react'; +import { generateUI, JSONSchema } from '../index.js'; + +describe('generateUI', () => { + const schema: JSONSchema = { + type: 'object', + properties: { + name: { type: 'string', title: 'Name' }, + age: { type: 'number', title: 'Age' }, + color: { type: 'string', enum: ['red', 'green'], title: 'Color' }, + }, + }; + + it('renders form fields based on schema', () => { + render(generateUI(schema)); + + expect(screen.getByLabelText('Name')).toBeInTheDocument(); + expect(screen.getByLabelText('Age')).toBeInTheDocument(); + expect(screen.getByLabelText('Color')).toBeInTheDocument(); + }); + + it('updates value on user input', () => { + render(generateUI(schema)); + + const nameInput = screen.getByLabelText('Name') as HTMLInputElement; + fireEvent.change(nameInput, { target: { value: 'Alice' } }); + expect(nameInput.value).toBe('Alice'); + + const ageInput = screen.getByLabelText('Age') as HTMLInputElement; + fireEvent.change(ageInput, { target: { value: '30' } }); + expect(ageInput.value).toBe('30'); + + const colorSelect = screen.getByLabelText('Color') as HTMLSelectElement; + fireEvent.change(colorSelect, { target: { value: 'green' } }); + expect(colorSelect.value).toBe('green'); + }); +}); diff --git a/packages/generator/src/index.tsx b/packages/generator/src/index.tsx new file mode 100644 index 0000000..a0a26da --- /dev/null +++ b/packages/generator/src/index.tsx @@ -0,0 +1,88 @@ +import React, { useState } from 'react'; + +export interface JSONSchemaProperty { + type: string; + enum?: (string | number)[]; + title?: string; +} + +export interface JSONSchema { + type: 'object'; + properties: Record; + required?: string[]; +} + +interface GeneratedFormProps { + schema: JSONSchema; +} + +const GeneratedForm: React.FC = ({ schema }) => { + const { properties } = schema; + const [formData, setFormData] = useState>({}); + + const handleChange = (key: string, value: unknown) => { + setFormData((prev) => ({ ...prev, [key]: value })); + }; + + const renderField = (key: string, prop: JSONSchemaProperty) => { + if (prop.enum) { + return ( + + ); + } + + switch (prop.type) { + case 'string': + return ( + handleChange(key, e.target.value)} + /> + ); + case 'number': + case 'integer': + return ( + handleChange(key, Number(e.target.value))} + /> + ); + default: + return null; + } + }; + + return ( +
+ {Object.entries(properties).map(([key, prop]) => ( +
+ +
+ ))} +
+ ); +}; + +export const generateUI = (schema: JSONSchema): React.ReactElement => { + return ; +}; + +export { GeneratedForm }; diff --git a/packages/generator/tsconfig.json b/packages/generator/tsconfig.json new file mode 100644 index 0000000..3aef79a --- /dev/null +++ b/packages/generator/tsconfig.json @@ -0,0 +1,8 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "jsx": "react-jsx" + }, + "include": ["src"], + "exclude": ["node_modules", "dist", "src/__tests__"] +} diff --git a/packages/generator/vite.config.ts b/packages/generator/vite.config.ts new file mode 100644 index 0000000..b36fdb9 --- /dev/null +++ b/packages/generator/vite.config.ts @@ -0,0 +1,25 @@ +import { defineConfig } from 'vite'; +import dts from 'vite-plugin-dts'; +import react from '@vitejs/plugin-react-swc'; +import path from 'path'; + +export default defineConfig({ + plugins: [ + react(), + dts({ + insertTypesEntry: true, + tsconfigPath: path.resolve(__dirname, 'tsconfig.json'), + exclude: ['**/__tests__/**', '**/*.test.ts', '**/*.spec.ts'], + }), + ], + build: { + lib: { + entry: path.resolve(__dirname, 'src/index.ts'), + name: 'McpUiGenerator', + formats: ['es', 'umd'], + fileName: (format) => + `index.${format === 'es' ? 'mjs' : format === 'umd' ? 'js' : format + '.js'}`, + }, + sourcemap: true, + }, +}); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 3e46818..8c890b4 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -142,6 +142,37 @@ importers: specifier: ^1.0.0 version: 1.6.1(@types/node@22.15.18)(jsdom@22.1.0) + packages/generator: + dependencies: + react: + specifier: ^18.2.0 + version: 18.3.1 + devDependencies: + '@testing-library/jest-dom': + specifier: ^6.0.0 + version: 6.6.3 + '@testing-library/react': + specifier: ^14.0.0 + version: 14.3.1(@types/react@18.3.21)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@types/react': + specifier: ^18.2.0 + version: 18.3.21 + jsdom: + specifier: ^22.0.0 + version: 22.1.0 + typescript: + specifier: ^5.0.0 + version: 5.8.3 + vite: + specifier: ^5.0.0 + version: 5.4.19(@types/node@22.15.18) + vite-plugin-dts: + specifier: ^3.6.0 + version: 3.9.1(@types/node@22.15.18)(rollup@4.40.2)(typescript@5.8.3)(vite@5.4.19(@types/node@22.15.18)) + vitest: + specifier: ^1.0.0 + version: 1.6.1(@types/node@22.15.18)(jsdom@22.1.0) + packages/server: devDependencies: '@types/node': From 363404686c7aa786e5aa78493ef653ed4635a325 Mon Sep 17 00:00:00 2001 From: HOW2AI <112258381+ivan-meer@users.noreply.github.com> Date: Sat, 7 Jun 2025 23:30:34 +0700 Subject: [PATCH 3/5] feat: add secure HTML rendering --- README.md | 2 +- docs/src/guide/client/html-resource.md | 17 +++- examples/server/README.md | 2 +- packages/client/README.md | 2 +- packages/client/package.json | 15 +-- .../client/src/components/HtmlResource.tsx | 93 ++++++++++++++++++- .../__tests__/HtmlResource.test.tsx | 69 ++++++++++---- packages/server/README.md | 2 +- pnpm-lock.yaml | 16 ++++ 9 files changed, 185 insertions(+), 33 deletions(-) diff --git a/README.md b/README.md index 1776ea4..8ed1780 100644 --- a/README.md +++ b/README.md @@ -60,7 +60,7 @@ interface HtmlResourceBlock { It's rendered in the client with the `` React component. -The HTML method is limited, and the external app method isn't secure enough for untrusted 3rd party sites. We need a better method. Some ideas we should explore: RSC, remotedom, etc. +`HtmlResource` now supports an experimental `secure` render mode that sanitizes the HTML with DOMPurify instead of using an iframe. This avoids the security pitfalls of embedding untrusted sites. Future improvements may leverage React Server Components or Remote DOM for even better isolation. ### UI Action diff --git a/docs/src/guide/client/html-resource.md b/docs/src/guide/client/html-resource.md index a63c0e3..dff31dd 100644 --- a/docs/src/guide/client/html-resource.md +++ b/docs/src/guide/client/html-resource.md @@ -14,12 +14,16 @@ export interface HtmlResourceProps { params: Record, ) => Promise; style?: React.CSSProperties; + renderMode?: 'iframe' | 'secure'; } ``` - **`resource`**: The resource object from an `HtmlResourceBlock`. It should include `uri`, `mimeType`, and either `text` or `blob`. - **`onUiAction`**: An optional callback that fires when the iframe content (for `ui://` resources) posts a message to your app. The message should look like `{ tool: string, params: Record }`. - **`style`** (optional): Custom styles for the iframe. +- **`renderMode`** (optional): `'iframe'` (default) or `'secure'`. Secure mode + sanitizes the HTML and renders it directly without an iframe. Actions are + triggered by elements with `data-tool` and optional `data-params` attributes. ## How It Works @@ -45,8 +49,15 @@ By default, the iframe stretches to 100% width and is at least 200px tall. You c See [Client SDK Usage & Examples](./usage-examples.md). +## Secure Renderer (Experimental) + +When `renderMode` is set to `"secure"`, the HTML is sanitized using +[`DOMPurify`](https://github.com/cure53/DOMPurify) and injected directly into the +page. No iframe is used. Interactive elements should emit actions by including a +`data-tool` attribute and an optional `data-params` JSON string. + ## Security Notes -- **`sandbox` attribute**: Restricts what the iframe can do. `allow-scripts` is needed for interactivity. `allow-same-origin` is only used for `ui-app://` URLs. Caution - it's not a secure way to render untrusted code. We should add more secure methods such as RSC ASAP. -- **`postMessage` origin**: When sending messages from the iframe, always specify the target origin for safety. The component listens globally, so your iframe content should be explicit. -- **Content Sanitization**: HTML is rendered as-is. If you don't fully trust the source, sanitize the HTML before passing it in, or rely on the iframe's sandboxing. +- **`sandbox` attribute**: Restricts what the iframe can do. `allow-scripts` is needed for interactivity. `allow-same-origin` is only used for `ui-app://` URLs. +- **`postMessage` origin**: When sending messages from the iframe, always specify the target origin for safety. +- **Content Sanitization**: In `secure` mode the HTML is sanitized with DOMPurify before rendering. In `iframe` mode the HTML is rendered as-is and relies on the iframe's sandboxing. diff --git a/examples/server/README.md b/examples/server/README.md index 1b80e03..cf10ca8 100644 --- a/examples/server/README.md +++ b/examples/server/README.md @@ -59,7 +59,7 @@ interface HtmlResourceBlock { It's rendered in the client with the `` React component. -The HTML method is limited, and the external app method isn't secure enough for untrusted 3rd party sites. We need a better method. Some ideas we should explore: RSC, remotedom, etc. +`HtmlResource` now has an experimental `secure` mode which sanitizes HTML instead of embedding it in an iframe. This is safer for untrusted UI blocks. React Server Components and Remote DOM are still being researched for future versions. ### UI Action diff --git a/packages/client/README.md b/packages/client/README.md index fb179c9..e5452f2 100644 --- a/packages/client/README.md +++ b/packages/client/README.md @@ -60,7 +60,7 @@ interface HtmlResourceBlock { It's rendered in the client with the `` React component. -The HTML method is limited, and the external app method isn't secure enough for untrusted 3rd party sites. We need a better method. Some ideas we should explore: RSC, remotedom, etc. +`HtmlResource` now provides an optional `secure` render mode which sanitizes the incoming HTML using DOMPurify and avoids embedding untrusted content in an iframe. Future versions may adopt React Server Components or Remote DOM for even tighter isolation. ### UI Action diff --git a/packages/client/package.json b/packages/client/package.json index 5e7baf4..7685d18 100644 --- a/packages/client/package.json +++ b/packages/client/package.json @@ -18,19 +18,20 @@ "dist" ], "dependencies": { - "react": "^18.2.0", - "@modelcontextprotocol/sdk": "*" + "@modelcontextprotocol/sdk": "*", + "dompurify": "^3.2.6", + "react": "^18.2.0" }, "devDependencies": { + "@testing-library/jest-dom": "^6.0.0", + "@testing-library/react": "^14.0.0", "@types/react": "^18.2.0", + "@vitejs/plugin-react": "^4.0.0", + "jsdom": "^22.0.0", "typescript": "^5.0.0", "vite": "^5.0.0", - "@vitejs/plugin-react": "^4.0.0", "vite-plugin-dts": "^3.6.0", - "vitest": "^1.0.0", - "@testing-library/react": "^14.0.0", - "@testing-library/jest-dom": "^6.0.0", - "jsdom": "^22.0.0" + "vitest": "^1.0.0" }, "scripts": { "dev": "vite", diff --git a/packages/client/src/components/HtmlResource.tsx b/packages/client/src/components/HtmlResource.tsx index a176244..ea94163 100644 --- a/packages/client/src/components/HtmlResource.tsx +++ b/packages/client/src/components/HtmlResource.tsx @@ -1,5 +1,6 @@ import React, { useEffect, useRef, useState } from 'react'; import type { Resource } from '@modelcontextprotocol/sdk/types.js'; +import DOMPurify from 'dompurify'; export interface RenderHtmlResourceProps { resource: Partial; @@ -8,21 +9,29 @@ export interface RenderHtmlResourceProps { params: Record, ) => Promise; style?: React.CSSProperties; + /** + * Choose how the HTML should be rendered. Defaults to `iframe`. + * `secure` renders sanitized HTML directly without an iframe. + */ + renderMode?: 'iframe' | 'secure'; } export const HtmlResource: React.FC = ({ resource, onUiAction, style, + renderMode = 'iframe', }) => { const [htmlString, setHtmlString] = useState(null); const [iframeSrc, setIframeSrc] = useState(null); const [iframeRenderMode, setIframeRenderMode] = useState<'srcDoc' | 'src'>( 'srcDoc', ); + const [secureHtml, setSecureHtml] = useState(null); const [isLoading, setIsLoading] = useState(false); const [error, setError] = useState(null); const iframeRef = useRef(null); + const secureContainerRef = useRef(null); useEffect(() => { const processResource = async () => { @@ -30,6 +39,7 @@ export const HtmlResource: React.FC = ({ setError(null); setHtmlString(null); setIframeSrc(null); + setSecureHtml(null); setIframeRenderMode('srcDoc'); // Default to srcDoc if (resource.mimeType !== 'text/html') { @@ -38,6 +48,42 @@ export const HtmlResource: React.FC = ({ return; } + if (renderMode === 'secure') { + if ( + resource.uri && + !resource.uri.startsWith('ui://') && + !resource.uri.startsWith('data:') + ) { + setError('Secure renderer only supports inline ui:// HTML content.'); + setIsLoading(false); + return; + } + + let html = ''; + if (typeof resource.text === 'string') { + html = resource.text; + } else if (typeof resource.blob === 'string') { + try { + html = new TextDecoder().decode( + Uint8Array.from(atob(resource.blob), (c) => c.charCodeAt(0)), + ); + } catch (e) { + console.error('Error decoding base64 blob for HTML content:', e); + setError('Error decoding HTML content from blob.'); + setIsLoading(false); + return; + } + } else { + setError('Secure renderer requires text or blob HTML content.'); + setIsLoading(false); + return; + } + + setSecureHtml(DOMPurify.sanitize(html)); + setIsLoading(false); + return; + } + if (resource.uri?.startsWith('ui-app://')) { setIframeRenderMode('src'); if (typeof resource.text === 'string' && resource.text.trim() !== '') { @@ -95,7 +141,7 @@ export const HtmlResource: React.FC = ({ }; processResource(); - }, [resource]); + }, [resource, renderMode]); useEffect(() => { function handleMessage(event: MessageEvent) { @@ -115,10 +161,53 @@ export const HtmlResource: React.FC = ({ return () => window.removeEventListener('message', handleMessage); }, [onUiAction]); + useEffect(() => { + if (renderMode !== 'secure' || !onUiAction) return; + + const handler = (e: Event) => { + const target = e.target as HTMLElement | null; + if (!target) return; + const tool = target.getAttribute('data-tool'); + if (tool) { + const paramsAttr = target.getAttribute('data-params'); + let params: Record = {}; + if (paramsAttr) { + try { + params = JSON.parse(paramsAttr); + } catch { + params = {}; + } + } + onUiAction(tool, params).catch((err) => { + console.error('Error from onUiAction in RenderHtmlResource:', err); + }); + } + }; + + const container = secureContainerRef.current; + container?.addEventListener('click', handler); + return () => container?.removeEventListener('click', handler); + }, [onUiAction, renderMode, secureHtml]); + if (isLoading) return

Loading HTML content...

; if (error) return

{error}

; - if (iframeRenderMode === 'srcDoc') { + if (renderMode === 'secure') { + if (secureHtml === null || secureHtml === undefined) { + if (!isLoading && !error) { + return

No HTML content to display.

; + } + return null; + } + return ( +
+ ); + } else if (iframeRenderMode === 'srcDoc') { if (htmlString === null || htmlString === undefined) { if (!isLoading && !error) { return

No HTML content to display.

; diff --git a/packages/client/src/components/__tests__/HtmlResource.test.tsx b/packages/client/src/components/__tests__/HtmlResource.test.tsx index 3c28629..3ab0f93 100644 --- a/packages/client/src/components/__tests__/HtmlResource.test.tsx +++ b/packages/client/src/components/__tests__/HtmlResource.test.tsx @@ -5,7 +5,7 @@ import { vi, Mock, MockInstance } from 'vitest'; import type { Resource } from '@modelcontextprotocol/sdk/types.js'; describe('HtmlResource component', () => { - const mockOnUiAction = vi.fn(); + const mockOnUiAction = vi.fn().mockResolvedValue(undefined); const defaultProps: RenderHtmlResourceProps = { resource: { mimeType: 'text/html', text: '

Hello Test

' }, @@ -25,6 +25,24 @@ describe('HtmlResource component', () => { expect(iframe.srcdoc).toContain('

Hello Test

'); }); + it('renders sanitized HTML using secure mode and handles actions', () => { + const props: RenderHtmlResourceProps = { + resource: { + mimeType: 'text/html', + text: '

Hello World

', + }, + onUiAction: mockOnUiAction, + renderMode: 'secure', + }; + render(); + const container = screen.getByTestId('html-resource-secure'); + expect(container).toBeInTheDocument(); + expect(container.innerHTML).toContain('Hello'); + expect(container.innerHTML).not.toContain('