Skip to main content
Glama
wri
by wri
README.md
# project-zeno-mcp

[Zeno](https://github.com/wri/project-zeno)'s analysis chain as MCP toolsets:
resolve an area, pick a dataset, pull the statistics, turn them into an
insight — with the areas and the statistics moving between the tools through
session state, never through the model.

The port is specified in [docs/PORT-PLAN.md](docs/PORT-PLAN.md). Read it before
you change a tool signature or a data key name.

```
toolsets/<name>/tools.py  ──▶  ghcr.io/<owner>/<repo>/mcp-<name>  ──▶  k8s Service mcp-<name>
   (LangChain @tool fns)        (Dockerfile --build-arg TOOLSET=...)     (charts/mcp-toolset)
```

## The toolsets

| Toolset | Tool | Does |
| --- | --- | --- |
| `zeno-aoi` | `pick_aoi` | Resolves the place(s) in a request to the areas zeno analyses: extracts the place names, searches the AOI catalogue, scores the candidates, expands subregions. |
| `zeno-analysis` | `pick_dataset` | Picks the dataset, context layer and date range that answer a request, from the catalog in `datasets/catalog/`. Clamps the dates to what the dataset covers and builds its map tile URLs. |
| `zeno-analysis` | `pull_data` | Fetches the statistics for a resolved area and dataset from the GNW analytics API. Publishes a record that names the areas and links to the numbers; the numbers themselves stay out of session state. |
| `zeno-analysis` | `generate_insight` | Fetches the numbers back, runs the analysis in a code interpreter, and writes the finding. Publishes the narrative and one or two chart specs, in the shape a chart view renders. |

These four are the whole chain. Still to come: a map view for `pick_aoi` and a
chart view for `generate_insight`. See "Order of work" in the port plan.

The two toolsets are split by **credential**, not by subject: `zeno-aoi` holds
the database URL and `zeno-analysis` needs no database at all. Keep it that
way — a query from `zeno-analysis` means the split has been broken.

## Run it

```sh
uv sync                 # the runtime from PyPI plus every toolset, into one .venv
```

`zeno-aoi` reads zeno's PostGIS database and calls one Gemini model;
`zeno-analysis` calls a model and the analytics API. Copy `.example.env` to
`.env` and set these:

| Variable | For |
| --- | --- |
| `DATABASE_URL` | The AOI catalogue (`zeno-aoi` only). A `postgresql+asyncpg://` URL. |
| `GOOGLE_API_KEY` | The geocoder's extraction step, dataset selection, and both stages of `generate_insight`. |
| `WRI_BEARER_TOKEN` | The GNW analytics API, which is where every statistic comes from (`zeno-analysis` only). |
| `ZENO_CODING_MODEL` | Optional. The model `generate_insight` runs its analysis with. Default `gemini-3.1-pro-preview`, with `ZENO_CODING_FALLBACK_MODELS` tried in order after it. |
| `ZENO_EXCLUDED_DATASETS` | Optional. Datasets this deployment hides, by catalog name. Defaults to what zeno hides — see below. |
| `ANALYTICS_API_BASE_URL` | Optional. Point `pull_data` at another analytics deployment. |

None is read at import, so the tests and the contract sweep run without
them. Then bring up the stack, each step in its own shell:

1. **The toolsets.**

   ```sh
   uv run mcp-serve-local        # toolsets at /<name>/mcp, index at /, on :8000
   ```

2. **The agent API.** From the repo root, so `.env` is found:

   ```sh
   uv run uvicorn agent_app:app --port 8765
   ```

   `agent_app:app` — not the runtime's `mcp_agent_api.app:app` — is this
   repo's system prompt. It carries the rules a tool docstring cannot enforce
   (see [The agent](#the-agent) below).

3. **The web client.**

   ```sh
   cd web && npm ci && npm run dev   # :5173, proxies /api to the agent API
   ```

To call a tool with no model in the loop:

```sh
uv run mcp-cli list --url http://localhost:8000/zeno-aoi/mcp
uv run mcp-cli call pick_aoi --url http://localhost:8000/zeno-aoi/mcp \
  question="tree cover loss in Para, Brazil"
```

## How the tools compose (session state)

A large value — the resolved areas, the pulled statistics — passes between
tools through **session state**, not through the model's context. Each data
key a tool returns is captured under `<toolset>/<tool>/<field>`, and a
parameter tagged `NotAuthored` accepts only an `@state:<key>` reference to
one. The model reads which keys exist off a `[state updated: ...]` note and
passes the reference back.

This is the port's whole point: it is what `InjectedState` and
`Command(update=…)` did inside zeno's LangGraph app, done over the MCP wire
instead, so the tools work outside zeno.

| Tool (toolset) | Consumes (parameter) | Publishes (state key) |
| --- | --- | --- |
| `pick_aoi` (zeno-aoi) | — | `zeno-aoi/pick_aoi/aoi_selection`, `zeno-aoi/pick_aoi/aoi_candidates` |
| `pick_dataset` (zeno-analysis) | `aoi_selection` (`NotAuthored`, optional) | `zeno-analysis/pick_dataset/dataset`, `zeno-analysis/pick_dataset/dataset_candidates` |
| `pull_data` (zeno-analysis) | `dataset`, `aoi_selection` (both `NotAuthored`, both required) | `zeno-analysis/pull_data/statistics` |
| `generate_insight` (zeno-analysis) | `statistics`, `dataset` (both `NotAuthored`, both required) | `zeno-analysis/generate_insight/insight`, `zeno-analysis/generate_insight/charts` |

The key **name is the entire contract** between two toolsets — there is no
type tag, so `zeno-analysis` will ask for `aoi_selection` and get whatever
published that name last. `tests/test_declarations.py` holds the table of
names and fails on a rename.

`aoi_candidates` is how an ambiguous place is reported. `pick_aoi` publishes
the candidates, publishes **no** selection, and tells the model to ask the
user. That replaces zeno's `aoi_choice` nudge, which needed a channel to end
the turn that a tool loop does not have. `dataset_candidates` does the same
job for a request no single dataset answers.

`pull_data` is where the mechanism earns its keep: one call resolves handles
from *both* toolsets, and neither can be written by hand. Its `statistics`
record deliberately carries **no numbers** — `data` is `{}` and `source_url`
points at them — because a result runs to thousands of rows and session state
is read into every later turn. `generate_insight` is what fetches and reads
them.

`generate_insight` is therefore the only tool that sees a number, and its
**message is load-bearing**. A published key never enters the transcript, so
the model that has to answer the user would otherwise have no figures at all.
The message carries the finding, the chart titles and the chart rows rendered
as text — and the tool says outright to quote nothing it does not contain.
Stage 4 showed what the alternative looks like: with no reader for the
numbers, the model invented them (`docs/stage-4-findings.md`).

The two published keys are split on purpose. `insight` is the prose and
`charts` the specs, because a future consumer wants one or the other — a
summariser the narrative, a renderer the rows. Splitting now is free;
renaming later is a contract break. `charts` is the exact camelCase shape
`InsightChart.to_frontend_dict()` produces, so the chart view stage 6 adds
needs no contract change.

`pick_dataset`'s `aoi_selection` is both narrowed and **optional**: a request
about a dataset alone needs no area. Narrowing a parameter to a handle drops
its declared default, so absence from `required` is the only structural signal
left that it may be omitted — which is why the tool's docstring says so in
prose. See `docs/stage-0-findings.md`.

## The agent

`agent_app.py` wraps the runtime's API with a zeno system prompt. Five rules
in it are load-bearing, because a tool docstring is only read while that tool
is being considered:

1. The chain order: `pick_aoi` → `pick_dataset` → `pull_data` →
   `generate_insight`.
2. Pass the place request verbatim. Never state a place's location or
   identity from your own knowledge. This is the most important line here: a
   model that geocodes from memory produces a confident wrong area, and every
   later tool believes it.
3. Candidates and no selection means ask the user. Never choose for them.
4. A request about a dataset alone needs no area.
5. Reuse an `aoi_selection` for the same place; call `pick_aoi` again for a
   different one.

Rule 5 is the mitigation for the one trap this design has: session state is
last-write-wins and is never cleared, so an `aoi_selection` from an earlier
turn stays valid for ever. Nothing refuses a call that points at a stale one.
Every tool message therefore names the areas it used, so a wrong reuse is
visible one step later.

`TodayMiddleware` puts today's date in front of each model call. It is the
one part of zeno's `SessionContextMiddleware` that is kept: the rest of that
block duplicates what the state notes already say.

## What is deliberately not ported

Do not add these back without reading the port plan first.

- **Custom (user-drawn) areas.** They need a user identity, and this server
  has none. `custom` is not a valid AOI source here.
- **The insights store.** No `InsightOrm`, no `StatisticsOrm`, no
  persistence. The thread checkpointer is the only durable store.
- **Per-user identity.** No `current_user_id`, no ownership filters, no
  credential headers.
- **`codeact_parts`.** Zeno stores the generated code and its output, base64
  encoded, against each insight row, for a debugging view. There is no row and
  no such view here, and it runs to tens of kilobytes per insight. The code is
  used for the prompt and then dropped, which also removed
  `replace_csv_paths_with_urls` — its only purpose was to make those stored
  code blocks runnable elsewhere.
- **`update_insight_display`** and its `display_reviser`. Restyling an
  existing insight needs an insight to load, and nothing here persists one.
- **i18n.** Tool messages are English, because they are read by the model.
  The user-facing half is one line in the system prompt. The one seam kept is
  `generate_insight`'s `language` parameter, which sets the language of the
  chart titles, the axis labels and the narrative.
- **Per-user feature flags** (`bound_availability`). Zeno's is a per-request
  ContextVar carrying a user's profile. The one thing it actually gates is
  which datasets the selector may offer, and that is per deployment here:
  `ZENO_EXCLUDED_DATASETS`, a comma-separated list of catalog names.

  Its default is not empty. It hides Land GHG Monitoring System (LGMS),
  matching what zeno hides from normal traffic, because LGMS and "Forest
  greenhouse gas net flux" compete for a carbon question on an administrative
  area and LGMS wins it. Set `ZENO_EXCLUDED_DATASETS=""` to reveal
  everything — an empty value means hide nothing, distinct from the variable
  being unset. The measurement behind the default is in
  `docs/dataset-selection-eval.md`.
- **Langfuse tracing.** `turn_context` on `create_app` is the seam if it
  returns.
- **Progress events.** There is no stream writer to reach from another
  process. What they reported is in each tool's message instead.

## Development

```sh
./scripts/format        # ruff autofix + format
./scripts/lint          # ruff security checks, format check, mypy
./scripts/test          # pytest (args forwarded, e.g. ./scripts/test -k pick_aoi)
```

`toolsets/zeno-aoi/tests/` replays a recorded fixture of the AOI database, so
the tests need no PostGIS. A place with no recorded rows fails the test rather
than reaching for a live database. Re-record with zeno's
`data/scripts/record_aoi_pick_aoi_fixtures.py`.

Tests are marked so a run needs no credentials: `llm` needs `GOOGLE_API_KEY`,
`live` needs `DATABASE_URL`, and both are skipped without them. To run only
what needs neither:

```sh
./scripts/test -m "not llm and not live"
```

`zeno-analysis` needs no database, and its catalog tests build a wheel to
check that the YAML files actually ship in it — `mcp-serve` imports the
package from site-packages, so a wheel without them has no catalog at all.

`tests/` holds what is about the whole repo: the toolset contract sweep, the
data-key declarations, and `agent_app`.

---

## Where this comes from

This repo is an instance of the
[mcp-toolsets](https://github.com/developmentseed/mcp-toolsets) template — a
monorepo of **toolsets** (small packages of
[LangChain](https://python.langchain.com) tools), each deployed as its own
[MCP](https://modelcontextprotocol.io) service. Everything that is not a
toolset comes from one PyPI package,
[**mcp-toolsets-runtime**](https://github.com/developmentseed/mcp-toolsets-runtime),
bounded in the root `pyproject.toml` and pinned by `uv.lock`. Never add a
module under `mcp_runtime`, `mcp_cli`, `mcp_agent`, `mcp_agent_api` or
`mcp_toolset` here, and never patch runtime behaviour locally — fix it
upstream, release, then bump the pin.

There is a worked precedent for this port shape in
[geo-assistant-mcp-toolset](https://github.com/developmentseed/geo-assistant-mcp-toolset),
which this repo's layout follows.

The template's own documentation follows. It still describes the example
toolsets it ships (`hello`, `credential-demo`, `stac-explorer`); those were
removed here.

## The runtime dependency

[![PyPI](https://img.shields.io/pypi/v/mcp-toolsets-runtime?label=mcp-toolsets-runtime)](https://pypi.org/project/mcp-toolsets-runtime/)

Everything that isn't a toolset comes from one PyPI package,
[`mcp-toolsets-runtime`](https://github.com/developmentseed/mcp-toolsets-runtime),
bounded in the root `pyproject.toml` and pinned exactly by `uv.lock`. Below is
what you reach for from *this* repo and when; the package's own README is the
authority on everything it exposes, and stays current when this doesn't:

| Module | What you use it for here |
| --- | --- |
| `mcp_runtime` | **Required.** Serves a toolset's `TOOLS` as a stateless streamable-HTTP MCP server with a `/health` route for k8s probes, and its `VIEWS` as `ui://` resources — `mcp-serve` for one toolset, `mcp-serve-local` for all of them at once. Also runs the directory service (`mcp-index`). |
| `mcp_cli` | **Development inner loop.** Typer/rich client (`mcp-cli`) to list and call tools on a running service. |
| `mcp_toolset` | **Scaffolding.** `mcp-toolset new [--with-ui] <name>` writes a conforming toolset into `toolsets/` and registers it in the workspace. |
| `mcp_agent` | **Optional example chat** (`mcp-agent` / `mcp-agent-web`) that discovers every server behind an index URL and drives their tools. `mcp-agent-web` needs the `[web]` extra; `[agent]` alone gives `build_agent`, `run_turn` and the host helpers without Chainlit, for a frontend of your own. Drop the extra from the pin if you want neither. |
| `mcp_state` | **Already working on these toolsets, untagged.** It keeps large tool values out of the model's context. Every `ToolResult` data key is declared in the tool's `_meta` and captured into session state by the bundled agent, whether or not you tag anything. Tagging a parameter `NotAuthored` is the accelerator on top — see below. |

[session-state]: https://github.com/developmentseed/mcp-toolsets-runtime/blob/main/docs/SESSION-STATE.md

Upgrade with `uv lock --upgrade-package mcp-toolsets-runtime`; because `uv.lock`
is a shared build input, merging the bump rebuilds and redeploys every toolset.
Fix runtime behaviour upstream and release it — never patch it here, since
nothing local would survive the next `uv sync`.

This repo owns `toolsets/*` — one directory per toolset, each becoming an MCP
service — `charts/*`, the `Dockerfile`, the workflows, and
`tests/test_contract.py`.

### Session state, and what tagging `NotAuthored` adds

Nothing here opts in explicitly, and the mechanism still runs: `search_collections`
advertises `stac-explorer/search_collections/collections` in its `_meta`, and
driving it from the bundled agent moves that list into session state instead of
the transcript. Best endeavours is the default, so an untagged toolset already
gets the context saving.

A state key is three parts — `<toolset>/<tool>/<field>` — and it is the whole
contract. There is no type to agree on: the producer names the value, and a
consumer that wants it names the same key. Which also means the key is a
**public name**, changeable only the way any published identifier is.

What tagging a parameter `NotAuthored` adds is a *constraint*, not a type. It
says one thing — a model must not write this value — and an `mcp_state` client
narrows the parameter until the only thing it accepts is a reference to a value
some tool already produced. A client that has never heard of the tag still gets
the sentence appended to the parameter's description, and mostly obeys it; one
that ignores `_meta` entirely sees a normal parameter and is no worse off than
before.

What the model writes either way is an `@state:<key>` handle rather than the
value, so the chat's tool step annotates the handle with what the key resolved
to — which the bare string does not say:

```
request: @state:stac-explorer/search_collections/collections · 12 item(s) · from search_collections · query written by the model
```

That last clause is the provenance: the call that produced this value was given
`query` by the model rather than from state, and a reader deciding how much to
trust the result wants to know it.

None of this is visible in this repo yet, and the reason is not tagging. The
`@state:<key>` form is only offered on `object` and `array` parameters, whereas
every tool here takes scalars — `hello(name)`, `whoami()`,
`search_collections(query, limit)`, `show_map(collection_id)`. A tool taking a
structured parameter would light up the handle path on its own, without tagging
anything.

Two consequences worth knowing before you read a deployment. `/health` and the
index report `state.produces` as one entry per published data key — `{tool,
field, state_key}` — so `stac-explorer` lists three and `hello` lists none;
alongside it `state.not_authored` is `[]` here, which means "nothing is tagged",
not "nothing is captured". And the guarantee is about what reaches the *model*,
not about what leaves the process: LangChain hands every tool call the whole
agent state, so a tracing backend wired to the chat records stored payloads on
every subsequent call. That is upstream behaviour, unrelated to whether you use
session state at all, but it is the wrong thing to discover after turning
tracing on.

Keeping values out of the context is client-side work, so external hosts do none
of it — served to Claude.ai or ChatGPT, these toolsets behave like any other, and
tool returns still have to be a sensible size on their own. The full contract,
with sequence diagrams and a runnable demo, is in the runtime's
[SESSION-STATE.md][session-state].

## Quickstart

```sh
uv sync                # runtime from PyPI + every toolset, into one .venv
./scripts/test         # run all tests
./scripts/lint         # ruff + mypy (./scripts/format to autofix)

# Serve every toolset in one process, with the index at /
uv run mcp-serve-local

# ...or a single toolset on its own, the way production runs it
TOOLSET=hello uv run mcp-serve

# Talk to them from another shell
uv run mcp-cli list --url http://localhost:8000/hello/mcp
uv run mcp-cli call hello name=dev --url http://localhost:8000/hello/mcp
uv run mcp-cli repl --url http://localhost:8000/hello/mcp
uv run mcp-cli call whoami \
  --url http://localhost:8000/credential-demo/mcp -H "X-Demo-Token: s3cret"
```

`mcp-serve-local` mounts each toolset at `/<name>/mcp` and serves the index
document at `/` — the same URL shape the shared domain has in production, so an
`mcp-cli`, an `mcp-agent` or an MCP Inspector session can be pointed at it
unchanged. Use it for the inner loop; reach for `mcp-serve` when you want a
toolset isolated exactly as its own pod runs it (one process, one toolset,
`/mcp` at the root, `PORT` to move it).

`mcp-cli` defaults to `http://localhost:8000/mcp`, which is where a bare
`mcp-serve` puts a toolset — hence the explicit `--url` above. Toolsets are also
importable directly (e.g. `from hello.tools import TOOLS`) for in-process use in
tests, notebooks or an agent repo.

## Adding a toolset

No Docker, Kubernetes or MCP knowledge needed — write ordinary LangChain
tools and merge.

1. Scaffold with the runtime's generator (registers the package in the uv
   workspace too):

   ```sh
   uv run mcp-toolset new my-toolset
   ```

2. Write your tools in `toolsets/my-toolset/src/my_toolset/tools.py`:

   ```python
   from typing import Any, NotRequired

   from langchain_core.tools import tool

   from mcp_runtime.tool_result import ToolError, ToolResult

   class DoSomethingResult(ToolResult):
       """Matches for the query, each with an 'id' and a 'score'."""

       matches: NotRequired[list[dict[str, Any]]]

   @tool
   def do_something(query: str, limit: int = 10) -> DoSomethingResult | ToolError:
       """One-line description — docstrings and type hints ARE the MCP schema."""
       ...
       return DoSomethingResult(message=f"Found {len(matches)} match(es).", matches=matches)

   TOOLS = [do_something]
   ```

   `TOOLS` is the only required export. Non-empty docstrings and the
   [ToolResult return contract](#typed-tool-returns) are enforced by a
   contract test. If a tool does I/O (HTTP, database), write it as
   `async def` — `@tool` supports coroutines natively; sync tools are fine
   for pure computation (the runtime runs them in a thread pool). If a tool
   needs the *user's* credentials, read them from the request headers — see
   [Per-user credentials](#per-user-credentials). The shipped `hello` toolset
   is a minimal starting point you can copy.

3. Add tests in `toolsets/my-toolset/tests/test_my_toolset.py` and run
   `./scripts/test`.

4. (Optional) `toolsets/my-toolset/toolset.yaml` holds Helm value overrides —
   secrets to mount via `envFrom`, env vars, resources, replicas. See
   `charts/mcp-toolset/values.yaml` for the available keys.

5. Merge to `main`. CI builds `ghcr.io/<owner>/<repo>/mcp-my-toolset` and
   deploys the `mcp-my-toolset` service automatically.

Conventions: directory `toolsets/<name>` (kebab-case) → module
`<name_snake_case>.tools` → service `mcp-<name>`.

## Typed tool returns

Every tool returns one dict per call, in one of two shapes from
`mcp_runtime.tool_result`:

- **`ToolResult`** — success: a required str `message` (the human-readable
  answer a model or UI reads first) plus any data keys your tool declares.
- **`ToolError`** — a structured error: a short machine-readable `error`
  kind and a `detail` saying what happened or what to do next.

The runtime derives each tool's MCP `outputSchema` from its return
annotation, advertises it in `tools/list`, validates every result against it
before sending, and delivers results as typed `structuredContent` (alongside
the usual text block). A tool whose annotation doesn't follow the contract
**fails at startup** (`build_server` aborts, naming the tool) and fails the
contract test in CI — never silently at chat time.

How to annotate:

- Minimum: `-> ToolResult | ToolError` for tools whose message is the whole
  answer (drop the `ToolError` arm if the tool raises instead of returning
  errors — exceptions become MCP `isError` results, which skip schema
  validation).
- Recommended: one `ToolResult` subclass per tool, adding each data key as
  `NotRequired[...]`, annotated `-> MyResult | ToolError`. Give the subclass
  a one-line docstring — it becomes the schema's `description`. Nested
  payloads can be TypedDicts or pydantic models all the way down.
- Construct returns with TypedDict call syntax —
  `ToolResult(message=...)`, `ToolError(error="not_found", detail=...)` —
  mypy-checked, still a plain dict at runtime. `is_error()` (a `TypeIs`
  guard) narrows helper results typed `dict[str, Any] | ToolError` in both
  branches.

Rules and gotchas:

- Keys not declared in the annotation are silently dropped from
  `structuredContent` — the annotation is the complete list of keys a client
  can see, and mypy flags undeclared keys in return literals.
- Union arms must all be TypedDicts/pydantic models; bare `str`/`list`
  returns and `dict[str, Any]` are rejected at startup (FastMCP would wrap
  the former in `{"result": ...}`, changing your payload shape; the latter
  guarantees nothing). Put data under a named key instead.
- The annotation must be on the function `@tool` wraps; the runtime reads it
  via `tool.coroutine`/`tool.func`.

Verify locally: `TOOLSET=my-toolset uv run mcp-serve`, then `tools/list`
(via MCP Inspector or `mcp-cli`) shows each tool's `outputSchema`, and
`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 an MCP Apps host — Claude, ChatGPT, 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
uv run mcp-toolset new --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** — [`@developmentseed/mcp-view`][mcp-view], the npm half of
   the runtime. It wraps the MCP Apps `ui/*` postMessage protocol in two
   functions, so a view never hand-rolls the wire format:

   ```ts
   import { onData, sendMessage } from "@developmentseed/mcp-view";

   onData<MyResult>((data) => render(data));  // the tool's structuredContent
   button.onclick = () => sendMessage("…");   // a user turn back into the chat
   ```

   Any framework works; only this seam is fixed. Add it to your `ui/`
   dependencies — it's a public package, so no registry auth here or in CI.

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. Because that follows the [MCP Apps][ext-apps] standard, any MCP Apps
host renders the same bundle unchanged — Claude, ChatGPT, Goose, VS Code — and
so does the bundled Chainlit agent, whose `McpView.jsx` element implements the
host end of the identical protocol.

[mcp-view]: https://www.npmjs.com/package/@developmentseed/mcp-view
[ext-apps]: https://github.com/modelcontextprotocol/ext-apps

### 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.

### Viewing them in the bundled Chainlit agent

External MCP Apps hosts need nothing from you beyond the contract above.
Chainlit isn't one out of the box, so the runtime ships the host-side element
that makes it one, and you install it into the app root once (it lands in the
git-ignored `public/elements/`):

```sh
uv run mcp-agent install-elements    # writes public/elements/McpView.jsx
```

Re-run it after a runtime upgrade to pick up the new element. Nothing is written
at runtime, so this works on a read-only filesystem; `mcp-agent-web` starts
without it but warns and won't render views.

## Removing a toolset

```sh
./scripts/remove-toolset my-toolset
```

Merge to `main`. Removal is GitOps like everything else: the deploy
workflow reconciles the cluster against `toolsets/`, uninstalling any
`mcp-<name>` release whose directory no longer exists — Deployment, Service
and Ingress with it; the index drops the entry automatically. Mind that
this means merging a deleted directory tears down the live service.

Not removed automatically: out-of-band Secrets the toolset listed in its
`toolset.yaml` (`kubectl -n __MCP_NAMESPACE__ delete secret <name>`) and its
images in GHCR (delete the package from the repo settings if you care).

## Deployment

- **ci.yml** (PRs + main): lint, tests, `helm lint`, and a no-push Docker
  build of every image affected by the change. Always runs — no cluster needed.
- **deploy.yml** (main): detects changed toolsets (`scripts/changed-toolsets`)
  — changes to shared build inputs (`charts/`, `Dockerfile`, `uv.lock`, root
  `pyproject.toml`) rebuild *all* toolsets, which is how a runtime version bump
  reaches every service — then per toolset: build and push
  `ghcr.io/<owner>/<repo>/mcp-<name>:<sha>` and
  `helm upgrade --install mcp-<name> charts/mcp-toolset -n __MCP_NAMESPACE__`.
  A reconcile job also uninstalls releases whose `toolsets/<name>` directory
  is gone — see [Removing a toolset](#removing-a-toolset).
- **Deploy guard**: the cluster-touching jobs are skipped unless **both** the
  `KUBE_CONFIG` secret and the `MCP_NAMESPACE` variable are set, so a freshly
  instantiated template never fails CI trying to reach a cluster that doesn't
  exist yet — and never deploys into an unintended namespace.
- **Required secret**: `KUBE_CONFIG` — a kubeconfig with rights to manage the
  deploy namespace. Images push to GHCR with the built-in `GITHUB_TOKEN`.
- **Required variable**: `MCP_NAMESPACE` — the namespace every release deploys
  into, set by `./scripts/bootstrap`. As a repo variable it stays per-instance,
  so two repos sharing a cluster don't collide.
- **Optional secret**: `MCP_INGRESS_HOST` — a shared hostname. When set, every
  toolset also gets an Ingress on that host at `/<name>`, and an `mcp-index`
  service (the same `Dockerfile` built with `TOOLSET=index`, which installs the
  runtime alone; deployed via `charts/mcp-index`) serves a directory of all
  toolsets at the domain root — see
  [Kubernetes cluster setup](#kubernetes-cluster-setup). When unset, services
  stay ClusterIP-only and the only access is `kubectl port-forward` via
  cluster RBAC:

```sh
kubectl -n __MCP_NAMESPACE__ port-forward svc/mcp-hello 8000:8000
uv run mcp-cli list
```

Build an image locally with `docker build --build-arg TOOLSET=hello .`.

- **Optional secret**: `MCP_CHAT_HOST` — a hostname for the hosted chat UI (see
  [Hosted chat](#hosted-chat-bring-your-own-model)). When `MCP_INGRESS_HOST` is
  set, the deploy builds `Dockerfile.chat` and installs `charts/mcp-chat` on
  this host (default `chat.<MCP_INGRESS_HOST>`). It needs its own DNS record and
  a TLS cert (`<namespace>-chat-tls`, issued by cert-manager if configured).

## Hosted chat (bring your own model)

The runtime's `mcp_agent` Chainlit UI can also run as a public web app over the
deployed toolsets, at `chat.<shared-domain>`. It is **bring-your-own-model**:
the deployment holds no provider key. Each user opens ⚙ settings and enters a
`provider:model` and their own API key (and any per-toolset credential headers);
the key lives only in that browser session — never sent to the model, logged, or
stored server-side — so exposing the host exposes no server-held secret and the
model spend is the user's own. The image (`Dockerfile.chat`) bundles a set of
providers (`anthropic`, `openai`, `google-genai`, `mistralai`) so any of them
works without a rebuild; the workspace itself stays provider-agnostic.

It deploys automatically alongside the index when `MCP_INGRESS_HOST` is set (a
shared-code change or a `workflow_dispatch` run). There is no built-in auth —
BYOM removes the shared-key abuse risk, but put an auth proxy in front (or
enable Chainlit auth) if you need to restrict who can use it.

Conversations are checkpointed per thread, **in the pod's memory by default** —
so a restart, a redeploy or a scale-up past the chart's single replica loses
them. That is fine for demos and is why nothing extra is deployed for it. To
keep conversations, point `MCP_AGENT_CHECKPOINT` at a PostgreSQL URL and add the
runtime's `[checkpointing-postgres]` extra to the chat image. The same
per-thread state also carries what the toolsets published, so if you do adopt
`mcp_state`, where conversations live becomes a real decision rather than a
detail.

## Kubernetes cluster setup

The deploy workflow assumes an existing cluster. Minimum requirements: a
conformant cluster (v1.24+) with outbound access to `ghcr.io`, plus the
one-time setup below. `__MCP_NAMESPACE__` is the namespace you chose at bootstrap
(the `MCP_NAMESPACE` variable's value); substitute it in the commands.

1. **Namespace and a scoped deploy service account** — the kubeconfig behind
   the `KUBE_CONFIG` GitHub secret. Don't use cluster-admin:

   ```sh
   kubectl create namespace __MCP_NAMESPACE__
   kubectl -n __MCP_NAMESPACE__ create serviceaccount deployer
   kubectl -n __MCP_NAMESPACE__ create role deployer --verb='*' \
     --resource=deployments.apps,services,secrets,serviceaccounts,ingresses.networking.k8s.io,roles.rbac.authorization.k8s.io,rolebindings.rbac.authorization.k8s.io
   kubectl -n __MCP_NAMESPACE__ create rolebinding deployer \
     --role=deployer --serviceaccount=__MCP_NAMESPACE__:deployer
   ```

   (`secrets` is Helm's release storage; `serviceaccounts`/`roles`/
   `rolebindings` are needed to install `charts/mcp-index`.)

   `KUBE_CONFIG` is a complete kubeconfig file with a deployer token inside —
   not the token alone. The API server URL must be reachable from GitHub's
   runners, and the token expires (~90 days here), after which deploys fail
   until the secret is refreshed:

   ```sh
   TOKEN=$(kubectl -n __MCP_NAMESPACE__ create token deployer --duration=2160h)
   SERVER=$(kubectl config view --minify -o jsonpath='{.clusters[0].cluster.server}')
   CA=$(kubectl config view --minify --raw -o jsonpath='{.clusters[0].cluster.certificate-authority-data}')

   KC=--kubeconfig=deployer.kubeconfig
   kubectl config $KC set-cluster cluster --server="$SERVER"
   kubectl config $KC set clusters.cluster.certificate-authority-data "$CA"
   kubectl config $KC set-credentials deployer --token="$TOKEN"
   kubectl config $KC set-context deployer --cluster=cluster --user=deployer --namespace=__MCP_NAMESPACE__
   kubectl config $KC use-context deployer

   gh secret set KUBE_CONFIG < deployer.kubeconfig && rm deployer.kubeconfig
   ```

2. **GHCR pull secret** — if the repo is private, its images are too. Both
   charts reference a `ghcr-pull` Secret by default; create it from a GitHub
   personal access token (classic) with the `read:packages` scope:

   ```sh
   kubectl -n __MCP_NAMESPACE__ create secret docker-registry ghcr-pull \
     --docker-server=ghcr.io \
     --docker-username=<github-username> \
     --docker-password=<token-with-read:packages>
   ```

3. **ingress-nginx** — the charts' ingress defaults assume it:

   ```sh
   helm upgrade --install ingress-nginx ingress-nginx \
     --repo https://kubernetes.github.io/ingress-nginx \
     --namespace ingress-nginx --create-namespace
   ```

4. **cert-manager** — issues and renews the shared domain's certificate:

   ```sh
   helm upgrade --install cert-manager cert-manager \
     --repo https://charts.jetstack.io \
     --namespace cert-manager --create-namespace \
     --set crds.enabled=true
   ```

5. **DNS + a ClusterIssuer.** Point an A/CNAME record for your chosen
   hostname at the ingress controller's load balancer
   (`kubectl -n ingress-nginx get svc ingress-nginx-controller`), and tell
   cert-manager how to reach Let's Encrypt — the one resource it can't
   create for itself. Set your email in `k8s/letsencrypt-clusterissuer.yaml`
   (Let's Encrypt sends expiry warnings there), then:

   ```sh
   kubectl apply -f k8s/letsencrypt-clusterissuer.yaml
   ```

   Certificates are then automatic: the `mcp-index` Ingress is annotated
   `cert-manager.io/cluster-issuer: letsencrypt`, so cert-manager issues and
   renews the `__MCP_NAMESPACE__-tls` Secret that all the Ingresses share. If
   your issuer is named differently, override `ingress.clusterIssuer` in
   `charts/mcp-index`.

6. **Per-toolset Secrets**, created out-of-band (`kubectl create secret ...`),
   for any names a toolset lists under `secrets:` in its `toolset.yaml`.

Finally set the optional shared-domain secret (step 1 already pushed
`KUBE_CONFIG`):

```sh
gh secret set MCP_INGRESS_HOST --body <the-hostname>
```

### One domain for all toolsets

With `MCP_INGRESS_HOST` set (e.g. `mcp.example.com`), the domain serves:

```
https://<host>/                   # index: JSON directory of every toolset + its tools
https://<host>/docs               # the same directory, browsable (Swagger UI)
https://<host>/<toolset>/mcp      # MCP endpoint (prefix stripped by ingress)
https://<host>/<toolset>/health   # liveness, lists the toolset's tool names
```

Anyone you give the URL to can discover what's deployed from the root index —
`mcp-index` lists the toolset Services via the Kubernetes API and asks each
one's `/health` for its tool names, so it always reflects what is actually
running:

```sh
curl https://<host>/ | jq
uv run mcp-cli list --url https://<host>/hello/mcp
```

The index's `connections` key is shaped for
`langchain_mcp_adapters.client.MultiServerMCPClient`, so an agent can consume
every deployed toolset in three lines:

```python
import httpx
from langchain_mcp_adapters.client import MultiServerMCPClient

connections = httpx.get("https://<host>/").json()["connections"]
tools = await MultiServerMCPClient(connections).get_tools()
```

The runtime's `mcp-agent` does exactly that as an interactive chat. The model is
provider-agnostic and no provider ships by default: `PROVIDER_MODEL` is a
`provider:model` string passed to LangChain's `init_chat_model` and
`PROVIDER_API_KEY` is that provider's key. Pick a provider, install its package
(`uv add langchain-openai`), and set both — in the environment or a `.env`
file (copy `.example.env`):

```sh
uv add langchain-openai                          # one-time: install a provider
export PROVIDER_MODEL=openai:gpt-4o-mini PROVIDER_API_KEY=sk-...

uv run mcp-agent https://<host>/                # all deployed toolsets
uv run mcp-agent http://localhost:8000/         # or every toolset from mcp-serve-local
uv run mcp-agent http://localhost:8000/mcp      # or one local mcp-serve
uv run mcp-agent --model anthropic:claude-3-5-haiku-latest   # override the model
uv run mcp-agent                                # url + model from .env
```

An index URL and a single server URL are both accepted, so the local loop above
and the deployed domain are the same command with a different argument.

Any `init_chat_model` provider works (`openai:`, `anthropic:`, `mistralai:`,
…) — switching is a `PROVIDER_MODEL` change plus that provider's package. The
same agent is available as a Chainlit chat UI: `uv run mcp-agent-web` serves it
at `http://localhost:8080`. It is **bring-your-own-model** — set the model and
API key in ⚙ settings, or pre-fill them from the environment/.env
(`PROVIDER_MODEL`, `PROVIDER_API_KEY`); `MCP_URL` (which index or server to chat
with) and `CHAINLIT_PORT` also come from there. See
[Hosted chat](#hosted-chat-bring-your-own-model) to run it as a public web app.

Each Helm release owns its own Ingress for the same host and the controller
merges them, so the domain's routing table tracks deploys with no central
config to edit; the index's `/` path only catches what no toolset claims.

### Per-user credentials

Tools that act on a user's behalf (with credentials that differ per calling
user) must not bake secrets into the deployment — and must not take them as
tool arguments either, or the model sees them and they land in chat history
and traces. Instead the client sends them as HTTP headers on every MCP
call, and the tool reads them at call time:

```python
from mcp_runtime.credentials import credential_from_header

@tool
def whoami() -> WhoamiResult:
    """Report which account the calling user's credential belongs to."""
    token = credential_from_header("x-demo-token")
    ...

TOOLS = [whoami]
CREDENTIAL_HEADERS = ["x-demo-token"]  # advertised; validated by the contract test
```

The `CREDENTIAL_HEADERS` export is advertised in the toolset's `/health` and
in the index's `toolsets` entries, so clients know which toolset needs which
credential — and send each one *only* to the connections that declare it,
never to unrelated toolsets. `toolsets/credential-demo` is a working
(stubbed) example. Clients attach the header per connection — agents by
decorating the index's `connections` map, `mcp-cli` with `-H`:

```python
connections = httpx.get("https://<host>/").json()["connections"]
connections["credential-demo"]["headers"] = {"X-Demo-Token": user_token}
tools = await MultiServerMCPClient(connections).get_tools()
```

`mcp-agent` goes further, in the shape a multi-user deployment needs: the
agent is built **once** and credentials are supplied per call. Each
connection gets an httpx client factory that, at request time, injects the
calling user's headers — only those the toolset's advertised declaration
names (for a direct single-server URL the agent asks the endpoint's sibling
`/health` for its declaration):

```python
from mcp_agent.main import user_credentials

with user_credentials({"x-demo-token": the_users_token}):
    result = await agent.ainvoke(...)
```

The Chainlit UI builds a settings field (⚙ by the message box) for every
credential header the connected toolsets advertise and applies the values
per message — so one long-lived agent process serves many users, each with
their own credentials.

```sh
uv run mcp-cli call whoami \
  --url https://<host>/credential-demo/mcp -H "X-Demo-Token: $TOKEN"
```

The secret rides the transport (TLS-encrypted at the ingress), never the
conversation, and the service stays stateless: every call carries its own
credential, so one pod serves all users. A missing header raises a
`MissingCredentialError` whose message tells the caller how to supply it.
Test credential-using tools without a server via
`mcp_runtime.credentials.header_context`:

```python
with header_context({"x-demo-token": "secret"}):
    whoami.invoke({})
```

## Development

```sh
./scripts/format        # ruff autofix + format
./scripts/lint          # ruff checks + mypy over tests/ and toolsets/
./scripts/test          # pytest (args forwarded, e.g. ./scripts/test -k hello)
```

The root `pyproject.toml` defines the uv workspace (`toolsets/*`), the
`mcp-toolsets-runtime` pin, shared tool configuration and the dependency
groups; `uv.lock` pins the runtime and the whole workspace consistently, and is
what the images build from.

`tests/` holds only the toolset contract sweep — every directory under
`toolsets/` must import, export a non-empty `TOOLS`, and satisfy the same
`ToolResult` and docstring gates `build_server` applies at startup. Tests for
runtime behaviour live in
[mcp-toolsets-runtime](https://github.com/developmentseed/mcp-toolsets-runtime),
not here.