mirror of
https://github.com/PrefectHQ/fastmcp.git
synced 2026-08-24 06:24:18 +02:00
1598 lines
60 KiB
Python
1598 lines
60 KiB
Python
"""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 = """\
|
|
<!doctype html>
|
|
<html>
|
|
<head>
|
|
<meta charset="UTF-8">
|
|
<title>{title}</title>
|
|
{import_map_tag}
|
|
<style>
|
|
html, body {{ margin: 0; padding: 0; width: 100%; height: 100vh; overflow: hidden; }}
|
|
#app-frame {{ width: 100%; height: 100%; border: none; display: none; }}
|
|
#status {{
|
|
display: flex; align-items: center; justify-content: center; height: 100vh;
|
|
font-family: system-ui, sans-serif; color: #666; font-size: 1rem;
|
|
}}
|
|
</style>
|
|
</head>
|
|
<body>
|
|
<div id="status" style="display:{status_display}">{status_text}</div>
|
|
<iframe id="app-frame" style="display:{frame_display}"></iframe>
|
|
<script type="module">
|
|
import {{ AppBridge, PostMessageTransport }}
|
|
from "/js/app-bridge.js";
|
|
import {{ Client }}
|
|
from "https://esm.sh/@modelcontextprotocol/sdk@{mcp_sdk_version}/client/index.js";
|
|
import {{ StreamableHTTPClientTransport }}
|
|
from "https://esm.sh/@modelcontextprotocol/sdk@{mcp_sdk_version}/client/streamableHttp.js";
|
|
|
|
const status = document.getElementById("status");
|
|
const iframe = document.getElementById("app-frame");
|
|
|
|
async function main() {{
|
|
const client = new Client({{ name: "fastmcp-dev", version: "1.0.0" }});
|
|
await client.connect(
|
|
new StreamableHTTPClientTransport(new URL("/mcp", window.location.origin))
|
|
);
|
|
const serverCaps = client.getServerCapabilities();
|
|
|
|
// Set iframe src after adding load listener to avoid race condition
|
|
const loaded = new Promise(r => iframe.addEventListener("load", r, {{ once: true }}));
|
|
iframe.src = {iframe_src_json};
|
|
await loaded;
|
|
|
|
const transport = new PostMessageTransport(
|
|
iframe.contentWindow,
|
|
iframe.contentWindow,
|
|
);
|
|
const bridge = new AppBridge(
|
|
client,
|
|
{{ name: "fastmcp-dev", version: "1.0.0" }},
|
|
{{
|
|
openLinks: {{}},
|
|
serverTools: serverCaps?.tools,
|
|
serverResources: serverCaps?.resources,
|
|
}},
|
|
{{
|
|
hostContext: {{
|
|
theme: window.matchMedia("(prefers-color-scheme: dark)").matches
|
|
? "dark" : "light",
|
|
platform: "web",
|
|
containerDimensions: {{ maxHeight: 8000 }},
|
|
displayMode: "inline",
|
|
availableDisplayModes: ["inline", "fullscreen"],
|
|
}},
|
|
}},
|
|
);
|
|
|
|
bridge.onmessage = async () => ({{}});
|
|
{on_open_link}
|
|
{on_initialized}
|
|
|
|
await bridge.connect(transport);
|
|
}}
|
|
|
|
main().catch(err => {{
|
|
console.error(err);
|
|
if (status) {{
|
|
status.style.display = "flex";
|
|
status.textContent = "Error: " + err.message;
|
|
}}
|
|
}});
|
|
</script>
|
|
</body>
|
|
</html>
|
|
"""
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Host page HTML
|
|
# ---------------------------------------------------------------------------
|
|
|
|
_HOST_HTML_TEMPLATE = """\
|
|
<!doctype html>
|
|
<html>
|
|
<head>
|
|
<meta charset="UTF-8">
|
|
<title>FastMCP Dev — {tool_name}</title>
|
|
{import_map_tag}
|
|
<style>
|
|
html, body {{ margin: 0; padding: 0; width: 100%; height: 100vh; overflow: hidden; }}
|
|
#app-frame {{ width: 100%; height: 100%; border: none; display: none; }}
|
|
#status {{
|
|
display: flex; align-items: center; justify-content: center; height: 100vh;
|
|
font-family: system-ui, sans-serif; color: #666; font-size: 1rem;
|
|
}}
|
|
</style>
|
|
</head>
|
|
<body>
|
|
<div id="status">Launching {tool_name}…</div>
|
|
<iframe id="app-frame"></iframe>
|
|
<script type="module">
|
|
import {{ AppBridge, PostMessageTransport, getToolUiResourceUri }}
|
|
from "/js/app-bridge.js";
|
|
import {{ Client }}
|
|
from "https://esm.sh/@modelcontextprotocol/sdk@{mcp_sdk_version}/client/index.js";
|
|
import {{ StreamableHTTPClientTransport }}
|
|
from "https://esm.sh/@modelcontextprotocol/sdk@{mcp_sdk_version}/client/streamableHttp.js";
|
|
|
|
const toolName = {tool_name_json};
|
|
const toolArgs = {tool_args_json};
|
|
const status = document.getElementById("status");
|
|
const iframe = document.getElementById("app-frame");
|
|
|
|
async function main() {{
|
|
// Connect to the proxied MCP server (same-origin, no CORS needed)
|
|
const client = new Client({{ name: "fastmcp-dev", version: "1.0.0" }});
|
|
await client.connect(
|
|
new StreamableHTTPClientTransport(new URL("/mcp", window.location.origin))
|
|
);
|
|
|
|
// Find the tool and its UI resource URI
|
|
const {{ tools }} = await client.listTools();
|
|
const tool = tools.find(t => t.name === toolName);
|
|
if (!tool) throw new Error("Tool not found: " + toolName);
|
|
|
|
const uiUri = getToolUiResourceUri(tool);
|
|
if (!uiUri) throw new Error("Tool has no UI resource: " + toolName);
|
|
|
|
// The Prefab renderer calls earlyBridge.connect() at module-load time
|
|
// (synchronously, before React mounts) so it sends its ui/initialize
|
|
// request very early — potentially before the iframe's load event fires.
|
|
// Fix: create the AppBridge and call bridge.connect() BEFORE loading the
|
|
// iframe so our window.addEventListener is registered first. We pass
|
|
// null as the PostMessageTransport source so early messages from the
|
|
// not-yet-known renderer window are not filtered out. After the iframe
|
|
// loads we update transport.eventTarget / .eventSource to the real
|
|
// renderer window; the load-event microtask always runs before the
|
|
// message macrotask, so the response reaches the correct window.
|
|
const serverCaps = client.getServerCapabilities();
|
|
const transport = new PostMessageTransport(iframe.contentWindow, null);
|
|
const bridge = new AppBridge(
|
|
client,
|
|
{{ name: "fastmcp-dev", version: "1.0.0" }},
|
|
{{
|
|
openLinks: {{}},
|
|
serverTools: serverCaps?.tools,
|
|
serverResources: serverCaps?.resources,
|
|
}},
|
|
{{
|
|
hostContext: {{
|
|
theme: window.matchMedia("(prefers-color-scheme: dark)").matches
|
|
? "dark" : "light",
|
|
platform: "web",
|
|
containerDimensions: {{ maxHeight: 8000 }},
|
|
displayMode: "inline",
|
|
availableDisplayModes: ["inline", "fullscreen"],
|
|
}},
|
|
}},
|
|
);
|
|
|
|
bridge.onopenlink = async ({{ url }}) => {{
|
|
window.open(url, "_blank", "noopener,noreferrer");
|
|
return {{}};
|
|
}};
|
|
bridge.onmessage = async () => ({{}});
|
|
|
|
// When the View initializes: send input args, call the tool, send result
|
|
bridge.oninitialized = async () => {{
|
|
await bridge.sendToolInput({{ arguments: toolArgs }});
|
|
const result = await client.callTool({{ name: toolName, arguments: toolArgs }});
|
|
await bridge.sendToolResult(result);
|
|
status.style.display = "none";
|
|
iframe.style.display = "block";
|
|
}};
|
|
|
|
// Start listening before the iframe loads
|
|
await bridge.connect(transport);
|
|
|
|
// Now load the renderer HTML via the server-side proxy
|
|
const frameUrl = "/ui-resource?uri=" + encodeURIComponent(uiUri);
|
|
const loaded = new Promise(r => {{ iframe.addEventListener("load", r, {{ once: true }}); }});
|
|
iframe.src = frameUrl;
|
|
await loaded;
|
|
|
|
// Update transport to the real renderer window. This microtask runs
|
|
// before the ui/initialize message macrotask, ensuring the response
|
|
// is dispatched to the correct window.
|
|
transport.eventTarget = iframe.contentWindow;
|
|
transport.eventSource = iframe.contentWindow;
|
|
}}
|
|
|
|
main().catch(err => {{
|
|
status.textContent = "Error: " + err.message;
|
|
console.error(err);
|
|
}});
|
|
</script>
|
|
</body>
|
|
</html>
|
|
"""
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Dev log panel (injected into host pages)
|
|
# ---------------------------------------------------------------------------
|
|
|
|
_LOG_PANEL_HTML = """\
|
|
<style>
|
|
#mcp-log-panel {
|
|
position: fixed; top: 0; left: 0; bottom: 0; width: 360px;
|
|
z-index: 10000;
|
|
font-family: ui-monospace, SFMono-Regular, "SF Mono", Menlo, monospace;
|
|
font-size: 12px; background: #1e1e2e; color: #cdd6f4;
|
|
border-right: 1px solid #45475a;
|
|
display: flex; flex-direction: column;
|
|
}
|
|
#mcp-log-panel.hidden { display: none; }
|
|
#app-frame {
|
|
width: calc(100% - 360px) !important; height: 100% !important;
|
|
margin-left: 360px !important;
|
|
}
|
|
#mcp-log-resize {
|
|
position: absolute; right: -3px; top: 0; bottom: 0; width: 6px;
|
|
cursor: col-resize; z-index: 1;
|
|
}
|
|
#mcp-log-resize:hover, #mcp-log-resize.active { background: #585b70; }
|
|
#mcp-log-header {
|
|
display: flex; justify-content: space-between; align-items: center;
|
|
padding: 10px 12px; background: #181825;
|
|
border-bottom: 1px solid #45475a; flex-shrink: 0;
|
|
}
|
|
#mcp-log-brand {
|
|
display: flex; align-items: center; gap: 8px;
|
|
}
|
|
#mcp-log-brand svg { flex-shrink: 0; }
|
|
#mcp-log-brand-text {
|
|
font-weight: 700; font-size: 13px; color: #cdd6f4;
|
|
letter-spacing: -0.3px;
|
|
}
|
|
#mcp-log-count-badge {
|
|
font-size: 11px; color: #6c7086; font-weight: 400;
|
|
}
|
|
#mcp-log-actions { display: flex; gap: 6px; }
|
|
#mcp-log-actions button {
|
|
background: #313244; color: #cdd6f4; border: 1px solid #45475a;
|
|
padding: 2px 8px; border-radius: 3px; cursor: pointer;
|
|
font-size: 11px; font-family: inherit;
|
|
}
|
|
#mcp-log-actions button:hover { background: #45475a; }
|
|
#mcp-log-entries { flex: 1; overflow-y: auto; }
|
|
.log-entry {
|
|
padding: 6px 12px; border-bottom: 1px solid #232334; cursor: pointer;
|
|
}
|
|
.log-entry:hover { background: #313244; }
|
|
.log-entry.error { background: rgba(243, 139, 168, 0.08); }
|
|
.log-entry.error:hover { background: rgba(243, 139, 168, 0.14); }
|
|
.log-entry.error .log-method { color: #f38ba8; }
|
|
.log-primary {
|
|
display: flex; justify-content: space-between;
|
|
align-items: baseline; gap: 8px;
|
|
}
|
|
.log-left {
|
|
display: flex; gap: 6px; align-items: baseline; min-width: 0;
|
|
}
|
|
.log-dir { flex-shrink: 0; }
|
|
.log-dir.request { color: #89b4fa; }
|
|
.log-dir.response { color: #a6e3a1; }
|
|
.log-dir.error { color: #f38ba8; }
|
|
.log-dir.bridge { color: #cba6f7; }
|
|
.log-dir.notification { color: #fab387; }
|
|
.log-method {
|
|
color: #f9e2af; font-weight: 600;
|
|
white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
|
|
}
|
|
.log-meta { color: #6c7086; font-size: 11px; white-space: nowrap; flex-shrink: 0; }
|
|
.log-subtitle {
|
|
color: #a6adc8; font-size: 11px; padding-left: 22px;
|
|
overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
|
|
margin-top: 1px;
|
|
}
|
|
.log-detail {
|
|
display: none; padding: 8px 12px 4px 22px; background: #11111b;
|
|
white-space: pre-wrap; word-break: break-all;
|
|
color: #bac2de; font-size: 11px; line-height: 1.4;
|
|
margin-top: 4px; border-radius: 4px;
|
|
}
|
|
.log-entry.expanded .log-detail { display: block; }
|
|
@keyframes log-flash {
|
|
from { background: rgba(137, 180, 250, 0.22); }
|
|
to { background: transparent; }
|
|
}
|
|
@keyframes log-flash-error {
|
|
from { background: rgba(243, 139, 168, 0.25); }
|
|
to { background: rgba(243, 139, 168, 0.08); }
|
|
}
|
|
.log-entry.new { animation: log-flash 2s ease-out; }
|
|
.log-entry.error.new { animation: log-flash-error 2s ease-out; }
|
|
.log-copy {
|
|
opacity: 0; transition: opacity 0.15s;
|
|
background: #313244; color: #a6adc8; border: 1px solid #45475a;
|
|
padding: 1px 6px; border-radius: 3px; cursor: pointer;
|
|
font-size: 10px; font-family: inherit; flex-shrink: 0;
|
|
}
|
|
.log-entry:hover .log-copy { opacity: 1; }
|
|
.log-copy:hover { background: #45475a; color: #cdd6f4; }
|
|
#mcp-log-open {
|
|
position: fixed; bottom: 12px; left: 12px; z-index: 10000;
|
|
background: #181825; color: #cdd6f4; border: 1px solid #45475a;
|
|
padding: 6px 12px; border-radius: 6px; cursor: pointer;
|
|
font-family: ui-monospace, SFMono-Regular, "SF Mono", Menlo, monospace;
|
|
font-size: 11px; display: none;
|
|
}
|
|
#mcp-log-open:hover { background: #313244; }
|
|
#mcp-log-filters {
|
|
display: flex; gap: 8px; align-items: center;
|
|
padding: 6px 12px; border-bottom: 1px solid #45475a; flex-shrink: 0;
|
|
}
|
|
.log-seg {
|
|
display: inline-flex; border: 1px solid #45475a; border-radius: 6px;
|
|
overflow: hidden;
|
|
}
|
|
.log-seg button {
|
|
background: transparent; color: #6c7086; border: none;
|
|
border-right: 1px solid #45475a; padding: 3px 10px; cursor: pointer;
|
|
font-size: 10px; font-family: inherit; transition: all 0.15s;
|
|
}
|
|
.log-seg button:last-child { border-right: none; }
|
|
.log-seg button:hover { background: rgba(205, 214, 244, 0.06); }
|
|
.log-seg button.active[data-filter="tools"] { background: rgba(137, 180, 250, 0.15); color: #89b4fa; }
|
|
.log-seg button.active[data-filter="notifications"] { background: rgba(250, 179, 135, 0.15); color: #fab387; }
|
|
.log-seg button.active[data-filter="bridge"] { background: rgba(203, 166, 247, 0.15); color: #cba6f7; }
|
|
.log-seg button.active[data-filter="errors"] { background: rgba(243, 139, 168, 0.15); color: #f38ba8; }
|
|
#mcp-log-filters-label {
|
|
font-size: 9px; color: #6c7086; text-transform: uppercase;
|
|
letter-spacing: 0.5px; font-weight: 600;
|
|
}
|
|
#mcp-log-level-select {
|
|
background: #313244; color: #cdd6f4; border: 1px solid #45475a;
|
|
border-radius: 6px; padding: 3px 8px; cursor: pointer;
|
|
font-size: 10px; font-family: inherit;
|
|
}
|
|
#mcp-log-level-select option { background: #1e1e2e; }
|
|
.log-level {
|
|
font-size: 9px; padding: 0 5px; border-radius: 3px;
|
|
font-weight: 600; text-transform: uppercase; letter-spacing: 0.3px;
|
|
flex-shrink: 0; line-height: 16px;
|
|
}
|
|
.log-level-debug { background: #313244; color: #6c7086; }
|
|
.log-level-info { background: rgba(137, 180, 250, 0.15); color: #89b4fa; }
|
|
.log-level-warning { background: rgba(249, 226, 175, 0.15); color: #f9e2af; }
|
|
.log-level-error { background: rgba(243, 139, 168, 0.15); color: #f38ba8; }
|
|
.log-level-notice { background: rgba(148, 226, 213, 0.15); color: #94e2d5; }
|
|
.log-level-critical { background: rgba(243, 139, 168, 0.2); color: #f38ba8; }
|
|
.log-level-alert { background: rgba(243, 139, 168, 0.25); color: #f38ba8; }
|
|
.log-level-emergency { background: rgba(243, 139, 168, 0.3); color: #f38ba8; }
|
|
</style>
|
|
<div id="mcp-log-panel">
|
|
<div id="mcp-log-resize"></div>
|
|
<div id="mcp-log-header">
|
|
<div id="mcp-log-brand">
|
|
<svg width="20" height="20" viewBox="0 0 196 196" fill="none" xmlns="http://www.w3.org/2000/svg"><path d="M145.747 44.611L145.355 44.3877L144.96 44.611L86.0283 78.5276V171.267L86.4014 171.499L99.6674 179.667V86.3859L159 52.2379L145.747 44.611Z" fill="#cdd6f4"/><path d="M121.616 30.2714L121.224 30.0454L120.832 30.2714L61.8975 64.188V156.928L62.2732 157.156L75.5393 165.325V72.0463L134.869 37.8983L121.616 30.2714Z" fill="#cdd6f4"/><path d="M97.4894 16.3818L97.0973 16.1558L96.7025 16.3818L37.7705 50.3038V142.066L51.4096 150.463V58.1567L110.742 24.0086L97.4894 16.3818Z" fill="#cdd6f4"/><path d="M131.23 113.671L124.979 117.266L124.584 117.494V117.5L116.796 121.987L110.547 125.581L110.152 125.807V141.51L144.564 121.709V121.698L158.999 113.394V97.6851L139.277 109.034L131.23 113.671Z" fill="#cdd6f4"/></svg>
|
|
<span id="mcp-log-brand-text">FastMCP Apps</span>
|
|
<span id="mcp-log-count-badge">\u00b7 <span id="mcp-log-count">0</span></span>
|
|
</div>
|
|
<div id="mcp-log-actions">
|
|
<button id="mcp-log-clear">Clear</button>
|
|
<button id="mcp-log-close">\u00d7</button>
|
|
</div>
|
|
</div>
|
|
<div id="mcp-log-filters">
|
|
<span id="mcp-log-filters-label">Show</span>
|
|
<div class="log-seg">
|
|
<button class="active" data-filter="tools">Tools</button>
|
|
<button class="active" data-filter="notifications">Logs</button>
|
|
<button class="active" data-filter="bridge">Host</button>
|
|
<button class="active" data-filter="errors">Errors</button>
|
|
</div>
|
|
<select id="mcp-log-level-select">
|
|
<option value="debug">Debug+</option>
|
|
<option value="info">Info+</option>
|
|
<option value="warning">Warn+</option>
|
|
<option value="error">Error+</option>
|
|
<option value="critical">Critical+</option>
|
|
</select>
|
|
</div>
|
|
<div id="mcp-log-entries"></div>
|
|
</div>
|
|
<button id="mcp-log-open">MCP Log</button>
|
|
<script>
|
|
(function() {
|
|
var lastId = 0, totalCount = 0, panelWidth = 360;
|
|
var panel = document.getElementById("mcp-log-panel");
|
|
var entries = document.getElementById("mcp-log-entries");
|
|
var countEl = document.getElementById("mcp-log-count");
|
|
var openBtn = document.getElementById("mcp-log-open");
|
|
var resizeHandle = document.getElementById("mcp-log-resize");
|
|
|
|
function setFrameLayout(w) {
|
|
var frame = document.getElementById("app-frame");
|
|
if (!frame) return;
|
|
frame.style.setProperty("width", w, "important");
|
|
frame.style.setProperty("margin-left", w === "100%" ? "0" : panelWidth + "px", "important");
|
|
}
|
|
|
|
document.getElementById("mcp-log-close").addEventListener("click", function() {
|
|
panel.classList.add("hidden");
|
|
openBtn.style.display = "block";
|
|
setFrameLayout("100%");
|
|
});
|
|
|
|
openBtn.addEventListener("click", function() {
|
|
panel.classList.remove("hidden");
|
|
openBtn.style.display = "none";
|
|
setFrameLayout("calc(100% - " + panelWidth + "px)");
|
|
});
|
|
|
|
resizeHandle.addEventListener("mousedown", function(e) {
|
|
e.preventDefault();
|
|
resizeHandle.classList.add("active");
|
|
var frame = document.getElementById("app-frame");
|
|
if (frame) frame.style.pointerEvents = "none";
|
|
document.addEventListener("mousemove", onResize);
|
|
document.addEventListener("mouseup", stopResize);
|
|
});
|
|
|
|
function onResize(e) {
|
|
var w = Math.max(200, Math.min(e.clientX, window.innerWidth * 0.8));
|
|
panelWidth = w;
|
|
panel.style.width = w + "px";
|
|
setFrameLayout("calc(100% - " + w + "px)");
|
|
}
|
|
|
|
function stopResize() {
|
|
resizeHandle.classList.remove("active");
|
|
var frame = document.getElementById("app-frame");
|
|
if (frame) frame.style.pointerEvents = "";
|
|
document.removeEventListener("mousemove", onResize);
|
|
document.removeEventListener("mouseup", stopResize);
|
|
}
|
|
|
|
document.getElementById("mcp-log-clear").addEventListener("click", function() {
|
|
entries.innerHTML = "";
|
|
totalCount = 0;
|
|
countEl.textContent = "0";
|
|
fetch("/api/logs/clear", { method: "POST" });
|
|
});
|
|
|
|
var activeFilters = {tools: true, notifications: true, bridge: true, errors: true};
|
|
var levelOrder = ["debug", "info", "notice", "warning", "error", "critical", "alert", "emergency"];
|
|
var minLevel = 0;
|
|
|
|
document.getElementById("mcp-log-filters").addEventListener("click", function(e) {
|
|
var btn = e.target.closest("[data-filter]");
|
|
if (!btn) return;
|
|
var f = btn.dataset.filter;
|
|
activeFilters[f] = !activeFilters[f];
|
|
btn.classList.toggle("active", activeFilters[f]);
|
|
applyFilters();
|
|
});
|
|
|
|
document.getElementById("mcp-log-level-select").addEventListener("change", function(e) {
|
|
minLevel = levelOrder.indexOf(e.target.value);
|
|
applyFilters();
|
|
});
|
|
|
|
function shouldShow(el) {
|
|
var cat = el.dataset.category || "";
|
|
if (activeFilters[cat] === false) return false;
|
|
var lv = el.dataset.level;
|
|
if (lv && levelOrder.indexOf(lv) < minLevel) return false;
|
|
return true;
|
|
}
|
|
|
|
function applyFilters() {
|
|
var items = entries.querySelectorAll(".log-entry");
|
|
for (var i = 0; i < items.length; i++) {
|
|
items[i].style.display = shouldShow(items[i]) ? "" : "none";
|
|
}
|
|
}
|
|
|
|
function summarize(entry) {
|
|
var b = entry.body;
|
|
if (!b) return "";
|
|
if (entry.direction === "request" || entry.direction === "notification") {
|
|
if (b.method === "tools/call" && b.params) return b.params.name || "";
|
|
if (b.method === "resources/read" && b.params) return b.params.uri || "";
|
|
if (b.method === "notifications/message" && b.params) {
|
|
var d = b.params.data;
|
|
if (d && typeof d === "object") return d.msg || d.message || JSON.stringify(d);
|
|
return d || b.params.level || "";
|
|
}
|
|
return "";
|
|
}
|
|
if (b.error) return "error: " + (b.error.message || JSON.stringify(b.error));
|
|
if (b.result && typeof b.result === "object") {
|
|
if (Array.isArray(b.result.tools)) return b.result.tools.length + " tools";
|
|
if (Array.isArray(b.result.resources)) return b.result.resources.length + " resources";
|
|
if (Array.isArray(b.result.prompts)) return b.result.prompts.length + " prompts";
|
|
if (b.result.content) {
|
|
var first = b.result.content[0];
|
|
if (first && first.text) {
|
|
return first.text.length > 60 ? first.text.slice(0, 60) + "\u2026" : first.text;
|
|
}
|
|
return b.result.content.length + " content item(s)";
|
|
}
|
|
}
|
|
return "";
|
|
}
|
|
|
|
function formatTime(ts) {
|
|
var d = new Date(ts * 1000);
|
|
return String(d.getHours()).padStart(2, "0") + ":"
|
|
+ String(d.getMinutes()).padStart(2, "0") + ":"
|
|
+ String(d.getSeconds()).padStart(2, "0");
|
|
}
|
|
|
|
function renderEntry(entry) {
|
|
var div = document.createElement("div");
|
|
var isError = entry.direction === "response" && entry.body
|
|
&& (entry.body.error || (entry.body.result && entry.body.result.isError));
|
|
div.className = "log-entry" + (isError ? " error" : "");
|
|
var dirClass = isError ? "error" : entry.direction;
|
|
var arrows = {request: "\u2192", response: "\u2190", bridge: "\u2191", notification: "\u2193"};
|
|
|
|
// Categorize for filtering
|
|
if (isError) div.dataset.category = "errors";
|
|
else if (entry.direction === "bridge") div.dataset.category = "bridge";
|
|
else if (entry.direction === "notification") div.dataset.category = "notifications";
|
|
else div.dataset.category = "tools";
|
|
|
|
var primary = document.createElement("div");
|
|
primary.className = "log-primary";
|
|
|
|
var left = document.createElement("div");
|
|
left.className = "log-left";
|
|
|
|
var dirEl = document.createElement("span");
|
|
dirEl.className = "log-dir " + dirClass;
|
|
dirEl.textContent = arrows[entry.direction] || "\u2190";
|
|
|
|
var methodEl = document.createElement("span");
|
|
methodEl.className = "log-method";
|
|
methodEl.textContent = entry.method || "";
|
|
|
|
left.appendChild(dirEl);
|
|
left.appendChild(methodEl);
|
|
|
|
// Log level badge for notifications
|
|
if (entry.direction === "notification" && entry.body && entry.body.params) {
|
|
var level = (entry.body.params.level || "").toLowerCase();
|
|
if (level) {
|
|
div.dataset.level = level;
|
|
var lvl = document.createElement("span");
|
|
lvl.className = "log-level log-level-" + level;
|
|
lvl.textContent = level;
|
|
left.appendChild(lvl);
|
|
}
|
|
}
|
|
|
|
var metaEl = document.createElement("span");
|
|
metaEl.className = "log-meta";
|
|
metaEl.textContent = entry.duration_ms != null
|
|
? entry.duration_ms + "ms"
|
|
: formatTime(entry.timestamp);
|
|
|
|
var copyBtn = document.createElement("button");
|
|
copyBtn.className = "log-copy";
|
|
copyBtn.textContent = "Copy";
|
|
copyBtn.addEventListener("click", function(e) {
|
|
e.stopPropagation();
|
|
navigator.clipboard.writeText(JSON.stringify(entry.body, null, 2));
|
|
copyBtn.textContent = "Copied";
|
|
setTimeout(function() { copyBtn.textContent = "Copy"; }, 1000);
|
|
});
|
|
|
|
primary.appendChild(left);
|
|
primary.appendChild(metaEl);
|
|
primary.appendChild(copyBtn);
|
|
div.appendChild(primary);
|
|
|
|
var summary = summarize(entry);
|
|
if (summary) {
|
|
var subtitle = document.createElement("div");
|
|
subtitle.className = "log-subtitle";
|
|
subtitle.textContent = summary;
|
|
div.appendChild(subtitle);
|
|
}
|
|
|
|
var detail = document.createElement("div");
|
|
detail.className = "log-detail";
|
|
detail.textContent = JSON.stringify(entry.body, null, 2);
|
|
div.appendChild(detail);
|
|
|
|
div.addEventListener("click", function() { div.classList.toggle("expanded"); });
|
|
return div;
|
|
}
|
|
|
|
var polling = false;
|
|
function poll() {
|
|
if (polling) return;
|
|
polling = true;
|
|
fetch("/api/logs?since=" + lastId)
|
|
.then(function(r) { return r.ok ? r.json() : []; })
|
|
.then(function(data) {
|
|
if (!data || !data.length) return;
|
|
lastId = data[data.length - 1].id;
|
|
totalCount += data.length;
|
|
countEl.textContent = String(totalCount);
|
|
var atBottom = entries.scrollHeight - entries.scrollTop - entries.clientHeight < 40;
|
|
for (var i = 0; i < data.length; i++) {
|
|
var el = renderEntry(data[i]);
|
|
el.classList.add("new");
|
|
if (!shouldShow(el)) el.style.display = "none";
|
|
entries.appendChild(el);
|
|
}
|
|
if (atBottom) entries.scrollTop = entries.scrollHeight;
|
|
})
|
|
.catch(function() {})
|
|
.finally(function() { polling = false; });
|
|
}
|
|
|
|
window.addEventListener("message", function(event) {
|
|
var data = event.data;
|
|
if (typeof data === "string") {
|
|
try { data = JSON.parse(data); } catch(e) { return; }
|
|
}
|
|
if (!data || typeof data !== "object") return;
|
|
if (!data.jsonrpc && !data.method) return;
|
|
fetch("/api/logs/bridge", {
|
|
method: "POST",
|
|
headers: {"Content-Type": "application/json"},
|
|
body: JSON.stringify({body: data})
|
|
});
|
|
});
|
|
|
|
setInterval(poll, 500);
|
|
poll();
|
|
})();
|
|
</script>
|
|
"""
|
|
|
|
|
|
def _inject_log_panel(html: str) -> str:
|
|
"""Inject the MCP message log panel before </body>."""
|
|
return html.replace("</body>", _LOG_PANEL_HTML + "\n</body>")
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# 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 "<html><body><p>prefab-ui not installed. Run: pip install fastmcp[apps]</p></body></html>"
|
|
|
|
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 ``<script type="importmap">`` tag.
|
|
|
|
Background
|
|
----------
|
|
esm.sh's ``zod@x.y.z/es2022/v4.mjs`` only re-exports ``{z, default}``,
|
|
losing all individual named exports (``custom``, ``string``, etc.). The
|
|
MCP SDK does ``import * as t from "zod/v4"`` and calls ``t.custom(…)``
|
|
which fails. ``zod@x.y.z/es2022/v4/classic/index.mjs`` exports everything
|
|
correctly. An import-map that remaps the broken URL to the working one
|
|
fixes all modules in the page's graph, including those loaded cross-origin
|
|
from esm.sh.
|
|
|
|
ext-apps app-bridge.js imports the SDK via bare specifiers
|
|
(``@modelcontextprotocol/sdk/types.js`` etc.) that the browser cannot
|
|
resolve. We rewrite them to concrete esm.sh URLs before serving.
|
|
"""
|
|
cache_path = (
|
|
Path(tempfile.gettempdir())
|
|
/ f"fastmcp-ext-apps-{version}-sdk-{sdk_version}-bundle.json"
|
|
)
|
|
if cache_path.exists():
|
|
cached = json.loads(cache_path.read_text())
|
|
return cached["app_bridge_js"], cached["import_map_json"]
|
|
|
|
# -- Download and patch app-bridge.js -----------------------------------
|
|
npm_url = f"https://registry.npmjs.org/@modelcontextprotocol/ext-apps/-/ext-apps-{version}.tgz"
|
|
with urllib.request.urlopen(npm_url) as resp:
|
|
data = resp.read()
|
|
|
|
with tarfile.open(fileobj=io.BytesIO(data), mode="r:gz") as tar:
|
|
member = tar.extractfile("package/dist/src/app-bridge.js")
|
|
if member is None:
|
|
raise RuntimeError("app-bridge.js not found in ext-apps tarball")
|
|
app_bridge_js = member.read().decode()
|
|
|
|
# Rewrite bare SDK module specifiers to concrete esm.sh URLs
|
|
sdk_base = f"https://esm.sh/@modelcontextprotocol/sdk@{sdk_version}"
|
|
for sdk_path in ("types.js", "shared/protocol.js"):
|
|
app_bridge_js = app_bridge_js.replace(
|
|
f'from"@modelcontextprotocol/sdk/{sdk_path}"',
|
|
f'from"{sdk_base}/{sdk_path}"',
|
|
)
|
|
|
|
# -- Detect the broken Zod v4.mjs URL -----------------------------------
|
|
# The SDK's types module imports zod/v4 via a version-range URL like
|
|
# /zod@^4.3.5/v4?target=es2022. That wrapper re-exports from the
|
|
# version-specific v4.mjs (e.g. /zod@4.3.6/es2022/v4.mjs) which is
|
|
# broken. We fetch the wrapper to discover the exact version.
|
|
types_url = f"{sdk_base}/types.js"
|
|
with urllib.request.urlopen(types_url) as resp:
|
|
types_content = resp.read().decode()
|
|
|
|
# Extract the zod/v4?target=es2022 path from the types.js redirect
|
|
zod_wrapper_match = re.search(r'import "(/zod@[^"]*v4[^"]*)"', types_content)
|
|
if not zod_wrapper_match:
|
|
raise RuntimeError(
|
|
f"Could not find zod/v4 import in {types_url}:\n{types_content[:500]}"
|
|
)
|
|
zod_wrapper_path = zod_wrapper_match.group(1) # e.g. /zod@^4.3.5/v4?target=es2022
|
|
|
|
zod_wrapper_url = f"https://esm.sh{zod_wrapper_path}"
|
|
with urllib.request.urlopen(zod_wrapper_url) as resp:
|
|
wrapper_content = resp.read().decode()
|
|
|
|
# The wrapper does: export * from "/zod@4.3.6/es2022/v4.mjs"
|
|
broken_match = re.search(
|
|
r'export \* from "(/zod@[\d.]+/es2022/v4\.mjs)"', wrapper_content
|
|
)
|
|
if not broken_match:
|
|
raise RuntimeError(
|
|
f"Could not find v4.mjs re-export in {zod_wrapper_url}:\n{wrapper_content[:500]}"
|
|
)
|
|
broken_path = broken_match.group(1) # e.g. /zod@4.3.6/es2022/v4.mjs
|
|
zod_version = broken_path.split("@")[1].split("/")[0] # e.g. 4.3.6
|
|
|
|
broken_url = f"https://esm.sh{broken_path}"
|
|
fixed_url = f"https://esm.sh/zod@{zod_version}/es2022/v4/classic/index.mjs"
|
|
|
|
import_map_json = json.dumps({"imports": {broken_url: fixed_url}})
|
|
|
|
# -- Cache and return ----------------------------------------------------
|
|
cache_path.write_text(
|
|
json.dumps({"app_bridge_js": app_bridge_js, "import_map_json": import_map_json})
|
|
)
|
|
return app_bridge_js, import_map_json
|
|
|
|
|
|
async def _fetch_app_bridge_bundle(
|
|
version: str,
|
|
sdk_version: str,
|
|
) -> tuple[str, str]:
|
|
"""Async wrapper around _fetch_app_bridge_bundle_sync."""
|
|
loop = asyncio.get_running_loop()
|
|
return await loop.run_in_executor(
|
|
None, _fetch_app_bridge_bundle_sync, version, sdk_version
|
|
)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# FastAPI dev server
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def _make_dev_app(
|
|
mcp_url: str,
|
|
app_bridge_js: str,
|
|
import_map_tag: str,
|
|
message_log: _MessageLog,
|
|
) -> Starlette:
|
|
"""Build the Starlette dev server application."""
|
|
|
|
async def picker(request: Request) -> HTMLResponse:
|
|
"""AppBridge host page — loads the picker app in an iframe and wires the bridge."""
|
|
host_html = _HOST_SHELL.format(
|
|
title="FastMCP Apps",
|
|
import_map_tag=import_map_tag,
|
|
status_text="",
|
|
status_display="none",
|
|
frame_display="block",
|
|
mcp_sdk_version=_MCP_SDK_VERSION,
|
|
iframe_src_json=json.dumps("/picker-app"),
|
|
on_open_link="bridge.onopenlink = async ({ url }) => { window.location.href = url; return {}; };",
|
|
on_initialized="bridge.oninitialized = async () => {};",
|
|
)
|
|
return HTMLResponse(_inject_log_panel(host_html))
|
|
|
|
async def picker_app(request: Request) -> HTMLResponse:
|
|
"""Prefab picker UI — tool list with one tab per UI tool."""
|
|
try:
|
|
raw_tools = await _list_tools(mcp_url)
|
|
ui_tools = [t for t in raw_tools if _has_ui_resource(t)]
|
|
html = _build_picker_html(ui_tools)
|
|
except Exception as exc:
|
|
logger.exception("Error building picker UI")
|
|
html = f"<pre style='padding:2rem;color:red'>Error: {exc}</pre>"
|
|
return HTMLResponse(html)
|
|
|
|
async def launch(request: Request) -> HTMLResponse:
|
|
"""Host page: GET /launch?tool=name&args={...}"""
|
|
tool = request.query_params.get("tool", "")
|
|
args_raw = request.query_params.get("args", "{}")
|
|
tool_args = json.loads(args_raw)
|
|
host_html = _HOST_HTML_TEMPLATE.format(
|
|
tool_name=tool,
|
|
import_map_tag=import_map_tag,
|
|
tool_name_json=json.dumps(tool),
|
|
tool_args_json=json.dumps(tool_args),
|
|
mcp_sdk_version=_MCP_SDK_VERSION,
|
|
)
|
|
return HTMLResponse(_inject_log_panel(host_html))
|
|
|
|
async def api_launch(request: Request) -> Response:
|
|
"""Picker form submits here; returns a /launch URL string for OpenLink."""
|
|
data = await request.json()
|
|
tool = data.pop("tool", "")
|
|
# Remaining keys are tool arguments. Form inputs always produce
|
|
# strings, but some parameters expect dicts/lists — try to parse
|
|
# string values that look like JSON objects or arrays.
|
|
tool_args: dict[str, Any] = {}
|
|
for k, v in data.items():
|
|
if isinstance(v, str):
|
|
stripped = v.strip()
|
|
if stripped and stripped[0] in ("{", "["):
|
|
try:
|
|
parsed = json.loads(stripped)
|
|
if isinstance(parsed, (dict, list)):
|
|
v = parsed
|
|
except (json.JSONDecodeError, TypeError):
|
|
pass
|
|
tool_args[k] = v
|
|
args_json = quote(json.dumps(tool_args))
|
|
url = f"/launch?tool={tool}&args={args_json}"
|
|
return Response(
|
|
content=json.dumps(url),
|
|
media_type="application/json",
|
|
)
|
|
|
|
async def ui_resource(request: Request) -> Response:
|
|
"""Fetch an MCP resource server-side and return it as HTML.
|
|
|
|
Used by the launch page to load the renderer via iframe.src rather
|
|
than iframe.srcdoc — avoids a race condition where the Prefab renderer
|
|
sends its MCP initialize message before the AppBridge transport is
|
|
listening (srcdoc parses and runs module scripts synchronously, while
|
|
iframe.src load adds the network-roundtrip gap needed).
|
|
"""
|
|
uri = request.query_params.get("uri", "")
|
|
if not uri:
|
|
return Response("Missing uri parameter", status_code=400)
|
|
html = await _read_mcp_resource(mcp_url, uri)
|
|
if html is None:
|
|
return Response(f"Could not read MCP resource: {uri}", status_code=502)
|
|
return HTMLResponse(html)
|
|
|
|
async def serve_app_bridge_js(request: Request) -> Response:
|
|
"""Serve the locally patched app-bridge.js."""
|
|
return Response(
|
|
content=app_bridge_js,
|
|
media_type="application/javascript",
|
|
)
|
|
|
|
async def proxy_mcp(request: Request) -> Response:
|
|
"""Proxy all MCP requests to the user's server (avoids browser CORS)."""
|
|
body = await request.body()
|
|
|
|
# Log MCP requests
|
|
if body and request.method == "POST":
|
|
try:
|
|
req_json = json.loads(body)
|
|
if isinstance(req_json, list):
|
|
for item in req_json:
|
|
if isinstance(item, dict):
|
|
message_log.log_request(item)
|
|
elif isinstance(req_json, dict):
|
|
message_log.log_request(req_json)
|
|
except (json.JSONDecodeError, TypeError):
|
|
pass
|
|
|
|
headers = {
|
|
k: v
|
|
for k, v in request.headers.items()
|
|
if k.lower() not in ("host", "content-length")
|
|
}
|
|
|
|
client = httpx.AsyncClient(timeout=None)
|
|
|
|
async def _stream_and_cleanup(resp: httpx.Response) -> Any:
|
|
is_sse = "text/event-stream" in resp.headers.get("content-type", "")
|
|
buf: list[bytes] = []
|
|
sse_buf = ""
|
|
try:
|
|
async for chunk in resp.aiter_bytes():
|
|
yield chunk
|
|
if is_sse:
|
|
# Parse SSE events incrementally
|
|
sse_buf += chunk.decode("utf-8", errors="replace")
|
|
while "\r\n\r\n" in sse_buf or "\n\n" in sse_buf:
|
|
# Split on whichever double-newline appears first
|
|
ri = sse_buf.find("\r\n\r\n")
|
|
ni = sse_buf.find("\n\n")
|
|
if ri >= 0 and (ni < 0 or ri < ni):
|
|
event, sse_buf = sse_buf[:ri], sse_buf[ri + 4 :]
|
|
else:
|
|
event, sse_buf = sse_buf[:ni], sse_buf[ni + 2 :]
|
|
for line in event.splitlines():
|
|
if line.startswith("data: "):
|
|
with contextlib.suppress(json.JSONDecodeError):
|
|
message_log.log_response(json.loads(line[6:]))
|
|
else:
|
|
buf.append(chunk)
|
|
except (
|
|
httpx.RemoteProtocolError,
|
|
httpx.ReadError,
|
|
httpcore.RemoteProtocolError,
|
|
):
|
|
pass # Connection closed during shutdown — not an error
|
|
finally:
|
|
# Log non-SSE responses (JSON) after stream completes
|
|
if buf:
|
|
_log_response_bytes(message_log, b"".join(buf), "application/json")
|
|
with contextlib.suppress(Exception):
|
|
await resp.aclose()
|
|
with contextlib.suppress(Exception):
|
|
await client.aclose()
|
|
|
|
try:
|
|
req = client.build_request(
|
|
method=request.method,
|
|
url=mcp_url,
|
|
content=body,
|
|
headers=headers,
|
|
params=dict(request.query_params),
|
|
)
|
|
resp = await client.send(req, stream=True)
|
|
content_type = resp.headers.get("content-type", "")
|
|
# Strip hop-by-hop headers that shouldn't be forwarded
|
|
fwd_headers = {
|
|
k: v
|
|
for k, v in resp.headers.items()
|
|
if k.lower()
|
|
not in (
|
|
"transfer-encoding",
|
|
"connection",
|
|
"keep-alive",
|
|
"content-encoding",
|
|
)
|
|
}
|
|
return StreamingResponse(
|
|
_stream_and_cleanup(resp),
|
|
status_code=resp.status_code,
|
|
headers=fwd_headers,
|
|
media_type=content_type or "application/octet-stream",
|
|
)
|
|
except httpx.ConnectError:
|
|
await client.aclose()
|
|
return Response(
|
|
content=json.dumps({"error": "MCP server not reachable"}).encode(),
|
|
status_code=503,
|
|
media_type="application/json",
|
|
)
|
|
|
|
async def api_logs(request: Request) -> Response:
|
|
"""Return message log entries since a given id."""
|
|
since = int(request.query_params.get("since", "0"))
|
|
entries = message_log.get_since(since)
|
|
return Response(
|
|
content=json.dumps(entries),
|
|
media_type="application/json",
|
|
)
|
|
|
|
async def api_logs_bridge(request: Request) -> Response:
|
|
"""Log a bridge message (postMessage between app iframe and host)."""
|
|
data = await request.json()
|
|
message_log.log_bridge(data.get("body", data))
|
|
return Response(content="{}", media_type="application/json")
|
|
|
|
async def api_logs_clear(request: Request) -> Response:
|
|
"""Clear the message log."""
|
|
message_log.clear()
|
|
return Response(content="{}", media_type="application/json")
|
|
|
|
return Starlette(
|
|
routes=[
|
|
Route("/", picker),
|
|
Route("/picker-app", picker_app),
|
|
Route("/launch", launch),
|
|
Route("/api/launch", api_launch, methods=["POST"]),
|
|
Route("/api/logs", api_logs),
|
|
Route("/api/logs/bridge", api_logs_bridge, methods=["POST"]),
|
|
Route("/api/logs/clear", api_logs_clear, methods=["POST"]),
|
|
Route("/ui-resource", ui_resource),
|
|
Route("/js/app-bridge.js", serve_app_bridge_js),
|
|
Route(
|
|
"/mcp",
|
|
proxy_mcp,
|
|
methods=["GET", "POST", "DELETE", "PUT", "PATCH", "OPTIONS"],
|
|
),
|
|
]
|
|
)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Launch helpers
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
async def _start_user_server(
|
|
server_spec: str,
|
|
mcp_port: int,
|
|
*,
|
|
reload: bool = True,
|
|
) -> asyncio.subprocess.Process:
|
|
"""Start the user's MCP server as a subprocess on mcp_port."""
|
|
cmd = [
|
|
sys.executable,
|
|
"-m",
|
|
"fastmcp.cli",
|
|
"run",
|
|
server_spec,
|
|
"--transport",
|
|
"http",
|
|
"--port",
|
|
str(mcp_port),
|
|
"--no-banner",
|
|
]
|
|
if reload:
|
|
cmd.append("--reload")
|
|
else:
|
|
cmd.append("--no-reload")
|
|
env = {**os.environ, "FASTMCP_LOG_LEVEL": "WARNING"}
|
|
process = await asyncio.create_subprocess_exec(
|
|
*cmd,
|
|
env=env,
|
|
start_new_session=sys.platform != "win32",
|
|
)
|
|
return process
|
|
|
|
|
|
async def _wait_for_server(url: str, timeout: float = 15.0) -> bool:
|
|
"""Poll until the server is accepting connections."""
|
|
loop = asyncio.get_running_loop()
|
|
deadline = loop.time() + timeout
|
|
async with httpx.AsyncClient() as client:
|
|
while loop.time() < deadline:
|
|
try:
|
|
await client.get(url, timeout=1.0)
|
|
return True
|
|
except (
|
|
httpx.ConnectError,
|
|
httpx.RemoteProtocolError,
|
|
httpx.TimeoutException,
|
|
):
|
|
await asyncio.sleep(0.25)
|
|
return False
|
|
|
|
|
|
async def run_dev_apps(
|
|
server_spec: str,
|
|
*,
|
|
mcp_port: int = 8000,
|
|
dev_port: int = 8080,
|
|
reload: bool = True,
|
|
) -> None:
|
|
"""Start the full dev environment for a FastMCPApp server.
|
|
|
|
Starts the user's MCP server on *mcp_port*, starts the Prefab dev UI
|
|
on *dev_port* (with an /mcp proxy to the user's server), then opens
|
|
the browser.
|
|
"""
|
|
mcp_url = f"http://localhost:{mcp_port}/mcp"
|
|
dev_url = f"http://localhost:{dev_port}"
|
|
|
|
user_proc: asyncio.subprocess.Process | None = None
|
|
|
|
async def _body() -> None:
|
|
nonlocal user_proc
|
|
|
|
logger.info(f"Starting user server on port {mcp_port}…")
|
|
logger.info("Fetching app-bridge.js from npm…")
|
|
|
|
# Start the server first so user_proc is assigned before anything
|
|
# that might fail (e.g. npm fetch). This ensures the finally
|
|
# cleanup can kill the subprocess even if the bundle fetch raises.
|
|
user_proc = await _start_user_server(server_spec, mcp_port, reload=reload)
|
|
app_bridge_js, import_map_json = await _fetch_app_bridge_bundle(
|
|
_EXT_APPS_VERSION, _MCP_SDK_VERSION
|
|
)
|
|
|
|
import_map_tag = (
|
|
f' <script type="importmap">\n {import_map_json}\n </script>'
|
|
)
|
|
|
|
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()
|