A template monorepo of toolsets — small packages of LangChain tools — each auto-deployed as its own MCP service on Kubernetes. Toolset implementors write a single Python module; a shared runtime, one parameterized Dockerfile and one generic Helm chart handle everything else.
toolsets/<name>/tools.py ──▶ ghcr.io/<owner>/<repo>/mcp-<name> ──▶ k8s Service mcp-<name>
(LangChain @tool fns) (Dockerfile --build-arg TOOLSET=...) (charts/mcp-toolset)
Terminology: a tool is a single LangChain @tool function; a toolset
is a directory under toolsets/ exporting a TOOLS list, deployed as one MCP
server.
This repository is a GitHub template — click Use this template to create your own. The image registry path is derived from your repo name automatically; the only thing to set is the Kubernetes namespace:
-
Bootstrap once — do this first.
./scripts/bootstrapis the intended first step after creating your repo. It does three things:- sets the
MCP_NAMESPACErepo Actions variable (the namespace your toolsets deploy into) viagh— deploys are skipped until this is set; - rewrites the
__MCP_NAMESPACE__placeholders inREADME.mdandCLAUDE.mdin place, so the cluster-setup commands below become copy-pasteable for your namespace; - optionally removes the shipped example toolsets.
./scripts/bootstrap # prompts for a namespace + which examples to keep # or non-interactively: ./scripts/bootstrap my-namespace --keep-examples
Re-running is safe. The namespace lives only in the repo variable, not a committed file, so it never carries over to repos generated from your instance. If
ghisn't set up when you bootstrap, the script prints the one command to set the variable yourself (gh variable set MCP_NAMESPACE --body <namespace>); the__MCP_NAMESPACE__placeholders then stay literal until you re-run bootstrap or edit them by hand. - sets the
-
Develop —
uv sync, then add a toolset (./scripts/new-toolset) or play with the shippedhelloexample (see Quickstart). -
Deploy when ready — set the
KUBE_CONFIGsecret (see Kubernetes cluster setup). Until you do, CI runs lint/tests/build on every push but skips the deploy — a fresh instance is green out of the box, with no cluster required.
The repo ships two example toolsets you can keep, copy or delete: hello (the
smallest thing that deploys) and credential-demo (the
per-user credentials pattern).
| Package | Role |
|---|---|
packages/mcp-runtime |
Required. Discovers a toolset's TOOLS and serves them as a stateless streamable-HTTP MCP server (mcp-serve), with a /health route for k8s probes. Also builds the mcp-index. |
packages/mcp-cli |
Recommended for development. Typer/rich client (mcp-cli) to list and call tools on a running service — the day-to-day inner loop. |
packages/mcp-agent |
Optional example. A chat agent (mcp-agent / mcp-agent-web) that discovers every server behind an index URL and drives their tools with a configurable chat model. Delete it if you don't need an agent. |
Plus toolsets/* — one directory per toolset, each becoming an MCP service —
and charts/mcp-toolset, the generic Helm chart all toolsets deploy through.
uv sync # installs every workspace member into one .venv
./scripts/test # run all tests
./scripts/lint # ruff + mypy (./scripts/format to autofix)
# Serve a toolset locally
TOOLSET=hello uv run mcp-serve
# Serve more toolsets alongside it, each on its own port
TOOLSET=credential-demo PORT=8001 uv run mcp-serve
# Talk to them from another shell
uv run mcp-cli list
uv run mcp-cli call hello name=dev
uv run mcp-cli repl
uv run mcp-cli call whoami --url http://localhost:8001/mcp -H "X-Demo-Token: s3cret"mcp-cli defaults to http://localhost:8000/mcp; pass --url to point
elsewhere. Each mcp-serve process serves exactly one toolset — the same
shape as production, where every toolset is its own pod and Service.
Toolsets are also importable directly (e.g.
from hello.tools import TOOLS) for in-process use in tests, notebooks or an
agent repo.
No Docker, Kubernetes or MCP knowledge needed — write ordinary LangChain tools and merge.
-
Scaffold (registers the package in the uv workspace too):
./scripts/new-toolset my-toolset
-
Write your tools in
toolsets/my-toolset/src/my_toolset/tools.py:from langchain_core.tools import tool @tool def do_something(query: str, limit: int = 10) -> list[dict]: """One-line description — docstrings and type hints ARE the MCP schema.""" ... TOOLS = [do_something]
TOOLSis the only required export. Non-empty docstrings 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>.
./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).
- 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 code (packages/,charts/,Dockerfile,uv.lock, rootpyproject.toml) rebuild all toolsets — 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 (built frompackages/mcp-runtime, 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 ..
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.kubeconfig
-
GHCR 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-namespace
-
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
-
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 ink8s/letsencrypt-clusterissuer.yaml(Let's Encrypt sends expiry warnings there), then:kubectl apply -f k8s/letsencrypt-clusterissuer.yaml
Certificates 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>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/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()packages/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/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 .envAny 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, configured entirely from the environment/.env —
MCP_URL (which index or server to chat with), PROVIDER_MODEL,
PROVIDER_API_KEY and CHAINLIT_PORT.
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.
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() -> dict[str, Any]:
"""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({})./scripts/format # ruff autofix + format
./scripts/lint # ruff checks + mypy over packages/ and toolsets/
./scripts/test # pytest (args forwarded, e.g. ./scripts/test -k hello)The root pyproject.toml defines the uv workspace (packages/*,
toolsets/*), shared tool configuration and the dev dependency group;
uv.lock pins the whole workspace consistently.