mirror of
https://github.com/PrefectHQ/fastmcp.git
synced 2026-08-22 05:24:18 +02:00
Add MCP message inspector to dev apps UI (#3570)
This commit is contained in:
parent
faef74b374
commit
85faad59a1
2 changed files with 753 additions and 8 deletions
120
examples/apps/inspector_demo.py
Normal file
120
examples/apps/inspector_demo.py
Normal file
|
|
@ -0,0 +1,120 @@
|
|||
"""Demo server for testing the dev apps MCP message inspector.
|
||||
|
||||
Exercises tool calls, server notifications (ctx.log), and errors
|
||||
so you can verify all message types appear in the inspector panel.
|
||||
|
||||
Usage:
|
||||
fastmcp dev apps examples/apps/inspector_demo.py
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from prefab_ui.actions import ShowToast
|
||||
from prefab_ui.actions.mcp import CallTool, SendMessage, UpdateContext
|
||||
from prefab_ui.components import (
|
||||
Badge,
|
||||
Button,
|
||||
Column,
|
||||
Heading,
|
||||
Muted,
|
||||
Row,
|
||||
)
|
||||
from prefab_ui.rx import ERROR
|
||||
|
||||
from fastmcp import FastMCP
|
||||
from fastmcp.server.context import Context
|
||||
|
||||
mcp = FastMCP("Inspector Demo")
|
||||
|
||||
|
||||
@mcp.tool(app=True)
|
||||
def demo() -> Column:
|
||||
"""A demo app that exercises various MCP message types."""
|
||||
with Column(gap=6, css_class="p-8 max-w-lg") as view:
|
||||
Heading("Inspector Demo")
|
||||
Muted("Click the buttons and watch the inspector panel on the right.")
|
||||
|
||||
with Column(gap=3):
|
||||
with Row(gap=2, align="center"):
|
||||
Button(
|
||||
"Call Tool",
|
||||
variant="default",
|
||||
on_click=CallTool(
|
||||
"echo",
|
||||
arguments={"message": "Hello from the inspector!"},
|
||||
on_success=ShowToast("Tool call succeeded", variant="success"),
|
||||
on_error=ShowToast(ERROR, variant="error"),
|
||||
),
|
||||
)
|
||||
Badge("tools/call + response", variant="secondary")
|
||||
|
||||
with Row(gap=2, align="center"):
|
||||
Button(
|
||||
"Call with Logging",
|
||||
variant="default",
|
||||
on_click=CallTool(
|
||||
"echo_with_logs",
|
||||
arguments={"message": "Watch the notifications!"},
|
||||
on_success=ShowToast("Done (check logs)", variant="success"),
|
||||
on_error=ShowToast(ERROR, variant="error"),
|
||||
),
|
||||
)
|
||||
Badge("tools/call + notifications", variant="secondary")
|
||||
|
||||
with Row(gap=2, align="center"):
|
||||
Button(
|
||||
"Trigger Error",
|
||||
variant="destructive",
|
||||
on_click=CallTool(
|
||||
"fail",
|
||||
arguments={},
|
||||
on_error=ShowToast(ERROR, variant="error"),
|
||||
),
|
||||
)
|
||||
Badge("error response", variant="destructive")
|
||||
|
||||
with Row(gap=2, align="center"):
|
||||
Button(
|
||||
"Update Context",
|
||||
variant="outline",
|
||||
on_click=[
|
||||
UpdateContext(content="Demo context from inspector"),
|
||||
ShowToast("Context updated", variant="success"),
|
||||
],
|
||||
)
|
||||
Badge("bridge: UpdateContext", variant="outline")
|
||||
|
||||
with Row(gap=2, align="center"):
|
||||
Button(
|
||||
"Send Message",
|
||||
variant="outline",
|
||||
on_click=SendMessage("Tell me about this demo app"),
|
||||
)
|
||||
Badge("bridge: SendMessage", variant="outline")
|
||||
|
||||
return view
|
||||
|
||||
|
||||
@mcp.tool()
|
||||
def echo(message: str) -> str:
|
||||
"""Echo a message back."""
|
||||
return f"Echo: {message}"
|
||||
|
||||
|
||||
@mcp.tool()
|
||||
async def echo_with_logs(message: str, ctx: Context) -> str:
|
||||
"""Echo a message and emit log notifications."""
|
||||
await ctx.log(f"Processing: {message}", level="info")
|
||||
await ctx.log("Step 1: validated input", level="debug")
|
||||
await ctx.log("Step 2: generating response", level="debug")
|
||||
return f"Logged echo: {message}"
|
||||
|
||||
|
||||
@mcp.tool()
|
||||
def fail() -> str:
|
||||
"""Always raises an error."""
|
||||
raise ValueError("This is a deliberate error for testing the inspector")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
mcp.run()
|
||||
|
|
@ -35,6 +35,7 @@ import signal
|
|||
import sys
|
||||
import tarfile
|
||||
import tempfile
|
||||
import time
|
||||
import urllib.request
|
||||
import webbrowser
|
||||
from pathlib import Path
|
||||
|
|
@ -53,6 +54,124 @@ 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.
|
||||
|
|
@ -280,6 +399,451 @@ _HOST_HTML_TEMPLATE = """\
|
|||
</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)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
|
@ -373,11 +937,11 @@ def _build_picker_html(tools: list[dict[str, Any]]) -> str:
|
|||
|
||||
if not tools:
|
||||
with Column(gap=4, css_class="p-6 max-w-2xl mx-auto") as view:
|
||||
Heading("FastMCP App Preview")
|
||||
Heading("FastMCP Apps")
|
||||
Muted(
|
||||
"No UI tools found on this server. Use @app.ui() to register entry-point tools."
|
||||
)
|
||||
return PrefabApp(title="FastMCP App Preview", view=view).html()
|
||||
return PrefabApp(title="FastMCP Apps", view=view).html()
|
||||
|
||||
first_name: str = tools[0]["name"]
|
||||
|
||||
|
|
@ -385,7 +949,7 @@ def _build_picker_html(tools: list[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 App Preview")
|
||||
Heading("FastMCP Apps")
|
||||
|
||||
if len(tools) > 1:
|
||||
with Column(gap=1):
|
||||
|
|
@ -437,7 +1001,7 @@ def _build_picker_html(tools: list[dict[str, Any]]) -> str:
|
|||
css_class="text-xs text-muted-foreground text-right",
|
||||
)
|
||||
|
||||
return PrefabApp(title="FastMCP App Preview", view=view).html()
|
||||
return PrefabApp(title="FastMCP Apps", view=view).html()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
|
|
@ -607,13 +1171,14 @@ 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 App Preview",
|
||||
title="FastMCP Apps",
|
||||
import_map_tag=import_map_tag,
|
||||
status_text="",
|
||||
status_display="none",
|
||||
|
|
@ -623,7 +1188,7 @@ def _make_dev_app(
|
|||
on_open_link="bridge.onopenlink = async ({ url }) => { window.location.href = url; return {}; };",
|
||||
on_initialized="bridge.oninitialized = async () => {};",
|
||||
)
|
||||
return HTMLResponse(host_html)
|
||||
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."""
|
||||
|
|
@ -648,7 +1213,7 @@ def _make_dev_app(
|
|||
tool_args_json=json.dumps(tool_args),
|
||||
mcp_sdk_version=_MCP_SDK_VERSION,
|
||||
)
|
||||
return HTMLResponse(host_html)
|
||||
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."""
|
||||
|
|
@ -690,6 +1255,20 @@ def _make_dev_app(
|
|||
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()
|
||||
|
|
@ -699,9 +1278,29 @@ def _make_dev_app(
|
|||
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,
|
||||
|
|
@ -709,6 +1308,9 @@ def _make_dev_app(
|
|||
):
|
||||
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):
|
||||
|
|
@ -750,12 +1352,35 @@ def _make_dev_app(
|
|||
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(
|
||||
|
|
@ -864,7 +1489,7 @@ async def run_dev_apps(
|
|||
|
||||
logger.info(f"FastMCP dev UI at {dev_url}")
|
||||
|
||||
dev_app = _make_dev_app(mcp_url, app_bridge_js, import_map_tag)
|
||||
dev_app = _make_dev_app(mcp_url, app_bridge_js, import_map_tag, _MessageLog())
|
||||
config = uvicorn.Config(
|
||||
dev_app,
|
||||
host="localhost",
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue