diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index a03f1d4..19b7a5a 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -9,8 +9,8 @@ jobs: test: runs-on: ubuntu-latest steps: - - uses: actions/checkout@v4 - - uses: astral-sh/setup-uv@v5 + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 + - uses: astral-sh/setup-uv@c771a70e6277c0a99b617c7a806ffedaca235ff9 # v9.0.0 - run: uv sync --frozen - run: ./scripts/lint - run: ./scripts/test @@ -18,7 +18,7 @@ jobs: helm: runs-on: ubuntu-latest steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 - run: > helm lint charts/mcp-toolset --set name=test @@ -30,12 +30,36 @@ jobs: --set image.repository=test --set image.tag=test + ui: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 + - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7 + with: + node-version: "22" + - uses: astral-sh/setup-uv@c771a70e6277c0a99b617c7a806ffedaca235ff9 # v9.0.0 + - run: uv sync --frozen + # Typecheck + bundle every toolset UI (fails on a TS error, which the + # Docker/vite build alone would not catch). + - run: ./scripts/build-views + # A toolset that declares VIEWS must have the bundles it names: build_server + # validates the VIEWS<->bundle wiring and stamps each tool's _meta. + - name: Validate view wiring + run: | + for manifest in toolsets/*/ui/package.json; do + [ -f "$manifest" ] || continue + name=$(basename "$(dirname "$(dirname "$manifest")")") + echo "validating $name" + TOOLSET="$name" uv run python -c \ + "import os; from mcp_runtime.server import build_server; build_server(os.environ['TOOLSET'])" + done + detect: runs-on: ubuntu-latest outputs: toolsets: ${{ steps.changed.outputs.toolsets }} steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 with: fetch-depth: 0 - id: changed @@ -56,10 +80,10 @@ jobs: matrix: toolset: ${{ fromJSON(needs.detect.outputs.toolsets) }} steps: - - uses: actions/checkout@v4 - - uses: docker/setup-buildx-action@v3 + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 + - uses: docker/setup-buildx-action@bb05f3f5519dd87d3ba754cc423b652a5edd6d2c # v4 - name: Build changed toolset image (no push) - uses: docker/build-push-action@v6 + uses: docker/build-push-action@53b7df96c91f9c12dcc8a07bcb9ccacbed38856a # v7 with: context: . push: false diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index 4ed8f78..0afa3ef 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -26,7 +26,7 @@ jobs: ingress: ${{ steps.ingress.outputs.enabled }} deploy: ${{ steps.deploy.outputs.enabled }} steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 with: fetch-depth: 0 - id: changed @@ -63,17 +63,17 @@ jobs: matrix: toolset: ${{ fromJSON(needs.detect.outputs.toolsets) }} steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 - id: image name: Compute image name (ghcr requires lowercase) run: echo "repository=ghcr.io/${GITHUB_REPOSITORY@L}/mcp-${{ matrix.toolset }}" >> "$GITHUB_OUTPUT" - - uses: docker/login-action@v3 + - uses: docker/login-action@af1e73f918a031802d376d3c8bbc3fe56130a9b0 # v4 with: registry: ghcr.io username: ${{ github.actor }} password: ${{ secrets.GITHUB_TOKEN }} - - uses: docker/setup-buildx-action@v3 - - uses: docker/build-push-action@v6 + - uses: docker/setup-buildx-action@bb05f3f5519dd87d3ba754cc423b652a5edd6d2c # v4 + - uses: docker/build-push-action@53b7df96c91f9c12dcc8a07bcb9ccacbed38856a # v7 with: context: . push: true @@ -114,7 +114,7 @@ jobs: if: needs.detect.outputs.toolsets != '[]' && needs.detect.outputs.deploy == 'true' runs-on: ubuntu-latest steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 - name: Configure kubeconfig run: | mkdir -p ~/.kube @@ -143,17 +143,17 @@ jobs: if: needs.detect.outputs.ingress == 'true' && needs.detect.outputs.toolsets != '[]' && needs.detect.outputs.deploy == 'true' runs-on: ubuntu-latest steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 - id: image name: Compute image name (ghcr requires lowercase) run: echo "repository=ghcr.io/${GITHUB_REPOSITORY@L}/mcp-index" >> "$GITHUB_OUTPUT" - - uses: docker/login-action@v3 + - uses: docker/login-action@af1e73f918a031802d376d3c8bbc3fe56130a9b0 # v4 with: registry: ghcr.io username: ${{ github.actor }} password: ${{ secrets.GITHUB_TOKEN }} - - uses: docker/setup-buildx-action@v3 - - uses: docker/build-push-action@v6 + - uses: docker/setup-buildx-action@bb05f3f5519dd87d3ba754cc423b652a5edd6d2c # v4 + - uses: docker/build-push-action@53b7df96c91f9c12dcc8a07bcb9ccacbed38856a # v7 with: context: . push: true diff --git a/.gitignore b/.gitignore index ea9ba35..04cb21a 100644 --- a/.gitignore +++ b/.gitignore @@ -11,8 +11,14 @@ dist/ # Chainlit (generated at runtime by mcp-agent-web) .chainlit/ +.files/ chainlit.md +# Toolset UI views: node deps and built bundles (rebuilt by the Dockerfile's +# node stage / `npm run build`; served from /views/ at runtime). +toolsets/*/ui/node_modules/ +toolsets/*/src/*/views/ + # Tooling caches .mypy_cache/ .pytest_cache/ diff --git a/CLAUDE.md b/CLAUDE.md index a407b26..26aab9f 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -7,7 +7,13 @@ first. This file holds only what an agent cannot derive from it. - `uv sync` once, then `./scripts/lint`, `./scripts/test`, `./scripts/format`. - New toolset: `./scripts/new-toolset ` — never hand-roll the layout. + Add `--with-ui` for a toolset with a React view (see README "Toolset UI + views"). - Remove a toolset: `./scripts/remove-toolset `. +- Build toolset UIs: `./scripts/build-views` (needs node). Built view bundles + live at `/views/*.html`, are git-ignored, and must exist before + `mcp-serve` or `build_server` aborts — the Dockerfile's node stage, the CI + `ui` job, and this script rebuild them. ## Safety diff --git a/Dockerfile b/Dockerfile index 6812b3c..28762c5 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,8 +1,23 @@ +# Stage 1: build the toolset's UI views (if it has a ui/ project). The Vite +# build writes self-contained bundles into the package's views/ dir; a toolset +# without ui/ skips this untouched. Node is a build-time dependency only — it +# never ships in the runtime image. +FROM node:23-bookworm-slim AS ui +ARG TOOLSET +WORKDIR /app +COPY toolsets/${TOOLSET}/ ./toolsets/${TOOLSET}/ +RUN if [ -f "toolsets/${TOOLSET}/ui/package.json" ]; then \ + cd "toolsets/${TOOLSET}/ui" && npm ci && npm run build; \ + fi + +# Stage 2: build the Python virtualenv, with the built views overlaid onto the +# toolset source so they are packaged into the installed wheel. FROM ghcr.io/astral-sh/uv:python3.12-bookworm-slim AS builder ARG TOOLSET ENV UV_COMPILE_BYTECODE=1 UV_LINK_MODE=copy WORKDIR /app COPY . . +COPY --from=ui /app/toolsets/${TOOLSET}/src/ ./toolsets/${TOOLSET}/src/ RUN uv sync --frozen --no-dev --no-editable --package "${TOOLSET}" FROM python:3.12-slim-bookworm diff --git a/README.md b/README.md index ac50244..a4c56b0 100644 --- a/README.md +++ b/README.md @@ -200,6 +200,63 @@ Verify locally: `TOOLSET=my-toolset uv run mcp-serve`, then `tools/list` `tools/call` responses carry `structuredContent`. +## Toolset UI views + +A tool can ship a **view**: a small frontend component (a map, a gallery, a +chart) that a UI-capable MCP host — Claude web, an mcp-ui client, or the bundled +Chainlit agent — renders in a sandboxed iframe and feeds the tool's +`structuredContent`. The runtime stays pure-Python: a view is a build-time HTML +bundle served as an MCP resource; nothing new executes at call time. Views are +**progressive enhancement** — the tool's `message` and structured data still +stand alone in a plain client, so a view never changes what a tool returns. + +Scaffold a toolset with an example view, then build it (needs node): + +```sh +./scripts/new-toolset --with-ui my-toolset +cd toolsets/my-toolset/ui && npm install && npm run build +``` + +### The contract + +A toolset opts in with three things, validated at startup — a missing bundle, +or a view naming an unknown tool, aborts `build_server`: + +1. **`VIEWS`** — a `{tool_name: view_id}` export in the tools module. +2. **A built bundle** at `/views/.html`, self-contained (all + JS/CSS inlined). The shipped `ui/` builds these with Vite + + `vite-plugin-singlefile`, one pass per view (`VIEW= vite build`), writing + into the package's `views/` dir. Built bundles are git-ignored; the + Dockerfile's node stage and `./scripts/build-views` rebuild them. +3. **The host bridge** — the bundle speaks a three-message postMessage protocol + (`ui/src/host.ts`): `mcp:ready` up when it mounts, `mcp:data` down carrying + the tool's `structuredContent`, `mcp:sendMessage` up to advance the chat. Any + framework works; only this seam is fixed. + +Given that, the runtime does two standard-MCP things: it serves each view as a +resource `ui:///` and stamps the owning tool's `_meta` with +that URI (the mcp-ui / Apps-SDK `_meta` convention). A UI-capable host reads the +`_meta` to know a tool has a view and reads the resource for its HTML — that is +all the Chainlit agent does (`get_resources` + the tool's `_meta`), and it is +what Claude web / mcp-ui clients consume too. + +### Credentials never reach the iframe + +A view can do exactly as much as what the tool put in its `ToolResult`: pass +**pre-signed or short-lived URLs** (tiles, thumbnails), never tokens. The +[per-user credential](#per-user-credentials) invariant is unchanged — secrets +ride the MCP transport as headers, never the conversation or the iframe. For an +authenticated data source, the tool mints a signed URL server-side and returns +it in the result. + +### Interactions advance the chat + +A view is an input device, not just a picture: an interaction calls +`sendMessage(...)`, which arrives back as a user message, so the model reads it +and calls the next tool. `toolsets/stac-explorer` is a worked example — a +collection gallery whose "Show on map" button drives a second tool that renders +the selected data on a map. + ## Removing a toolset ```sh 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/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

" 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 ( +