"""Dev server for previewing FastMCPApp UIs locally. Starts the user's MCP server on a configurable port, then starts a lightweight Starlette dev server that: - Serves a Prefab-based tool picker at GET / - Proxies /mcp to the user's server (avoids browser CORS restrictions) - Serves the AppBridge host page at GET /launch The host page uses @modelcontextprotocol/ext-apps to connect to the MCP server and render the selected UI tool inside an iframe. Startup sequence ---------------- 1. Download ext-apps app-bridge.js from npm and patch its bare ``@modelcontextprotocol/sdk/…`` imports to use concrete esm.sh URLs. 2. Detect the exact Zod v4 module URL that esm.sh serves for that SDK version and build an import-map entry that redirects the broken ``v4.mjs`` (which only re-exports ``{z, default}``) to ``v4/classic/index.mjs`` (which correctly exports every named Zod v4 function). Import maps apply to the full module graph in the document, including cross-origin esm.sh modules. 3. Serve both the patched JS and the import-map JSON from the dev server. """ from __future__ import annotations import asyncio import contextlib import io import json import logging import os import re import signal import sys import tarfile import tempfile import time import urllib.request import webbrowser from pathlib import Path from typing import Any from urllib.parse import quote import httpcore import httpx import uvicorn from starlette.applications import Starlette from starlette.requests import Request from starlette.responses import HTMLResponse, Response, StreamingResponse from starlette.routing import Route from fastmcp.utilities.logging import get_logger logger = get_logger(__name__) # --------------------------------------------------------------------------- # MCP message log (captures proxy traffic for the dev UI log panel) # --------------------------------------------------------------------------- class _MessageLog: """In-memory buffer of MCP JSON-RPC messages flowing through the proxy.""" def __init__(self) -> None: self._entries: list[dict[str, Any]] = [] self._counter = 0 self._request_methods: dict[int | str, str] = {} self._request_times: dict[int | str, float] = {} def log_request(self, body: dict[str, Any]) -> None: method = body.get("method", "unknown") jsonrpc_id = body.get("id") timestamp = time.time() if jsonrpc_id is not None: self._request_methods[jsonrpc_id] = method self._request_times[jsonrpc_id] = timestamp self._counter += 1 self._entries.append( { "id": self._counter, "timestamp": timestamp, "direction": "request", "method": method, "body": body, } ) def log_response(self, body: dict[str, Any]) -> None: # Server-initiated notifications have "method" but no "id" if "method" in body and "id" not in body: self._counter += 1 self._entries.append( { "id": self._counter, "timestamp": time.time(), "direction": "notification", "method": body.get("method", "unknown"), "body": body, } ) return jsonrpc_id = body.get("id") method = ( self._request_methods.pop(jsonrpc_id, None) if jsonrpc_id is not None else None ) request_time = ( self._request_times.pop(jsonrpc_id, None) if jsonrpc_id is not None else None ) timestamp = time.time() duration_ms = ( round((timestamp - request_time) * 1000, 1) if request_time else None ) self._counter += 1 self._entries.append( { "id": self._counter, "timestamp": timestamp, "direction": "response", "method": method, "body": body, "duration_ms": duration_ms, } ) def get_since(self, since_id: int = 0) -> list[dict[str, Any]]: return [e for e in self._entries if e["id"] > since_id] def log_bridge(self, body: dict[str, Any]) -> None: method = body.get("method", "unknown") self._counter += 1 self._entries.append( { "id": self._counter, "timestamp": time.time(), "direction": "bridge", "method": method, "body": body, } ) def clear(self) -> None: self._entries.clear() self._request_methods.clear() self._request_times.clear() def _log_response_bytes(log: _MessageLog, raw: bytes, content_type: str) -> None: """Parse accumulated proxy response bytes and log as message entries.""" if not raw: return try: if "text/event-stream" in content_type: for line in raw.decode("utf-8", errors="replace").splitlines(): if line.startswith("data: "): with contextlib.suppress(json.JSONDecodeError): log.log_response(json.loads(line[6:])) else: body = json.loads(raw) if isinstance(body, list): for item in body: log.log_response(item) else: log.log_response(body) except (json.JSONDecodeError, TypeError): pass _EXT_APPS_VERSION = "1.0.1" # Pin to the SDK version ext-apps 1.0.1 was compiled against so the client # and transport modules are API-compatible with the app-bridge internals. _MCP_SDK_VERSION = "1.25.2" # --------------------------------------------------------------------------- # Shared AppBridge host shell # --------------------------------------------------------------------------- # Both the picker and the app launcher use the same host-page structure: an # iframe that hosts a Prefab renderer, wired to the MCP server via AppBridge. # The only differences are (a) which URL loads in the iframe and (b) what # oninitialized does. # # app-bridge.js is served locally (see _fetch_app_bridge_bundle). # Client/Transport are loaded from esm.sh. # The import map (injected as {import_map_tag}) patches the broken esm.sh # Zod v4 module so all Zod named exports are visible to the SDK at runtime. _HOST_SHELL = """\ {title} {import_map_tag}
{status_text}
""" # --------------------------------------------------------------------------- # Host page HTML # --------------------------------------------------------------------------- _HOST_HTML_TEMPLATE = """\ FastMCP Dev — {tool_name} {import_map_tag}
Launching {tool_name}…
""" # --------------------------------------------------------------------------- # Dev log panel (injected into host pages) # --------------------------------------------------------------------------- _LOG_PANEL_HTML = """\
FastMCP Apps \u00b7 0
Show
""" def _inject_log_panel(html: str) -> str: """Inject the MCP message log panel before .""" return html.replace("", _LOG_PANEL_HTML + "\n") # --------------------------------------------------------------------------- # Picker UI (Prefab-based, built in Python) # --------------------------------------------------------------------------- def _has_ui_resource(tool: dict[str, Any]) -> bool: """Return True if the tool has a UI resourceUri in its metadata.""" for key in ("meta", "_meta"): m = tool.get(key) if isinstance(m, dict): ui = m.get("ui") if isinstance(ui, dict) and ui.get("resourceUri"): return True return False def _model_from_schema(tool_name: str, input_schema: dict[str, Any]) -> type[Any]: """Dynamically create a Pydantic model from a JSON Schema for form generation.""" import pydantic import pydantic.fields properties: dict[str, Any] = input_schema.get("properties") or {} required: list[str] = input_schema.get("required") or [] field_definitions: dict[str, Any] = {} for prop_name, prop in properties.items(): json_type = prop.get("type", "string") # Handle anyOf / oneOf (union types like str | dict | None) for key in ("anyOf", "oneOf"): if key in prop: non_null = [ t for t in prop[key] if isinstance(t, dict) and t.get("type") != "null" ] if non_null: types = [t.get("type") for t in non_null if "type" in t] for candidate in ( "object", "array", "integer", "number", "boolean", "string", ): if candidate in types: json_type = candidate break break match json_type: case "integer": py_type: type = int case "number": py_type = float case "boolean": py_type = bool case "object" | "array": # Render as a string textarea; api_launch parses JSON later py_type = str case _: py_type = str title = prop.get("title") or prop_name.replace("_", " ").title() description = prop.get("description") is_required = prop_name in required if is_required: default = pydantic.fields.PydanticUndefined elif "default" in prop: default = prop["default"] else: default = None py_type = py_type | None # type: ignore[assignment] extra: dict[str, Any] = {} if prop.get("enum"): from typing import Literal py_type = Literal[tuple(prop["enum"])] # type: ignore[assignment] # Textarea detection: # 1. Explicit format: "textarea" in JSON schema # 2. UI annotation: {"ui": {"type": "textarea"}} (json_schema_extra merged flat) # 3. Object/array types need multiline JSON editing use_textarea = ( prop.get("format") == "textarea" or ( isinstance(prop.get("ui"), dict) and prop["ui"].get("type") == "textarea" ) or json_type in ("object", "array") ) if use_textarea: extra["json_schema_extra"] = {"ui": {"type": "textarea"}} field_definitions[prop_name] = ( py_type, pydantic.Field( default=default, title=title, description=description, **extra ), ) return pydantic.create_model(f"{tool_name.title()}Form", **field_definitions) def _build_picker_html(tools: list[dict[str, Any]]) -> str: """Build Prefab picker page: dropdown selector with per-tool forms.""" try: from prefab_ui.actions import Fetch, OpenLink, SetState, ShowToast from prefab_ui.app import PrefabApp from prefab_ui.components import ( Button, Column, Heading, Label, Markdown, Muted, Page, Pages, Select, SelectOption, ) from prefab_ui.components.form import Form from prefab_ui.rx import RESULT, Rx except ImportError: return "

prefab-ui not installed. Run: pip install fastmcp[apps]

" if not tools: with Column(gap=4, css_class="p-6 max-w-2xl mx-auto") as view: Heading("FastMCP Apps") Muted( "No UI tools found on this server. Use @app.ui() to register entry-point tools." ) return PrefabApp(title="FastMCP Apps", view=view).html() first_name: str = tools[0]["name"] def _tool_title(tool: dict[str, Any]) -> str: return tool.get("title") or tool["name"] with Column(gap=6, css_class="p-8 max-w-lg mx-auto") as view: Heading("FastMCP Apps") if len(tools) > 1: with Column(gap=1): Label("Tool") with Select( placeholder="Choose a tool…", on_change=SetState("activeTool", Rx("$event")), ): for tool in tools: SelectOption( _tool_title(tool), value=tool["name"], selected=tool["name"] == first_name, ) else: Heading(_tool_title(tools[0]), level=3) with Pages(name="activeTool", value=first_name): for tool in tools: name: str = tool["name"] desc: str = tool.get("description") or "" input_schema: dict[str, Any] = tool.get("inputSchema") or {} model = _model_from_schema(name, input_schema) body: dict[str, Any] = {"tool": name} for field_name in model.model_fields: body[field_name] = Rx(field_name) with Page(name, value=name), Column(gap=4): if desc: Muted(desc, css_class="pb-2") with Form( on_submit=Fetch.post( "/api/launch", body=body, on_success=OpenLink(RESULT), on_error=ShowToast(Rx("$error"), variant="error"), # type: ignore[arg-type] ), ): Form.from_model(model, fields_only=True) Button( "Launch", variant="success", button_type="submit", ) Markdown( "Generated by [Prefab](https://prefab.prefect.io) 🎨", css_class="text-xs text-muted-foreground text-right", ) return PrefabApp(title="FastMCP Apps", view=view).html() # --------------------------------------------------------------------------- # MCP tool listing helper # --------------------------------------------------------------------------- async def _list_tools(mcp_url: str) -> list[dict[str, Any]]: """Return raw tool dicts from the MCP server at mcp_url.""" try: from mcp import ClientSession from mcp.client.streamable_http import streamable_http_client except ImportError: return [] try: async with streamable_http_client(mcp_url) as (read, write, _): # noqa: SIM117 async with ClientSession(read, write) as session: await session.initialize() result = await session.list_tools() return [t.model_dump() for t in result.tools] except Exception as exc: logger.debug(f"Could not list tools from {mcp_url}: {exc}") return [] async def _read_mcp_resource(mcp_url: str, uri: str) -> str | None: """Read an MCP resource by URI and return its text content.""" try: from mcp import ClientSession from mcp.client.streamable_http import streamable_http_client from pydantic import AnyUrl except ImportError: return None try: async with streamable_http_client(mcp_url) as (read, write, _): # noqa: SIM117 async with ClientSession(read, write) as session: await session.initialize() result = await session.read_resource(AnyUrl(uri)) for content in result.contents: text = getattr(content, "text", None) if text: return text return None except Exception as exc: logger.debug(f"Could not read resource {uri} from {mcp_url}: {exc}") return None # --------------------------------------------------------------------------- # app-bridge.js download, patch, and Zod import-map generation # --------------------------------------------------------------------------- def _fetch_app_bridge_bundle_sync( version: str, sdk_version: str, ) -> tuple[str, str]: """Download app-bridge.js and build an import-map that fixes Zod v4 on esm.sh. Returns ``(app_bridge_js, import_map_json)`` where *import_map_json* is a JSON string ready to embed in a ``' ) ready = await _wait_for_server(mcp_url, timeout=15.0) if not ready: raise RuntimeError(f"User server did not start on port {mcp_port}") logger.info(f"FastMCP dev UI at {dev_url}") dev_app = _make_dev_app(mcp_url, app_bridge_js, import_map_tag, _MessageLog()) config = uvicorn.Config( dev_app, host="localhost", port=dev_port, log_level="warning", ws="websockets-sansio", ) server = uvicorn.Server(config) # Suppress uvicorn's own signal handlers — they use signal.signal() which # conflicts with asyncio and causes hangs. We cancel the task instead. server.install_signal_handlers = lambda: None # type: ignore[method-assign] async def _open_browser() -> None: await asyncio.sleep(0.8) webbrowser.open(dev_url) await asyncio.gather(server.serve(), _open_browser()) # Register signal handlers before any work starts so that Ctrl+C during # startup (server spawn, npm fetch, server-ready poll) is handled the same # way as Ctrl+C during the running phase — both cancel the body task and # fall through to the cleanup finally block. loop = asyncio.get_running_loop() task = asyncio.ensure_future(_body()) def _on_signal() -> None: # Silence uvicorn's error logger before cancelling so that the # CancelledError propagating through uvicorn doesn't get logged as # an ERROR during the forced shutdown. logging.getLogger("uvicorn.error").setLevel(logging.CRITICAL) task.cancel() if sys.platform != "win32": loop.add_signal_handler(signal.SIGINT, _on_signal) loop.add_signal_handler(signal.SIGTERM, _on_signal) try: await task except asyncio.CancelledError: pass finally: if sys.platform != "win32": loop.remove_signal_handler(signal.SIGINT) loop.remove_signal_handler(signal.SIGTERM) if user_proc is not None and user_proc.returncode is None: # Kill the entire process group (not just the top-level process) # because --reload creates a watcher that spawns child processes. # Killing only the watcher leaves the actual server holding the port. try: if sys.platform != "win32": os.killpg(os.getpgid(user_proc.pid), signal.SIGTERM) else: user_proc.kill() except (ProcessLookupError, PermissionError): user_proc.kill() await user_proc.wait()