mcp2web — Building Web UI MCP Servers
Protocol Overview
mcp2web is a protocol standard for MCP servers that serve HTML/CSS web UIs. An Electron mini-browser connects to these servers and renders their HTML content.
URI Schemes
- Resources:
mcp2web://<server-name>/<path>— HTML pages served as MCP resources withmimeType: "text/html" - Tool Actions:
mcp2web-tool://<tool-name>— used as<form action>attributes to trigger MCP tool calls
How It Works
- MCP server registers resources (HTML pages) and tools (form handlers)
- Electron browser connects via stdio, lists resources, renders them in a sandboxed iframe
- Links (
<a href="mcp2web://...">) trigger resource reads - Forms (
<form action="mcp2web-tool://...">) trigger tool calls - Tool responses containing HTML are rendered as new pages
Project Scaffolding
When creating a new mcp2web server, use this structure:
my-server/
├── package.json
├── tsconfig.json
└── src/
├── pages.ts # HTML page template functions
└── server.ts # MCP server setup, resources, tools
package.json
{
"type": "module",
"dependencies": {
"@modelcontextprotocol/sdk": "^1.12.1",
"zod": "^3.24.4"
},
"devDependencies": {
"typescript": "^5.8.3"
},
"scripts": {
"build": "tsc",
"start": "node dist/server.js"
}
}
tsconfig.json
{
"compilerOptions": {
"target": "ES2022",
"module": "NodeNext",
"moduleResolution": "NodeNext",
"outDir": "dist",
"rootDir": "src",
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true
},
"include": ["src/**/*"]
}
Resource Pattern
Register HTML pages as MCP resources:
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
const server = new McpServer({ name: "my-server", version: "1.0.0" });
server.resource("page-name", "mcp2web://my-server/page-name", async (uri) => ({
contents: [{
uri: uri.href,
mimeType: "text/html",
text: myPageFunction()
}]
}));
Tool Pattern
Register form handlers as MCP tools:
import { z } from "zod";
server.tool("submit-form", {
field1: z.string(),
field2: z.string()
}, async ({ field1, field2 }) => ({
content: [{
type: "text",
text: successPageFunction(field1, field2)
}]
}));
HTML/CSS Guidelines
MUST follow:
- All CSS must be inline (
<style>tags) — no external stylesheets - All pages must be complete HTML documents (
<!DOCTYPE html>,<html>,<head>,<body>) - Navigation links:
<a href="mcp2web://server-name/page-name"> - Form actions:
<form action="mcp2web-tool://tool-name" method="POST"> - Form inputs must have
nameattributes matching tool parameter names - No external JavaScript dependencies
- No external images or fonts (use inline SVG or CSS-only graphics)
Recommended:
- Dark theme for consistency with the mcp2web browser
- Responsive design with max-width containers
- Color scheme: bg #1a1a2e, text #e0e0e0, accent #0f3460, links #16c79a
- Clean typography with system fonts
Server Entry Point Pattern
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
// ... register resources and tools ...
const transport = new StdioServerTransport();
await server.connect(transport);
Security Considerations
- Content is rendered in a sandboxed iframe (
allow-scripts allow-forms, noallow-same-origin) - Never include sensitive data in HTML responses
- Validate all tool inputs with Zod schemas
- Pages cannot access the parent window or Node.js APIs
Security Best Practices
Always escape user input in HTML
User-provided strings must be HTML-escaped before embedding in templates to prevent XSS:
function escapeHtml(str: string): string {
return str
.replace(/&/g, "&")
.replace(/</g, "<")
.replace(/>/g, ">")
.replace(/"/g, """)
.replace(/'/g, "'");
}
// Use it when rendering user data
`<p>${escapeHtml(userInput)}</p>`
Add constraints to Zod schemas
Always set max lengths on string inputs and use format validators where applicable:
server.tool("example", {
name: z.string().max(100),
email: z.string().email().max(254),
description: z.string().max(1000),
}, async ({ name, email, description }) => { /* ... */ });
Use non-guessable IDs
Use crypto.randomUUID() (built-in to Node.js) instead of sequential integers for resource identifiers:
const item = {
id: crypto.randomUUID(), // e.g. "a1b2c3d4-..."
// ...
};