From 690b34f3dc08008f76df7ab1e492de6eb8ffe67e Mon Sep 17 00:00:00 2001 From: Ciaran Sweet Date: Wed, 22 Jul 2026 20:41:40 +0100 Subject: [PATCH 1/4] feat(mcp-runtime): serve toolset UI views as MCP resources A toolset can ship a view: a build-time HTML bundle a UI-capable host renders in an iframe, fed the tool's structuredContent. Opt in with a VIEWS ({tool_name: view_id}) export and a bundle at /views/.html. The runtime does two standard-MCP things: serves each view as a ui:/// resource and stamps the owning tool's _meta with that URI (the mcp-ui / Apps-SDK convention). Nothing executes at call time, so the runtime stays pure-Python. VIEWS entries and bundles are validated at build_server time, the same gate as the ToolResult contract. Co-Authored-By: Claude Opus 4.8 --- .../mcp-runtime/src/mcp_runtime/server.py | 10 +- packages/mcp-runtime/src/mcp_runtime/views.py | 110 ++++++++++++++++++ packages/mcp-runtime/tests/test_views.py | 100 ++++++++++++++++ 3 files changed, 219 insertions(+), 1 deletion(-) create mode 100644 packages/mcp-runtime/src/mcp_runtime/views.py create mode 100644 packages/mcp-runtime/tests/test_views.py diff --git a/packages/mcp-runtime/src/mcp_runtime/server.py b/packages/mcp-runtime/src/mcp_runtime/server.py index 2c454dc..0c6717b 100644 --- a/packages/mcp-runtime/src/mcp_runtime/server.py +++ b/packages/mcp-runtime/src/mcp_runtime/server.py @@ -21,6 +21,7 @@ from starlette.responses import JSONResponse, Response from mcp_runtime.fastmcp_output import to_fastmcp +from mcp_runtime.views import load_views, register_views, with_view_meta class RuntimeSettings(BaseSettings): @@ -80,13 +81,20 @@ def build_server( module_name = module_name or toolset_module_name(toolset) tools = load_tools(module_name) credential_headers = load_credential_headers(module_name) + views = load_views(module_name) + + fastmcp_tools = with_view_meta( + toolset, module_name, [to_fastmcp(tool) for tool in tools], views + ) + server = FastMCP( name=f"mcp-{toolset}", - tools=[to_fastmcp(tool) for tool in tools], + tools=fastmcp_tools, host=host, port=port, stateless_http=True, ) + register_views(server, toolset, module_name, views) tool_names = [tool.name for tool in tools] diff --git a/packages/mcp-runtime/src/mcp_runtime/views.py b/packages/mcp-runtime/src/mcp_runtime/views.py new file mode 100644 index 0000000..7529415 --- /dev/null +++ b/packages/mcp-runtime/src/mcp_runtime/views.py @@ -0,0 +1,110 @@ +"""Optional per-toolset UI views. + +A view is a build-time, self-contained HTML bundle (e.g. a React app built by +Vite with everything inlined) that a UI-capable MCP host renders in a sandboxed +iframe, fed the tool's ``structuredContent``. It is standard MCP: the runtime +serves each view as a **resource** ``ui:///`` and stamps the +owning tool's ``_meta`` with that URI — exactly what Claude web / mcp-ui clients +read. Nothing here executes at call time, so the runtime stays pure-Python. + +A toolset opts in with two things, both validated at ``build_server`` time (a +view naming an unknown tool, or a missing bundle, aborts startup): + +- ``VIEWS`` — a ``{tool_name: view_id}`` export in the tools module, and +- a built bundle at ``/views/.html``. + +Views are **progressive enhancement**: the tool's ``message`` and +``structuredContent`` still stand alone in a plain client. Credentials never +reach the iframe — put pre-signed URLs in the ``ToolResult`` instead of tokens. +""" + +import importlib +from pathlib import Path + +from mcp.server.fastmcp import FastMCP +from mcp.server.fastmcp.resources import FunctionResource +from mcp.server.fastmcp.tools import Tool as FastMCPTool +from pydantic import AnyUrl + +# The ``_meta`` a UI-capable host reads: ``{"ui": {"resourceUri": "ui://..."}}``, +# following the emerging mcp-ui / Apps-SDK convention. +VIEW_META_KEY = "ui" + + +def view_resource_uri(toolset: str, view_id: str) -> str: + """The ``ui://`` resource URI a view is served under.""" + return f"ui://{toolset}/{view_id}" + + +def load_views(module_name: str) -> dict[str, str]: + """A tools module's optional ``VIEWS`` export (tool name -> view id).""" + views = getattr(importlib.import_module(module_name), "VIEWS", {}) + if not isinstance(views, dict) or not all( + isinstance(t, str) and t and isinstance(v, str) and v for t, v in views.items() + ): + raise RuntimeError( + f"{module_name}.VIEWS must be a dict of tool name -> view id" + ) + return dict(views) + + +def view_html(module_name: str, view_id: str) -> str: + """Read a view's built HTML bundle from ``/views/``, or raise.""" + location = importlib.import_module(module_name.rsplit(".", 1)[0]).__file__ + if location is None: + raise RuntimeError(f"cannot locate the package directory for {module_name!r}") + path = Path(location).parent / "views" / f"{view_id}.html" + if not path.is_file(): + raise RuntimeError( + f"view {view_id!r} has no bundle at {path} — build the toolset's ui/ " + "(vite build) so /views/.html exists" + ) + return path.read_text(encoding="utf-8") + + +def with_view_meta( + toolset: str, + module_name: str, + tools: list[FastMCPTool], + views: dict[str, str], +) -> list[FastMCPTool]: + """Return ``tools`` with each view-bearing tool's ``_meta`` stamped. + + Pure: inputs are left untouched; each view-owning tool is replaced by a copy + carrying its ``ui://`` resource URI under ``_meta``. Validates first that + every ``VIEWS`` entry names a real tool with a built bundle, raising (naming + the offender) otherwise — so a broken view fails ``build_server``. + """ + names = {tool.name for tool in tools} + for tool_name, view_id in views.items(): + if tool_name not in names: + raise RuntimeError( + f"{module_name}.VIEWS names tool {tool_name!r}, which is not in TOOLS" + ) + view_html(module_name, view_id) # raise now if the bundle is missing + + def stamped(tool: FastMCPTool) -> FastMCPTool: + if tool.name not in views: + return tool + uri = view_resource_uri(toolset, views[tool.name]) + return tool.model_copy( + update={"meta": {**(tool.meta or {}), VIEW_META_KEY: {"resourceUri": uri}}} + ) + + return [stamped(tool) for tool in tools] + + +def register_views( + server: FastMCP, toolset: str, module_name: str, views: dict[str, str] +) -> None: + """Register each view as an MCP resource (``ui:///``).""" + for view_id in sorted(set(views.values())): + html = view_html(module_name, view_id) + server.add_resource( + FunctionResource( + uri=AnyUrl(view_resource_uri(toolset, view_id)), + name=f"{toolset}-{view_id}", + mime_type="text/html", + fn=lambda html=html: html, + ) + ) diff --git a/packages/mcp-runtime/tests/test_views.py b/packages/mcp-runtime/tests/test_views.py new file mode 100644 index 0000000..cffff66 --- /dev/null +++ b/packages/mcp-runtime/tests/test_views.py @@ -0,0 +1,100 @@ +"""Views: MCP resource serving and tool _meta stamping. + +Uses synthetic package + tools modules with an on-disk views/ dir, so the +tests never import a real toolset (which come and go). +""" + +import sys +import types +from pathlib import Path + +import pytest +from langchain_core.tools import tool + +from mcp_runtime.fastmcp_output import to_fastmcp +from mcp_runtime.server import build_server +from mcp_runtime.tool_result import ToolResult +from mcp_runtime.views import load_views, view_html, with_view_meta + + +@tool +def show(text: str) -> ToolResult: + """Show the text.""" + return ToolResult(message=text) + + +def toolset_with_views( + monkeypatch: pytest.MonkeyPatch, + tmp_path: Path, + package: str, + *, + views: dict[str, str] | None = None, + bundles: tuple[str, ...] = ("panel",), + tools: list | None = None, +) -> str: + """Register a synthetic ```` + ``.tools`` with a views dir. + + Returns the tools module name. ``bundles`` are the view ids to write HTML + files for under ``/views/``. + """ + pkg_dir = tmp_path / package + (pkg_dir / "views").mkdir(parents=True) + for view_id in bundles: + (pkg_dir / "views" / f"{view_id}.html").write_text(f"

{view_id}

") + + pkg = types.ModuleType(package) + pkg.__file__ = str(pkg_dir / "__init__.py") + monkeypatch.setitem(sys.modules, package, pkg) + + module_name = f"{package}.tools" + module = types.ModuleType(module_name) + module.TOOLS = tools if tools is not None else [show] # type: ignore[attr-defined] + module.VIEWS = {"show": "panel"} if views is None else views # type: ignore[attr-defined] + monkeypatch.setitem(sys.modules, module_name, module) + return module_name + + +def test_load_views_rejects_bad_export(monkeypatch, tmp_path): + module = toolset_with_views(monkeypatch, tmp_path, "bad", views={"show": 3}) # type: ignore[dict-item] + with pytest.raises(RuntimeError, match="VIEWS"): + load_views(module) + + +def test_view_html_reads_bundle(monkeypatch, tmp_path): + module = toolset_with_views(monkeypatch, tmp_path, "reads") + assert view_html(module, "panel") == "

panel

" + + +def test_view_html_missing_bundle(monkeypatch, tmp_path): + module = toolset_with_views(monkeypatch, tmp_path, "nobundle", bundles=()) + with pytest.raises(RuntimeError, match="no bundle"): + view_html(module, "panel") + + +def test_with_view_meta_stamps_and_is_pure(monkeypatch, tmp_path): + module = toolset_with_views(monkeypatch, tmp_path, "stamp") + original = to_fastmcp(show) + (stamped,) = with_view_meta("stamp", module, [original], {"show": "panel"}) + assert stamped.meta == {"ui": {"resourceUri": "ui://stamp/panel"}} + assert original.meta is None # input untouched + + +def test_with_view_meta_unknown_tool(monkeypatch, tmp_path): + module = toolset_with_views( + monkeypatch, tmp_path, "unknown", views={"ghost": "panel"} + ) + with pytest.raises(RuntimeError, match="ghost"): + with_view_meta("unknown", module, [to_fastmcp(show)], {"ghost": "panel"}) + + +async def test_build_server_registers_view_resource(monkeypatch, tmp_path): + toolset_with_views(monkeypatch, tmp_path, "srv") + server = build_server("srv", module_name="srv.tools") + + (listed,) = await server.list_tools() + assert listed.meta == {"ui": {"resourceUri": "ui://srv/panel"}} + + resources = await server.list_resources() + assert str(resources[0].uri) == "ui://srv/panel" + contents = list(await server.read_resource("ui://srv/panel")) + assert contents[0].content == "

panel

" From 34f52e8f4ba228293d83b9ee9c77d1c38dd0bb1f Mon Sep 17 00:00:00 2001 From: Ciaran Sweet Date: Wed, 22 Jul 2026 20:45:20 +0100 Subject: [PATCH 2/4] feat(mcp-agent): render tool UI views in the Chainlit app MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The agent reads each connected toolset's ui:// view bundles via get_resources and matches them to tools by their _meta — standard MCP, no bespoke endpoints. A view renders in a sandboxed iframe (srcDoc, opaque origin, allow-scripts only), fed the tool's structuredContent; the iframe's postMessage interactions come back as user messages via sendUserMessage, so the loop advances the chat. Co-Authored-By: Claude Opus 4.8 --- packages/mcp-agent/src/mcp_agent/web.py | 91 +++++++++++++++++++++++-- public/elements/McpView.jsx | 47 +++++++++++++ 2 files changed, 131 insertions(+), 7 deletions(-) create mode 100644 public/elements/McpView.jsx diff --git a/packages/mcp-agent/src/mcp_agent/web.py b/packages/mcp-agent/src/mcp_agent/web.py index 5dd120f..fb8e3c1 100644 --- a/packages/mcp-agent/src/mcp_agent/web.py +++ b/packages/mcp-agent/src/mcp_agent/web.py @@ -21,7 +21,9 @@ import chainlit as cl from chainlit.input_widget import InputWidget, TextInput -from langchain_core.messages import BaseMessage, ToolMessage +from langchain_core.messages import AIMessage, BaseMessage, ToolMessage +from langchain_core.tools import BaseTool +from langchain_mcp_adapters.client import MultiServerMCPClient from pydantic import ValidationError from mcp_agent.main import ( @@ -33,8 +35,41 @@ first_leaf, run_turn, user_credentials, + with_credential_support, ) +# The _meta convention a UI-capable host reads (mcp-ui / Apps-SDK style): +# tool.metadata["_meta"]["ui"]["resourceUri"] names a ui:// resource to render. +VIEW_META_KEY = "ui" + + +async def view_bundles( + connections: dict[str, Any], required: dict[str, list[str]] | None +) -> dict[str, str]: + """Read every ``ui://`` view bundle the toolsets serve, as ``{uri: html}``. + + Standard MCP: the runtime registers each view as a ``ui:///`` + resource and stamps the owning tool's ``_meta`` with that URI. A tool with + no view has no such resource, so it renders as text exactly as before. + """ + client = MultiServerMCPClient(with_credential_support(connections, required)) + try: + blobs = await client.get_resources() + except Exception: # noqa: BLE001 - views are optional; degrade to text + return {} + return { + uri: blob.as_string() + for blob in blobs + if (uri := str(blob.metadata.get("uri", ""))).startswith("ui://") + } + + +def view_uri_for(tool: BaseTool | None) -> str | None: + """The ``ui://`` resource a tool declares via its ``_meta``, if any.""" + meta = (getattr(tool, "metadata", None) or {}).get("_meta") or {} + ui = meta.get(VIEW_META_KEY) + return ui.get("resourceUri") if isinstance(ui, dict) else None + @cl.on_chat_start async def start() -> None: @@ -60,6 +95,8 @@ async def start() -> None: return cl.user_session.set("agent", agent) cl.user_session.set("messages", []) + cl.user_session.set("view_html", await view_bundles(connections, required)) + cl.user_session.set("tools_by_name", {tool.name: tool for tool in tools}) await cl.Message( f"Connected to **{len(connections)}** server(s) " f"({', '.join(connections)}) with **{len(tools)}** tools: " @@ -107,19 +144,59 @@ async def on_message(message: cl.Message) -> None: return cl.user_session.set("messages", history) - tool_outputs: dict[str, Any] = { - msg.tool_call_id: msg.content - for msg in new_messages - if isinstance(msg, ToolMessage) - } + tool_messages = [msg for msg in new_messages if isinstance(msg, ToolMessage)] + tool_outputs = {msg.tool_call_id: msg for msg in tool_messages} for msg in new_messages: for call in getattr(msg, "tool_calls", None) or []: async with cl.Step(name=call["name"]) as step: step.input = call["args"] - step.output = str(tool_outputs.get(call["id"], "")) + result = tool_outputs.get(call["id"]) + step.output = str(result.content) if result else "" + + await render_views(new_messages, tool_outputs) await cl.Message(str(history[-1].content)).send() +def _tool_name(message: ToolMessage, new_messages: list[BaseMessage]) -> str | None: + """The name of the tool a ToolMessage answers (its own, or from the call).""" + if message.name: + return message.name + for candidate in new_messages: + if isinstance(candidate, AIMessage): + for call in candidate.tool_calls: + if call["id"] == message.tool_call_id: + return call["name"] + return None + + +async def render_views( + new_messages: list[BaseMessage], tool_outputs: dict[str, ToolMessage] +) -> None: + """Render a UI view for each tool result whose tool declares one. + + A tool's ``_meta`` names a ``ui://`` resource; its HTML (read once at + connect) goes into a sandboxed iframe, fed the tool's ``structuredContent`` + (carried on the ToolMessage's ``artifact``). Interactions inside come back + as user messages via ``sendUserMessage``, so the loop advances the chat. + """ + view_html: dict[str, str] = cl.user_session.get("view_html") or {} + tools_by_name: dict[str, BaseTool] = cl.user_session.get("tools_by_name") or {} + if not view_html: + return + for message in tool_outputs.values(): + tool = tools_by_name.get(_tool_name(message, new_messages) or "") + uri = view_uri_for(tool) + html = view_html.get(uri) if uri else None + if not html: + continue + artifact = message.artifact if isinstance(message.artifact, dict) else {} + element = cl.CustomElement( + name="McpView", + props={"html": html, "data": artifact.get("structured_content")}, + ) + await cl.Message(content="", elements=[element]).send() + + def main() -> None: """Console entry point (``mcp-agent-web``).""" from chainlit.cli import run_chainlit diff --git a/public/elements/McpView.jsx b/public/elements/McpView.jsx new file mode 100644 index 0000000..d6c4634 --- /dev/null +++ b/public/elements/McpView.jsx @@ -0,0 +1,47 @@ +// Renders a toolset's UI view in a sandboxed iframe and bridges the postMessage +// protocol (see toolsets/*/ui/src/host.ts) into Chainlit: +// +// iframe "mcp:ready" -> we post the tool's structuredContent down +// iframe "mcp:sendMessage" -> sendUserMessage(), so the agent runs the next tool +// +// Props (from web.py): { html: the ui:// resource bundle, data: structuredContent }. +// The bundle is rendered via srcdoc, so the frame has an opaque origin and needs +// only allow-scripts — never allow-same-origin. +import { useEffect, useRef } from "react"; + +export default function McpView() { + const ref = useRef(null); + const { html, data } = props; + + const postData = () => + ref.current?.contentWindow?.postMessage({ type: "mcp:data", payload: data }, "*"); + + useEffect(() => { + function onMessage(event) { + const message = event.data; + if (!message || event.source !== ref.current?.contentWindow) return; + if (message.type === "mcp:ready") { + postData(); + } else if (message.type === "mcp:sendMessage" && typeof message.text === "string") { + sendUserMessage(message.text); + } + } + window.addEventListener("message", onMessage); + return () => window.removeEventListener("message", onMessage); + }, []); + + return ( +