From 85faad59a15930c7fb3bced8ffffed40a71659a6 Mon Sep 17 00:00:00 2001 From: Jeremiah Lowin <153965+jlowin@users.noreply.github.com> Date: Sat, 21 Mar 2026 12:20:16 -0400 Subject: [PATCH] Add MCP message inspector to dev apps UI (#3570) --- examples/apps/inspector_demo.py | 120 ++++++ src/fastmcp/cli/apps_dev.py | 641 +++++++++++++++++++++++++++++++- 2 files changed, 753 insertions(+), 8 deletions(-) create mode 100644 examples/apps/inspector_demo.py diff --git a/examples/apps/inspector_demo.py b/examples/apps/inspector_demo.py new file mode 100644 index 000000000..2d5d244bc --- /dev/null +++ b/examples/apps/inspector_demo.py @@ -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() diff --git a/src/fastmcp/cli/apps_dev.py b/src/fastmcp/cli/apps_dev.py index b05578ec9..8e7cc65fb 100644 --- a/src/fastmcp/cli/apps_dev.py +++ b/src/fastmcp/cli/apps_dev.py @@ -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 = """\ """ +# --------------------------------------------------------------------------- +# 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) # --------------------------------------------------------------------------- @@ -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",