[MCP] iframe-based UI components

While raw HTML and Remote DOM provide great ways to create UI components, they have limitations when it comes to building truly complex, interactive applications. Raw HTML requires you to write all styling from scratch, and Remote DOM, while consistent, can be cumbersome for complex layouts and interactions. For sophisticated applications that need rich functionality, responsive design, and complex state management, you need a more powerful approach.
The solution is iframe-based UI components - embedding full web applications (though normally just widgets rather than fully routed solutions) as UI resources in MCP. This approach lets you leverage entire frameworks like React, Vue, or any other web technology to create rich, interactive interfaces that can communicate with the host application through a standardized protocol.
Example:
import { createUIResource } from '@mcp-ui/server'

// Create a UI resource with an iframe for a task management widget
const resource = createUIResource({
	uri: `ui://task-dashboard/${Date.now()}`,
	content: {
		type: 'externalUrl',
		iframeUrl: 'https://example.com/widgets/task-dashboard',
	},
	encoding: 'text',
	uiMetadata: {
		'preferred-frame-size': ['1200px', '800px'],
	},
})

Iframe-based UI components use the externalUrl content type to embed full web applications. The uiMetadata allows you to specify preferred sizing for the iframe, giving the host application hints about optimal dimensions.

 Here's how this works in practice. When an AI assistant needs to show a complex journal viewer with interactive features like deleting entries, viewing details, and summarizing content, instead of trying to build this with raw HTML or Remote DOM, it can embed a full React application that handles all the complexity.

The iframe approach provides several key advantages:
  1. Full Framework Support: Use any web framework. If it can serve a web page, it can be embedded in an iframe.
  2. Complex State Management: Handle sophisticated application state with libraries like Redux, Zustand, or Context
  3. Rich Interactions: Build complex user interactions with full access to browser APIs
  4. Responsive Design: Leverage CSS frameworks and responsive design patterns
  5. Communication Protocol: Standardized postMessage API for iframe-to-host communication
Key message types include:
  • ui-lifecycle-iframe-ready - Notify host that iframe is ready to receive messages
  • ui-size-change - Inform host when iframe dimensions change
  • tool - Request host to execute an MCP tool
  • prompt - Request host to send a prompt to the AI
  • link - Request host to navigate to a URL

 

Request Info

In the MCP TypeScript SDK, tools can receive two arguments:
  1. args - the arguments passed to the tool
  2. extra - extra information about the request
If the tool does not have an inputSchema, the first argument will be the "extra" object which includes the requestInfo object.
When constructing iframe URLs, we need to use the origin from the request headers.
Here's how to access the origin from the request headers in the tool handler:
agent.server.registerTool(
	'get_dashboard',
	{
		title: 'Get Dashboard',
		description: 'Get the dashboard for a project',
		// no inputSchema means the first argument to our tool handler will be the "extra" object which includes the requestInfo object
	},
	// In your tool handler, the requestInfo object contains the request headers
	async ({ requestInfo }) => {
		const origin = requestInfo.headers['x-origin']
		// origin would be something like https://example.com
		// ...
	},
)
We need to add a custom x-origin header to the MCP request so our tool handler knows where to set the full iframe URL. This header is added in the worker's fetch handler before forwarding the request to the MCP server.

 

The worker sets up the custom header like this:

// In worker/index.ts
if (url.pathname === '/mcp') {
	// clone the request headers
	const headers = new Headers(request.headers)
	// add the custom header
	headers.set('x-origin', url.origin)
	// clone the request with the new headers
	const newRequest = new Request(request, { headers })

	return EpicMeMCP.serve('/mcp', {
		binding: 'EPIC_ME_MCP_OBJECT',
		// pass the newRequest instead of request
	}).fetch(newRequest, env, ctx)
}

 

This ensures that when our tool handler receives the request, it has access to the origin information needed to construct the proper iframe URL.

 

Code:

worker/index.ts

import { createRequestHandler } from 'react-router'
import { db } from './db.ts'
import { EpicMeMCP, type Props as McpProps } from './mcp/index.ts'

const requestHandler = createRequestHandler(
	() => import('virtual:react-router/server-build'),
	import.meta.env.MODE,
)

export default {
	fetch: async (
		request: Request,
		env: Env,
		ctx: ExecutionContext<McpProps>,
	) => {
		const url = new URL(request.url)

		// Tell the MCP agent what the base URL for the MCP server is
		if (url.pathname === '/mcp') {
			// Passing the base URL to the MCP agent allows it to construct URLs for UI resources
			ctx.props.baseUrl = url.origin

			return EpicMeMCP.serve('/mcp', {
				binding: 'EPIC_ME_MCP_OBJECT',
			}).fetch(request, env, ctx)
		}

		return requestHandler(request, {
			db,
			cloudflare: { env, ctx },
		})
	},
}

export { EpicMeMCP }

 

worker/mcp/index.ts:

export class EpicMeMCP extends McpAgent<Env, State, Props> {
	db!: DBClient
	server = new McpServer(
		...
	)

	async init() {
		this.db = db
		await initializeTools(this)
		await initializeResources(this)
		await initializePrompts(this)
	}

    // Helper function for MCP UI to get the baseUrl
	requireBaseUrl() {
		const { baseUrl } = this.props ?? {}
		invariant(baseUrl, 'Unexpected: baseUrl not set on agent')
		return baseUrl
	}
}

 

worker/mcp/tools.ts

	agent.server.registerTool(
		'view_journal',
		{
			title: 'View Journal',
			description: 'View the journal visually',
			annotations: {
				readOnlyHint: true,
				openWorldHint: false,
			} satisfies ToolAnnotations,
		},
		async () => {
			const baseUrl = agent.requireBaseUrl()
            // Visit our full-stack application on this path
			const iframeUrl = new URL('/ui/journal-viewer', baseUrl)
			return {
				content: [
					await createUIResource({
						uri: `ui://view-journal/${Date.now()}`,
						content: {
							type: 'externalUrl',
							iframeUrl: iframeUrl.toString(),
						},
						encoding: 'text',
						uiMetadata: {
							'preferred-frame-size': ['600px', '800px'],
						},
					}),
				],
			}
		},
	)

 

posted @ 2026-01-30 15:03  Zhentiw  阅读(7)  评论(0)    收藏  举报