project-zeno-mcp
OfficialClick on "Install Server".
Wait a few minutes for the server to deploy. Once ready, it will show a "Started" state.
In the chat, type
@followed by the MCP server name and your instructions, e.g., "@project-zeno-mcpanalyze tree cover loss in the Amazon"
That's it! The server will respond to your query, and you can continue using it as needed.
Here is a step-by-step guide with screenshots.
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 |
|
| 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. |
|
| Picks the dataset, context layer and date range that answer a request, from the catalog in |
|
| 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. |
|
| 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 .venvzeno-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 |
| The AOI catalogue ( |
| The geocoder's extraction step, dataset selection, and both stages of |
| The GNW analytics API, which is where every statistic comes from ( |
| Optional. The model |
| Optional. Datasets this deployment hides, by catalog name. Defaults to what zeno hides — see below. |
| Optional. Point |
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:
The toolsets.
uv run mcp-serve-local # toolsets at /<name>/mcp, index at /, on :8000The agent API. From the repo root, so
.envis found:uv run uvicorn agent_app:app --port 8765agent_app:app— not the runtime'smcp_agent_api.app:app— is this repo's system prompt. It carries the rules a tool docstring cannot enforce (see The agent below).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) |
| — |
|
|
|
|
|
|
|
|
|
|
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:
The chain order:
pick_aoi→pick_dataset→pull_data→generate_insight.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.
Candidates and no selection means ask the user. Never choose for them.
A request about a dataset alone needs no area.
Reuse an
aoi_selectionfor the same place; callpick_aoiagain 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.
customis not a valid AOI source here.The insights store. No
InsightOrm, noStatisticsOrm, 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 removedreplace_csv_paths_with_urls— its only purpose was to make those stored code blocks runnable elsewhere.update_insight_displayand itsdisplay_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'slanguageparameter, 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 indocs/dataset-selection-eval.md.Langfuse tracing.
turn_contextoncreate_appis 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
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 |
| Required. Serves a toolset's |
| Development inner loop. Typer/rich client ( |
| Scaffolding. |
| Optional example chat ( |
| Already working on these toolsets, untagged. It keeps large tool values out of the model's context. Every |
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 modelThat 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.
Scaffold with the runtime's generator (registers the package in the uv workspace too):
uv run mcp-toolset new my-toolsetWrite 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]TOOLSis 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 asasync def—@toolsupports 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 shippedhellotoolset is a minimal starting point you can copy.Add tests in
toolsets/my-toolset/tests/test_my_toolset.pyand run./scripts/test.(Optional)
toolsets/my-toolset/toolset.yamlholds Helm value overrides — secrets to mount viaenvFrom, env vars, resources, replicas. Seecharts/mcp-toolset/values.yamlfor the available keys.Merge to
main. CI buildsghcr.io/<owner>/<repo>/mcp-my-toolsetand deploys themcp-my-toolsetservice 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 strmessage(the human-readable answer a model or UI reads first) plus any data keys your tool declares.ToolError— a structured error: a short machine-readableerrorkind and adetailsaying 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 | ToolErrorfor tools whose message is the whole answer (drop theToolErrorarm if the tool raises instead of returning errors — exceptions become MCPisErrorresults, which skip schema validation).Recommended: one
ToolResultsubclass per tool, adding each data key asNotRequired[...], annotated-> MyResult | ToolError. Give the subclass a one-line docstring — it becomes the schema'sdescription. 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()(aTypeIsguard) narrows helper results typeddict[str, Any] | ToolErrorin 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/listreturns anddict[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
@toolwraps; the runtime reads it viatool.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 buildThe contract
A toolset opts in with three things, validated at startup — a missing bundle,
or a view naming an unknown tool, aborts build_server:
VIEWS— a{tool_name: view_id}export in the tools module.A built bundle at
<package>/views/<view_id>.html, self-contained (all JS/CSS inlined). The shippedui/builds these with Vite +vite-plugin-singlefile, one pass per view (VIEW=<id> vite build), writing into the package'sviews/dir. Built bundles are git-ignored; the Dockerfile's node stage and./scripts/build-viewsrebuild them.The host bridge —
@developmentseed/mcp-view, the npm half of the runtime. It wraps the MCP Appsui/*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 chatAny 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.jsxRe-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-toolsetMerge 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, rootpyproject.toml) rebuild all toolsets, which is how a runtime version bump reaches every service — then per toolset: build and pushghcr.io/<owner>/<repo>/mcp-<name>:<sha>andhelm upgrade --install mcp-<name> charts/mcp-toolset -n __MCP_NAMESPACE__. A reconcile job also uninstalls releases whosetoolsets/<name>directory is gone — see Removing a toolset.Deploy guard: the cluster-touching jobs are skipped unless both the
KUBE_CONFIGsecret and theMCP_NAMESPACEvariable 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-inGITHUB_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 anmcp-indexservice (the sameDockerfilebuilt withTOOLSET=index, which installs the runtime alone; deployed viacharts/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 iskubectl port-forwardvia cluster RBAC:
kubectl -n __MCP_NAMESPACE__ port-forward svc/mcp-hello 8000:8000
uv run mcp-cli listBuild 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). WhenMCP_INGRESS_HOSTis set, the deploy buildsDockerfile.chatand installscharts/mcp-chaton this host (defaultchat.<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.
Namespace and a scoped deploy service account — the kubeconfig behind the
KUBE_CONFIGGitHub 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(
secretsis Helm's release storage;serviceaccounts/roles/rolebindingsare needed to installcharts/mcp-index.)KUBE_CONFIGis 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.kubeconfigGHCR pull secret — if the repo is private, its images are too. Both charts reference a
ghcr-pullSecret by default; create it from a GitHub personal access token (classic) with theread:packagesscope:kubectl -n __MCP_NAMESPACE__ create secret docker-registry ghcr-pull \ --docker-server=ghcr.io \ --docker-username=<github-username> \ --docker-password=<token-with-read:packages>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-namespacecert-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=trueDNS + 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 ink8s/letsencrypt-clusterissuer.yaml(Let's Encrypt sends expiry warnings there), then:kubectl apply -f k8s/letsencrypt-clusterissuer.yamlCertificates are then automatic: the
mcp-indexIngress is annotatedcert-manager.io/cluster-issuer: letsencrypt, so cert-manager issues and renews the__MCP_NAMESPACE__-tlsSecret that all the Ingresses share. If your issuer is named differently, overrideingress.clusterIssuerincharts/mcp-index.Per-toolset Secrets, created out-of-band (
kubectl create secret ...), for any names a toolset lists undersecrets:in itstoolset.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 namesAnyone 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/mcpThe 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 .envAn 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 testThe 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.
This server cannot be installed
Maintenance
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
Governed data discovery, exact queries, decisions, simulations, and runtime utilities over MCP.
Geospatial AI MCP server — satellite imagery, embeddings, weather, GNS governance
Ask in plain English, get a rendered, shareable map from live public data. 24 geospatial tools.
Geocoding, truck routing, traffic, weather, and place search via MCP — 11 hosted tools.
Related MCP Servers
- AlicenseAqualityBmaintenanceMCP server for the loc8n Geographic Data API. Exposes U.S. demographics, housing, mortgage, migration, employment, and geographic data as tools.2351MIT
- AlicenseNot gradedqualityCmaintenanceEnables searching and querying City of Tulsa GIS open geospatial datasets (parcels, zoning, public works) via ArcGIS Feature Services through natural language or direct MCP tool calls.13MIT
- AlicenseNot gradedqualityCmaintenanceEnables AI agents to perform spatial analysis in ArcGIS Pro using natural language by exposing all ArcGIS Pro toolboxes as MCP tools.4MIT
- AlicenseNot gradedqualityBmaintenanceEnables discovery, analysis, and explanation of open satellite data through MCP, with support for STAC catalogs and NASA GIBS.1MIT
Latest Blog Posts
- Who's Calling? MCP Hosts Are an Identity Blind Spot (And the Spec Knows It)By Om-Shree-0709 on .mcpAgent IdentityOAuth 2.1
- Your AI Chatbot Just Exposed Your CEO's Salary to an InternBy Om-Shree-0709 on .Agent IdentityMCP SecurityOAuth Delegation
- Why MCP Servers Need Execution Sandboxing (And Why Your Current Stack Isn't Enough)By Om-Shree-0709 on .Agentic AiPrompt InjectionWebAssembly
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