Skip to main content
Glama
wri
by wri

project-zeno-mcp

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

Related MCP server: mcp-arcgis-tulsa

Run it

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.

    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:

    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 below).

  3. The web client.

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

To call a tool with no model in the loop:

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 numbersdata 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_aoipick_datasetpull_datagenerate_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

./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:

./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 template — a monorepo of toolsets (small packages of LangChain tools), each deployed as its own MCP service. Everything that is not a toolset comes from one PyPI package, 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, 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

Everything that isn't a toolset comes from one PyPI package, 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.

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.

Quickstart

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):

    uv run mcp-toolset new my-toolset
  2. Write your tools in toolsets/my-toolset/src/my_toolset/tools.py:

    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 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. 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):

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, 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:

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

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 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/):

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

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

  • 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. When unset, services stay ClusterIP-only and the only access is kubectl port-forward via cluster RBAC:

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). 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:

    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:

    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:

    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:

    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:

    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:

    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):

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:

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:

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):

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 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:

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:

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):

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.

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:

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

Development

./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, not here.

Maintenance

ActivityMaintained
ResponsivenessNo issues

Resources

Unclaimed servers have limited discoverability.

Looking for Admin?

If you are the server author, to access and configure the admin panel.

Related MCP Connectors

Related MCP Servers

Latest Blog Posts

MCP directory API

We provide all the information about MCP servers via our MCP API.

curl -X GET 'https://glama.ai/api/mcp/v1/servers/wri/project-zeno-mcp'

If you have feedback or need assistance with the MCP directory API, please join our Discord server