Corey Schue Avatar
, , ,
Scaffolding MCP Servers with kmcp: From Binary to Locked-Down Deployment

Scaffolding MCP Servers with kmcp

From binary install to a locked-down deployment — the full path, one command at a time.

Everyone is shipping MCP servers right now, and almost all of them are built to run on a laptop inside a desktop agent. That is fine until you want to run one as a managed service that other things depend on. Then the questions start: where does it run, who can reach it, how does it get its secrets, and what stops it from talking to everything on the network by default.

kmcp — part of the kagent project from Solo.io — is a CLI plus a Kubernetes controller that answers those questions with a single opinionated workflow. You scaffold a project, add tools, build an image, and deploy it as a first-class Kubernetes object via an MCPServer custom resource. No hand-written Dockerfiles, no reverse-engineering the spec into a Deployment manifest.

This is the top-to-bottom guide: install the binary, scaffold a server and a tool, run it locally, ship it to a cluster, and then do the part most tutorials skip — actually locking it down. The security posture here is the whole point. An MCP server is a tool endpoint an agent can call; starting from “deny” and earning each “yes” is the only sane way to run one.

Series context

This is the standalone kmcp deep-dive I promised in Part 2 of the Air-Gapped AI series, where scaffolding a server was compressed into a single bullet point that deserved more room. If you are here for the air-gapped angle specifically, start with Part 1: The Eccentricities of Air-Gapped AI for the why, then Part 2 for the full deployment pipeline, and Part 3: Operationalizing for secrets, RBAC, and observability behind the wall. This post is framework-agnostic — everything here applies whether or not you are air-gapped.

1. Installing the kmcp binary

[notice]

The CLI is your development tool. It scaffolds projects, generates tool boilerplate, builds container images, and runs servers locally. Install it with the upstream script:

curl -fsSL https://raw.githubusercontent.com/kagent-dev/kmcp/refs/heads/main/scripts/get-kmcp.sh | bash

Then confirm it landed:

kmcp --help
Air-gap note

That curl | bash pattern is a non-starter behind the wall — and, frankly, a bad habit anywhere. Pull the release binary from the GitHub releases page on a connected host, verify its checksum against the published manifest, move it across your transfer mechanism, and drop it on $PATH yourself. The script only fetches and installs a binary; nothing about kmcp requires the script to exist.

You also want a few companions on the workstation depending on which framework you scaffold: Docker for building images, uv if you go the FastMCP Python route, the Go toolchain if you go Go, and the MCP Inspector for local testing. kmcp shells out to these rather than reimplementing them.

2. Scaffolding a server

[notice]

kmcp scaffolds two first-class frameworks: FastMCP (Python) and the MCP Go SDK. Pick based on what your tools need to talk to — Python if you are wrapping data/ML libraries, Go if you want a single static binary and a smaller attack surface.

FastMCP (Python)

kmcp init python my-mcp-server

MCP Go

kmcp init go my-mcp-server --go-module-name my-mcp-server

Either command drops you into an interactive prompt for an optional description and author, then generates the full project: source layout, a sample echo tool, a Dockerfile you did not have to write, and — the important file — kmcp.yaml. That manifest is the source of truth kmcp reads at build and deploy time. It defines the framework, the container settings, and the per-environment secrets configuration you will edit later.

Look at the tree before you touch anything:

# Python layout
my-mcp-server/
├── kmcp.yaml            # project + deploy config
├── Dockerfile
├── pyproject.toml
└── src/
    ├── main.py
    └── tools/
        └── echo.py      # sample tool

The echo tool is deliberately boring. It exists so you have a known-good reference for the shape of a tool before you write your own.

3. Adding tools

[notice]

A server with only echo is a placeholder. Generate a new tool boilerplate with add-tool:

kmcp add-tool mytool

For a Python project this writes src/tools/mytool.py; for Go, tools/mytool.go. In both cases the generated file is a working echo-style tool you rewrite in place. The boilerplate handles the MCP plumbing — registration, the input schema, the transport wiring — so the only thing left for you is the function body.

Tool scope is a security decision

Every tool you add is a new verb an agent can invoke against your systems. Before you write one, decide what it is allowed to touch, and build that boundary into the tool itself — a read-only query tool should hold read-only credentials, not the admin token that happens to be in the environment. The tool is the last place you can enforce least privilege before the model decides how to use it. Grant narrow, grant nothing you cannot justify.

4. Running and testing locally

[warning]

Before anything goes near a cluster, run it on your machine:

kmcp run

This builds the image and automatically opens the MCP Inspector, a browser UI for exercising your tools. If you would rather not launch the Inspector, append --no-inspector.

When the Inspector opens, connect with the right transport for your framework:

  • Python / Go local run: transport type STDIO; command uv (Python) or go (Go).
  • Deployed / HTTP: transport type Streamable HTTP; URL http://127.0.0.1:3000/mcp.

The CLI prints a Proxy Session Token on startup. Paste it into the Inspector’s Configuration section or the connection fails — and it failing on a missing token is a preview of the posture you want everywhere: the endpoint does not talk to anonymous callers. List the tools, run mytool with a test string, confirm the result card echoes back. That loop — edit tool, kmcp run, exercise in Inspector — is your entire local dev cycle.

5. Installing the controller in-cluster

[notice]

The CLI builds and tests. The controller runs the server in Kubernetes and manages its lifecycle through the MCPServer CRD. Install the CRDs first, then the controller. Both ship as Helm charts from the project’s OCI registry:

# 1. CRDs
helm install kmcp-crds oci://ghcr.io/kagent-dev/kmcp/helm/kmcp-crds \
  --namespace kmcp-system \
  --create-namespace

# 2. controller
helm install kmcp oci://ghcr.io/kagent-dev/kmcp/helm/kmcp \
  --namespace kmcp-system

The controller bundle installs three things worth naming, because two of them are security-relevant: the MCPServer CRD that defines your servers as native objects, a ClusterRole and ClusterRoleBinding that scope what the controller itself is permitted to do, and the controller Deployment that reconciles MCPServer resources into running workloads.

Confirm the controller is healthy before you deploy anything onto it:

kubectl get pods -n kmcp-system

6. Secrets management

[warning]

Real tools need credentials — API keys, tokens, database passwords — and those never belong in your image, your kmcp.yaml, or your git history. kmcp handles this by syncing a local .env file into a Kubernetes Secret and wiring that Secret into the deployment automatically.

Open kmcp.yaml and find the secrets block. It ships with multiple environments — local, staging, production — each naming a Secret and namespace, and each disabled by default. Enable the one you want:

secrets:
  staging:
    enabled: true
    name: my-mcp-server-secrets-staging
    namespace: default

Put the actual values in a matching .env.staging file (which is not committed), then sync it into the cluster:

kmcp secrets sync staging \
  --from-file my-mcp-server/.env.staging \
  --project-dir my-mcp-server

Verify the Secret exists — the values will be base64-encoded, which is encoding, not encryption, so treat the output accordingly:

kubectl get secret my-mcp-server-secrets-staging -o yaml

Prefer to see the generated YAML without applying it? Add --dry-run to the sync command and pipe it wherever your review process lives.

Base64 is not a control

A synced Secret is plaintext to anyone with get secret in that namespace, and it sits in etcd. For anything beyond a lab, layer real protection underneath: Sealed Secrets so the encrypted form is the only thing that touches git, encryption-at-rest on etcd, and RBAC that keeps get secret off the list for everyone who does not need it. kmcp’s sync is the delivery mechanism, not the safe.

7. Deploying the server

[notice]

Build the image and, for a local kind cluster, load it directly so you skip a registry round-trip:

kmcp build --project-dir my-mcp-server \
  -t my-mcp-server:latest \
  --kind-load-cluster kind

For a real cluster, push the tagged image to your registry — Harbor, if you are behind the wall — and reference it by its full path. Then deploy, binding in the staging secrets you just synced:

kmcp deploy --environment staging \
  --file my-mcp-server/kmcp.yaml \
  --image my-mcp-server:latest \
  --no-inspector

The deploy command generates an MCPServer resource from your kmcp.yaml and applies it. The controller sees the new resource and reconciles it into a running Deployment and Service. Confirm the secret binding actually took — you want the reference under envFrom:

kubectl get deployment my-mcp-server -o yaml | grep -A3 envFrom

Drop --no-inspector and kmcp opens the Inspector against the deployed server so you can smoke-test it over Streamable HTTP exactly as you did locally.

Writing the MCPServer resource by hand

You do not have to go through kmcp deploy. The MCPServer CRD is a normal Kubernetes object, and for GitOps you will often want to commit it directly. A minimal resource for a stdio server that consumes a secret looks like this:

apiVersion: kagent.dev/v1alpha1
kind: MCPServer
metadata:
  name: my-mcp-server
spec:
  deployment:
    image: registry.internal/my-mcp-server:latest
    port: 3000
    cmd: "python"
    args: ["src/main.py"]
  secretRefs:
    - name: my-mcp-server-secrets-staging
  transportType: "stdio"
Gateway discovery flag

Planning to front these servers with agentgateway or kgateway so traffic is routed and authenticated centrally? Add the label kagent.dev/discovery: disabled to the resource. That stops kagent from auto-discovering the server directly and lets the gateway own the path — which is where you want your OAuth2 and RBAC enforcement living anyway.

8. The path I actually prefer: just ship the image

[notice]

Everything above treats kmcp as a lifecycle system — CLI, controller, CRD, the whole apparatus. That is the design, and it is genuinely useful when you want declarative fleet management. But here is the thing the docs bury: the scaffold hands you a working Dockerfile, and once you have an image, none of the rest is load-bearing. You do not need the controller, you do not need the CRDs, you do not need kmcp to exist on the cluster at all. You need an image and a Deployment.

For air-gapped work especially, this is the path I reach for. Every piece of the controller stack is one more thing to mirror across the wall, one more Helm chart to vendor, one more cluster-wide RBAC grant to audit. Dropping all of it in favor of a plain Deployment you wrote yourself is fewer moving parts and a smaller trust surface. To be fair, you also give up the lifecycle automation the controller provides — but for a handful of MCP servers, a Deployment is not exactly hard to manage by hand.

The workflow is four steps, and the wall only sits between the first and the rest:

1. Build the image on the connected side

The scaffold already wrote the Dockerfile, so kmcp build is just a convenience wrapper over docker build. Use whichever you like — the output is a standard OCI image either way:

# via kmcp
kmcp build --project-dir my-mcp-server -t my-mcp-server:latest

# or straight docker, identical result
docker build -t my-mcp-server:latest my-mcp-server/
Switch the transport to HTTP first

The scaffold defaults to stdio transport, which is fine for local Inspector testing but wrong for a standalone Deployment — a stdio server does not listen on a port, so a Service has nothing to route to. Before you build, set the server to streamable-http bound to 0.0.0.0 on your chosen port (the FastMCP entrypoint takes transport="streamable-http", host="0.0.0.0", port=3000). This is the single most common thing people miss when they drop the controller: the CRD used to configure transport for you, and now you own it.

2. Carry the image across and push to Harbor

Save the image to a tarball, move it through your transfer mechanism, load it on the inside, then tag and push to your internal registry:

# connected side
docker save my-mcp-server:latest -o my-mcp-server.tar

# after transfer, on the inside
docker load -i my-mcp-server.tar
docker tag my-mcp-server:latest harbor.internal/mcp/my-mcp-server:latest
docker push harbor.internal/mcp/my-mcp-server:latest

This is the same Harbor-as-the-source-of-truth pattern from Part 2 — the registry is the boundary artifact, and everything downstream pulls from it.

3. Write a plain Deployment and Service

No CRD, no custom API group — just Kubernetes objects any cluster already understands. Wire in the same secret you synced earlier with a normal envFrom:

apiVersion: apps/v1
kind: Deployment
metadata:
  name: my-mcp-server
spec:
  replicas: 1
  selector:
    matchLabels:
      app: my-mcp-server
  template:
    metadata:
      labels:
        app: my-mcp-server
    spec:
      automountServiceAccountToken: false
      containers:
        - name: mcp
          image: harbor.internal/mcp/my-mcp-server:latest
          ports:
            - containerPort: 3000
          envFrom:
            - secretRef:
                name: my-mcp-server-secrets-staging
          securityContext:
            runAsNonRoot: true
            allowPrivilegeEscalation: false
            readOnlyRootFilesystem: true
            capabilities:
              drop: ["ALL"]
---
apiVersion: v1
kind: Service
metadata:
  name: my-mcp-server
spec:
  selector:
    app: my-mcp-server
  ports:
    - port: 80
      targetPort: 3000

4. Deploy it

kubectl apply -f my-mcp-server.yaml
kubectl rollout status deployment/my-mcp-server

That is the whole thing. The MCP server is now a first-class workload you can reason about with tools you already know — kubectl, your existing GitOps pipeline, your normal Deployment conventions — with nothing kmcp-specific running in the cluster to maintain.

When the controller is still worth it

This is a deliberate trade, not a free win. The controller earns its keep when you are running many MCP servers and want declarative, consistent lifecycle management across all of them, or when you are leaning on its transport-adapter and agentgateway integration for centralized auth. For a small, stable set of servers behind the wall, the plain-Deployment path wins on simplicity. Pick based on how many of these you are actually running and how much you value one declarative surface over fewer components.

9. Hardening: the part that earns the “yes”

[critical]

kmcp gets you a running server. It does not, by itself, get you a safe one. Everything to this point has been about capability. This section is about constraint — the implicit-deny work that decides what your MCP server is permitted to do once it is live. Granted, none of this is unique to MCP; it is standard Kubernetes hygiene. But an MCP server is an unusually sharp instance of the problem, because on the other side of it is a model deciding which of your tools to call and how.

If you want the same posture applied specifically inside a boundary that does not talk back — RBAC scoped to the MCPServer CRD, NetworkPolicy as implicit deny, and observability with no external collector — Part 3 of the Air-Gapped AI series goes deeper on the operational side. What follows here is the framework-agnostic baseline.

Scope the controller’s RBAC

The controller ships with a ClusterRole. Read it. It runs cluster-wide and reconciles workloads on your behalf, which makes it exactly the kind of component you audit rather than trust by default. Confirm what it can actually do:

kubectl get clusterrole kmcp-controller -o yaml
kubectl describe clusterrolebinding kmcp-controller

If your servers live in a known set of namespaces, consider whether a namespaced Role serves you better than a ClusterRole. The default is convenient. Convenient and least-privilege are rarely the same setting.

Give each server its own ServiceAccount

By default a workload runs as its namespace’s default ServiceAccount, which is a shared identity you cannot reason about. Give every MCP server a dedicated ServiceAccount with no bound permissions unless a tool genuinely needs the Kubernetes API — and if none does, turn off the token entirely:

apiVersion: v1
kind: ServiceAccount
metadata:
  name: my-mcp-server
automountServiceAccountToken: false

NetworkPolicy as implicit deny

This is the one that matters most, and the one most people never write. In a default cluster, every pod can reach every other pod. An MCP server — an endpoint whose whole job is to execute actions on behalf of a model — should be the last workload you leave with open egress.

Start with a default-deny policy for the namespace, then punch the specific holes your server actually needs:

apiVersion: networking.k8s.io/v1
kind: NetworkPolicy
metadata:
  name: default-deny-all
spec:
  podSelector: {}
  policyTypes:
    - Ingress
    - Egress

With that in place nothing flows. Now allow only what the design requires — ingress from your gateway, egress to the one internal API a tool calls, DNS. Every rule you add is a “yes” you had to justify, which is the entire posture: the server starts isolated and earns each connection.

Pod-level security context

The generated Dockerfile is a starting point, not a hardened one. Constrain the pod through podTemplateSpec on the resource: run as non-root, drop all Linux capabilities, set a read-only root filesystem, and disable privilege escalation.

securityContext:
  runAsNonRoot: true
  allowPrivilegeEscalation: false
  readOnlyRootFilesystem: true
  capabilities:
    drop: ["ALL"]

Add resource limits while you are there. An MCP server that a model can drive into an unbounded loop is a denial-of-service waiting to happen; a CPU and memory ceiling turns “took down the node” into “got throttled.”

Front it with a gateway

Do not expose an MCP server’s transport directly to callers. Put agentgateway or kgateway in front so authentication, authorization, and observability live in one enforced place rather than being reimplemented per server. This is where OAuth2 protection and per-caller RBAC belong — the server stays a simple tool endpoint, and the gateway is the wall its traffic has to clear.


[also]

kmcp collapses a genuinely tedious problem — turning an MCP prototype into a managed Kubernetes service — into a handful of commands, and that is real leverage. But leverage cuts both ways. The same tool that makes it trivial to ship a server makes it trivial to ship one with open egress, a default ServiceAccount, and secrets sitting in plaintext, and to feel productive while doing it. The scaffolding is the easy 80%. The RBAC, the NetworkPolicy, the security context, the gateway — the parts that decide what your server is not allowed to do — are the 20% that was the actual job. An MCP endpoint is a set of verbs you are handing to a model. Ship it the way you would hand a set of keys to someone you have not met: start with the door locked, and open exactly the ones they can prove they need.

Leave a Reply

Your email address will not be published. Required fields are marked *

Latest Posts