#!/usr/bin/env python3 """ MCP UI Server - standard MCP Apps protocol (SEP-1865). - tools/call returns structured data only (no HTML) - resources/read returns static HTML App files - Host renders HTML App in iframe, passes tool data via postMessage """ import asyncio import json import os from typing import Any, Dict from mcp_common import ( create_error_response, create_ping_response, create_tools_list_response, load_tools_from_json, handle_mcp_streaming, ) RESOURCE_MIME_TYPE = "text/html;profile=mcp-app" URI_MIME_TYPE = "text/uri-list" APPS_DIR = os.path.join(os.path.dirname(__file__), "apps") # Resource URI -> static HTML App file mapping RESOURCE_MAP = { "ui://mcp-ui/html": "html.html", "ui://mcp-ui/url": "url.html", "ui://mcp-ui/ask-user": "ask-user.html", } RESOURCE_DEFINITIONS = [ { "uri": "ui://mcp-ui/html", "name": "html-renderer", "title": "HTML Renderer", "description": "Renders custom HTML/CSS/JS content", "mimeType": RESOURCE_MIME_TYPE, }, { "uri": "ui://mcp-ui/url", "name": "url-embed", "title": "URL Embed", "description": "Embeds an external URL in an iframe", "mimeType": URI_MIME_TYPE, }, { "uri": "ui://mcp-ui/ask-user", "name": "ask-user", "title": "Ask User", "description": "Presents interactive questions with selectable options", "mimeType": RESOURCE_MIME_TYPE, }, ] def _load_app_html(uri: str) -> str: """Load static HTML App file for the given resource URI.""" filename = RESOURCE_MAP.get(uri) if not filename: raise ValueError(f"Unknown resource URI: {uri}") filepath = os.path.join(APPS_DIR, filename) with open(filepath, "r", encoding="utf-8") as f: return f.read() def _create_app_response(resource_uri: str, data: Dict[str, Any], width: str = "100%", height: str = "auto") -> Dict[str, Any]: """Create a tool result for MCP Apps protocol. Returns a JSON payload with type:"app" that tells the host to: 1. Fetch the static HTML App via resources/read(resourceUri) 2. Render it in a sandboxed iframe 3. Send `data` to the iframe via postMessage """ app_json = json.dumps({ "type": "app", "resourceUri": resource_uri, "data": data, "_meta": { "mcpui.dev/ui-preferred-frame-size": [width, height], }, }, ensure_ascii=False) return {"content": [{"type": "text", "text": app_json}]} # --------------------------------------------------------------------------- # Tool handlers — return data only # --------------------------------------------------------------------------- def _handle_render_html(arguments: Dict[str, Any]) -> Dict[str, Any]: html_content = arguments.get("html_content", "") if not html_content: raise ValueError("Missing required parameter: html_content") return _create_app_response( "ui://mcp-ui/html", {"html_content": html_content}, arguments.get("width", "100%"), arguments.get("height", "auto"), ) def _handle_render_url(arguments: Dict[str, Any]) -> Dict[str, Any]: url = arguments.get("url", "") if not url: raise ValueError("Missing required parameter: url") return _create_app_response( "ui://mcp-ui/url", {"url": url}, arguments.get("width", "100%"), arguments.get("height", "auto"), ) def _handle_ask_user(arguments: Dict[str, Any]) -> Dict[str, Any]: questions = arguments.get("questions", []) if not questions: raise ValueError("Missing required parameter: questions") return _create_app_response( "ui://mcp-ui/ask-user", {"questions": questions}, "100%", "auto", ) TOOL_HANDLERS = { "render_html": _handle_render_html, "render_url": _handle_render_url, "ask_user": _handle_ask_user, } async def handle_request(request: Dict[str, Any]) -> Dict[str, Any]: """Handle an MCP request.""" try: method = request.get("method") params = request.get("params", {}) request_id = request.get("id") if method == "initialize": return { "jsonrpc": "2.0", "id": request_id, "result": { "protocolVersion": "2024-11-05", "capabilities": { "tools": {}, "resources": {}, }, "serverInfo": { "name": "mcp-ui", "version": "3.0.0", }, }, } elif method == "ping": return create_ping_response(request_id) elif method == "tools/list": tools = load_tools_from_json("mcp_ui_tools.json") if not tools: tools = [ {"name": "render_html", "description": "Render custom HTML.", "inputSchema": {"type": "object", "properties": {"title": {"type": "string"}, "html_content": {"type": "string"}}, "required": ["title", "html_content"]}, "_meta": {"ui": {"resourceUri": "ui://mcp-ui/html"}}}, {"name": "render_url", "description": "Embed external URL.", "inputSchema": {"type": "object", "properties": {"title": {"type": "string"}, "url": {"type": "string"}}, "required": ["title", "url"]}, "_meta": {"ui": {"resourceUri": "ui://mcp-ui/url"}}}, {"name": "ask_user", "description": "Present questions with options.", "inputSchema": {"type": "object", "properties": {"title": {"type": "string"}, "questions": {"type": "array"}}, "required": ["title", "questions"]}, "_meta": {"ui": {"resourceUri": "ui://mcp-ui/ask-user"}}}, ] return create_tools_list_response(request_id, tools) elif method == "resources/list": return { "jsonrpc": "2.0", "id": request_id, "result": {"resources": RESOURCE_DEFINITIONS}, } elif method == "resources/read": uri = params.get("uri", "") try: html = _load_app_html(uri) except (ValueError, FileNotFoundError) as e: return create_error_response(request_id, -32602, str(e)) mime = RESOURCE_MIME_TYPE for rd in RESOURCE_DEFINITIONS: if rd["uri"] == uri: mime = rd["mimeType"] break return { "jsonrpc": "2.0", "id": request_id, "result": { "contents": [ { "uri": uri, "mimeType": mime, "text": html, } ] }, } elif method == "tools/call": tool_name = params.get("name") arguments = params.get("arguments", {}) handler = TOOL_HANDLERS.get(tool_name) if not handler: return create_error_response(request_id, -32601, f"Unknown tool: {tool_name}") try: result = handler(arguments) except ValueError as e: return create_error_response(request_id, -32602, str(e)) return {"jsonrpc": "2.0", "id": request_id, "result": result} else: return create_error_response(request_id, -32601, f"Unknown method: {method}") except Exception as e: return create_error_response( request.get("id"), -32603, f"Internal error: {str(e)}" ) async def main(): await handle_mcp_streaming(handle_request) if __name__ == "__main__": asyncio.run(main())