Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
38 changes: 31 additions & 7 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -9,16 +9,16 @@ 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

helm:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
- run: >
helm lint charts/mcp-toolset
--set name=test
Expand All @@ -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
Expand All @@ -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
Expand Down
20 changes: 10 additions & 10 deletions .github/workflows/deploy.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
6 changes: 6 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -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 <package>/views/ at runtime).
toolsets/*/ui/node_modules/
toolsets/*/src/*/views/

# Tooling caches
.mypy_cache/
.pytest_cache/
Expand Down
6 changes: 6 additions & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 <name>` — 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 <name>`.
- Build toolset UIs: `./scripts/build-views` (needs node). Built view bundles
live at `<package>/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

Expand Down
15 changes: 15 additions & 0 deletions Dockerfile
Original file line number Diff line number Diff line change
@@ -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
Expand Down
57 changes: 57 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 `<package>/views/<view_id>.html`, self-contained (all
JS/CSS inlined). The shipped `ui/` builds these with Vite +
`vite-plugin-singlefile`, one pass per view (`VIEW=<id> 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://<toolset>/<view_id>` 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
Expand Down
91 changes: 84 additions & 7 deletions packages/mcp-agent/src/mcp_agent/web.py
Original file line number Diff line number Diff line change
Expand Up @@ -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 (
Expand All @@ -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://<toolset>/<view>``
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:
Expand All @@ -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: "
Expand Down Expand Up @@ -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
Expand Down
Loading
Loading