(null);
+ useImperativeHandle(ref, () => iframeRef.current as HTMLIFrameElement);
- const { error, iframeSrc, iframeRenderMode, htmlString } = useMemo(
- () => processResource(resource),
- [resource],
- );
+ const { error, iframeSrc, iframeRenderMode, htmlString } = useMemo(
+ () => processResource(resource, supportedContentTypes),
+ [resource, supportedContentTypes],
+ );
- useEffect(() => {
- function handleMessage(event: MessageEvent) {
- // Only process the message if it came from this specific iframe
- if (
- iframeRef.current &&
- event.source === iframeRef.current.contentWindow
- ) {
- const uiActionResult = event.data as UiActionResult;
- if (!uiActionResult) {
- return;
+ useEffect(() => {
+ function handleMessage(event: MessageEvent) {
+ // Only process the message if it came from this specific iframe
+ if (
+ iframeRef.current &&
+ event.source === iframeRef.current.contentWindow
+ ) {
+ const uiActionResult = event.data as UiActionResult;
+ if (!uiActionResult) {
+ return;
+ }
+ onUiAction?.(uiActionResult)?.catch((err) => {
+ console.error(
+ 'Error handling UI action result in RenderHtmlResource:',
+ err,
+ );
+ });
}
- onUiAction?.(uiActionResult)?.catch((err) => {
- console.error(
- 'Error handling UI action result in RenderHtmlResource:',
- err,
- );
- });
}
- }
- window.addEventListener('message', handleMessage);
- return () => window.removeEventListener('message', handleMessage);
- }, [onUiAction]);
+ window.addEventListener('message', handleMessage);
+ return () => window.removeEventListener('message', handleMessage);
+ }, [onUiAction]);
- if (error) return {error}
;
+ if (error) return {error}
;
- if (iframeRenderMode === 'srcDoc') {
- if (htmlString === null || htmlString === undefined) {
- if (!error) {
- return No HTML content to display.
;
+ if (iframeRenderMode === 'srcDoc') {
+ if (htmlString === null || htmlString === undefined) {
+ if (!error) {
+ return No HTML content to display.
;
+ }
+ return null;
}
- return null;
- }
- return (
-
- );
- } else if (iframeRenderMode === 'src') {
- if (iframeSrc === null || iframeSrc === undefined) {
- if (!error) {
- return (
- No URL provided for HTML resource.
- );
+ return (
+
+ );
+ } else if (iframeRenderMode === 'src') {
+ if (iframeSrc === null || iframeSrc === undefined) {
+ if (!error) {
+ return (
+
+ No URL provided for HTML resource.
+
+ );
+ }
+ return null;
}
- return null;
+ return (
+
+ );
}
+
return (
-
+ Initializing HTML resource display...
);
- }
-
- return Initializing HTML resource display...
;
-});
+ },
+);
HtmlResource.displayName = 'HtmlResource';
diff --git a/packages/client/src/components/__tests__/HtmlResource.test.tsx b/packages/client/src/components/__tests__/HtmlResource.test.tsx
index f239d901..151083bc 100644
--- a/packages/client/src/components/__tests__/HtmlResource.test.tsx
+++ b/packages/client/src/components/__tests__/HtmlResource.test.tsx
@@ -227,6 +227,83 @@ https://example.com/backup
});
});
+describe('supportedContentTypes', () => {
+ it('renders raw HTML when supportedContentTypes includes "rawHtml"', () => {
+ const props: RenderHtmlResourceProps = {
+ resource: { mimeType: 'text/html', text: 'Supported HTML
' },
+ supportedContentTypes: ['rawHtml'],
+ };
+ render();
+ const iframe = screen.getByTitle('MCP HTML Resource (Embedded Content)');
+ expect(iframe).toBeInTheDocument();
+ expect(iframe.getAttribute('srcdoc')).toContain('Supported HTML
');
+ });
+
+ it('shows an error for raw HTML when supportedContentTypes does not include "rawHtml"', () => {
+ const props: RenderHtmlResourceProps = {
+ resource: { mimeType: 'text/html', text: 'Unsupported HTML
' },
+ supportedContentTypes: ['externalUrl'],
+ };
+ render();
+ expect(
+ screen.getByText('Raw HTML content type (text/html) is not supported.'),
+ ).toBeInTheDocument();
+ });
+
+ it('renders external URL when supportedContentTypes includes "externalUrl"', () => {
+ const props: RenderHtmlResourceProps = {
+ resource: {
+ mimeType: 'text/uri-list',
+ text: 'https://supported.example.com',
+ },
+ supportedContentTypes: ['externalUrl'],
+ };
+ render();
+ const iframe = screen.getByTitle('MCP HTML Resource (URL)');
+ expect(iframe).toBeInTheDocument();
+ expect(iframe.getAttribute('src')).toBe('https://supported.example.com');
+ });
+
+ it('shows an error for external URL when supportedContentTypes does not include "externalUrl"', () => {
+ const props: RenderHtmlResourceProps = {
+ resource: {
+ mimeType: 'text/uri-list',
+ text: 'https://unsupported.example.com',
+ },
+ supportedContentTypes: ['rawHtml'],
+ };
+ render();
+ expect(
+ screen.getByText(
+ 'External URL content type (text/uri-list) is not supported.',
+ ),
+ ).toBeInTheDocument();
+ });
+
+ it('renders content by default when supportedContentTypes is not provided', () => {
+ // Test raw HTML
+ let props: RenderHtmlResourceProps = {
+ resource: { mimeType: 'text/html', text: 'Default HTML
' },
+ };
+ const { rerender } = render();
+ let iframe = screen.getByTitle('MCP HTML Resource (Embedded Content)');
+ expect(iframe).toBeInTheDocument();
+ expect(iframe.getAttribute('srcdoc')).toContain('Default HTML
');
+
+ // Test external URL
+ props = {
+ resource: {
+ mimeType: 'text/uri-list',
+ text: 'https://default.example.com',
+ },
+ };
+ rerender();
+ iframe = screen.getByTitle('MCP HTML Resource (URL)');
+ expect(iframe).toBeInTheDocument();
+ expect(iframe.getAttribute('src')).toBe('https://default.example.com');
+ });
+});
+
const mockResourceBaseForUiActionTests: Partial = {
mimeType: 'text/html',
text: 'Test Content
',
diff --git a/packages/client/src/types.ts b/packages/client/src/types.ts
index f5b11825..8bd4b64f 100644
--- a/packages/client/src/types.ts
+++ b/packages/client/src/types.ts
@@ -1,4 +1,12 @@
-export type UiActionType = 'tool' | 'prompt' | 'link' | 'intent' | 'notification';
+export type UiActionType =
+ | 'tool'
+ | 'prompt'
+ | 'link'
+ | 'intent'
+ | 'notification';
+
+export const ALL_RESOURCE_CONTENT_TYPES = ['rawHtml', 'externalUrl'] as const;
+export type ResourceContentType = (typeof ALL_RESOURCE_CONTENT_TYPES)[number];
export type UiActionResultToolCall = {
type: 'tool';
diff --git a/packages/client/src/utils/processResource.ts b/packages/client/src/utils/processResource.ts
index 4ea8f5f5..c7a6062f 100644
--- a/packages/client/src/utils/processResource.ts
+++ b/packages/client/src/utils/processResource.ts
@@ -1,4 +1,6 @@
import { Resource } from '@modelcontextprotocol/sdk/types.js';
+import { ResourceContentType, ALL_RESOURCE_CONTENT_TYPES } from '../types';
+
type ProcessResourceResult = {
error?: string;
@@ -9,7 +11,10 @@ type ProcessResourceResult = {
export function processResource(
resource: Partial,
+ supportedContentTypes?: ResourceContentType[],
): ProcessResourceResult {
+ const supported = supportedContentTypes || ALL_RESOURCE_CONTENT_TYPES;
+
// Backwards compatibility: if URI starts with ui-app://, treat as URL content
const isLegacyExternalApp =
typeof resource.uri === 'string' && resource.uri.startsWith('ui-app://');
@@ -27,6 +32,21 @@ export function processResource(
};
}
+ if (effectiveMimeType === 'text/html' && !supported.includes('rawHtml')) {
+ return {
+ error: 'Raw HTML content type (text/html) is not supported.',
+ };
+ }
+
+ if (
+ effectiveMimeType === 'text/uri-list' &&
+ !supported.includes('externalUrl')
+ ) {
+ return {
+ error: 'External URL content type (text/uri-list) is not supported.',
+ };
+ }
+
if (effectiveMimeType === 'text/uri-list') {
// Handle URL content (external apps)
// Note: While text/uri-list format supports multiple URLs, MCP-UI requires a single URL.
diff --git a/packages/server/README.md b/packages/server/README.md
index 6db63a0d..28d07c6a 100644
--- a/packages/server/README.md
+++ b/packages/server/README.md
@@ -20,7 +20,7 @@
**`mcp-ui`** brings interactive web components to the [Model Context Protocol](https://modelcontextprotocol.io/introduction) (MCP). Deliver rich, dynamic UI resources directly from your MCP server to be rendered by the client. Take AI interaction to the next level!
-> *This project is an experimental playground for MCP UI ideas. Expect rapid iteration and community-driven enhancements!*
+> *This project is an experimental community playground for MCP UI ideas. Expect rapid iteration and enhancements!*
@@ -33,6 +33,11 @@
Together, they let you define reusable UI resource blocks on the server side, seamlessly display them in the client, and react to their actions in the MCP host environment.
+**North star** -
+* Enable servers to deliver rich, interactive UIs with ergonomic APIs
+* Allow any host to support UI with its own look-and-feel
+* Eliminate security concerns (limit/remove local code execution)
+
## ✨ Core Concepts
@@ -44,7 +49,7 @@ The primary payload exchanged between the server and the client:
interface HtmlResourceBlock {
type: 'resource';
resource: {
- uri: string; // e.g. "ui://component/id"
+ uri: string; // ui://component/id
mimeType: 'text/html' | 'text/uri-list'; // text/html for HTML content, text/uri-list for URL content
text?: string; // Inline HTML or external URL
blob?: string; // Base64-encoded HTML or URL
@@ -59,8 +64,15 @@ interface HtmlResourceBlock {
* **`text` vs. `blob`**: Choose `text` for simple strings; use `blob` for larger or encoded content.
It's rendered in the client with the `` React component.
+The component accepts the following props:
+
+* **`resource`**: The `resource` object from an MCP message.
+* **`onUiAction`**: A callback function to handle events from the resource.
+* **`supportedContentTypes`**: (Optional) An array of content types to allow. Can include `'rawHtml'` and/or `'externalUrl'`. If omitted, all supported types are rendered. This is useful for restricting content types due to capability or security considerations.
+* **`style`**: (Optional) Custom styles for the iframe.
+* **`iframeProps`**: (Optional) Custom iframe props.
-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.
+The HTML method is limited, and the external app method isn't secure enough for untrusted sites. We need a better method. We're exploring web components and remote-dom as alternatives that can allow the servers to render their components with the host's look-and-feel without local code execution.
### UI Action
@@ -115,6 +127,7 @@ yarn add @mcp-ui/server @mcp-ui/client
return (
{
console.log('Action:', result);
return { status: 'ok' };
@@ -146,14 +159,17 @@ Drop those URLs into any MCP-compatible host to see `mcp-ui` in action.
## 🛣️ Roadmap
-- [ ] Support new SSR methods (e.g., RSC)
-- [ ] Support additional client-side libraries
-- [ ] Expand UI Action API
-- [ ] Do more with Resources and Sampling
+- [X] Add online playground
+- [X] Expand UI Action API (beyond tool calls)
+- [ ] Add
+- [ ] Support Web Components (in progress)
+- [ ] Support Remote-DOM (in progress)
+- [ ] Add component libraries (in progress)
+- [ ] Support additional client-side libraries and render engines (e.g., Vue, TUI, etc.)
## 🤝 Contributing
-Contributions, ideas, and bug reports are welcome! See the [contribution guidelines](https://github.com/idosal/mco-ui/blob/main/.github/CONTRIBUTING.md) to get started.
+Contributions, ideas, and bug reports are welcome! See the [contribution guidelines](https://github.com/idosal/mcp-ui/blob/main/.github/CONTRIBUTING.md) to get started.
## 📄 License
diff --git a/packages/server/src/index.ts b/packages/server/src/index.ts
index d4f5897d..9bc8864c 100644
--- a/packages/server/src/index.ts
+++ b/packages/server/src/index.ts
@@ -120,11 +120,10 @@ export function createHtmlResource(
};
break;
default:
- // Exhaustive check
- (() => {
+ {
const exhaustiveCheck: never = options.delivery;
throw new Error(`Invalid delivery type: ${exhaustiveCheck}`);
- })();
+ };
}
return {