Platform Engineering
9 min readAugust 16, 2026

Build a Kubernetes MCP Server From Scratch

CO
Coding Protocols Team
Platform Engineering
Build a Kubernetes MCP Server From Scratch

Quick answer

Tools living inside one agent script can't be reused. Moving them behind an MCP server that runs in-cluster gets you a real ServiceAccount identity instead of a kubeconfig on somebody's laptop.

9 min read · Platform Engineering

Build a Kubernetes MCP Server From Scratch

If you've written an agent that troubleshoots Kubernetes, you already have the hard part: functions that read pods, events, and logs, and return something a model can reason about.

The problem is where they live. Inside one Python script, those tools are usable by exactly one program. Want the same capability in Claude Code? Reimplement. In a chat bot? Reimplement. In a second agent your colleague is writing? Reimplement — and now there are three copies of the log-truncation logic, two of which are wrong.

That's the N-clients × M-tools problem, and MCP exists to collapse it. Write the tools once behind a server; every MCP-aware client gets them.

There's a second benefit that turns out to matter more than reuse, and it's about credentials. We'll get to it.

The server

The Python SDK generates the protocol layer from your type hints, so a tool is a normal function with an annotated signature and a docstring:

bash
pip install "mcp[cli]" kubernetes
python
1# server.py
2from datetime import datetime, timezone
3
4from kubernetes import client, config
5from mcp.server import MCPServer
6
7# In-cluster when deployed; falls back to kubeconfig for local development.
8try:
9    config.load_incluster_config()
10except config.ConfigException:
11    config.load_kube_config()
12
13core = client.CoreV1Api()
14mcp = MCPServer("kubernetes-readonly")
15
16MAX_LOG_BYTES = 20_000
17UNDATED = datetime.min.replace(tzinfo=timezone.utc)
18
19
20@mcp.tool()
21def get_pods(namespace: str, selector: str | None = None) -> str:
22    """List pods with phase, restart count, and failure reason.
23
24    Start here when you don't yet know which pod is broken.
25    """
26    pods = core.list_namespaced_pod(namespace, label_selector=selector or "")
27    if not pods.items:
28        return f"No pods found in {namespace}"
29
30    lines = []
31    for p in pods.items:
32        statuses = p.status.container_statuses or []
33        restarts = sum(c.restart_count for c in statuses)
34        reasons = [
35            c.state.waiting.reason
36            for c in statuses
37            if c.state and c.state.waiting and c.state.waiting.reason
38        ]
39        lines.append(
40            f"{p.metadata.name}  phase={p.status.phase}  restarts={restarts}"
41            + (f"  {', '.join(reasons)}" if reasons else "")
42        )
43    return "\n".join(lines)
44
45
46@mcp.tool()
47def get_logs(namespace: str, name: str, previous: bool = False) -> str:
48    """Read container logs. Set previous=true to read the container that just
49    crashed — for a restart loop, that is where the error is.
50    """
51    try:
52        text = core.read_namespaced_pod_log(
53            name=name, namespace=namespace, previous=previous, tail_lines=200
54        )
55    except client.exceptions.ApiException as e:
56        return f"Could not read logs: {e.reason}"
57
58    if len(text) > MAX_LOG_BYTES:
59        text = "...[truncated]...\n" + text[-MAX_LOG_BYTES:]
60    return text or "(empty)"
61
62
63@mcp.tool()
64def get_events(namespace: str, name: str | None = None) -> str:
65    """Recent events for a namespace or a single object. Events explain
66    scheduling failures, image pull errors, and OOM kills.
67    """
68    field = f"involvedObject.name={name}" if name else ""
69    events = core.list_namespaced_event(namespace, field_selector=field)
70    # Some events carry neither timestamp. Sort those first with an aware
71    # sentinel — a bare 0 makes int meet datetime and raises TypeError.
72    rows = sorted(events.items, key=lambda e: e.last_timestamp or e.event_time or UNDATED)
73    return "\n".join(
74        f"{e.last_timestamp}  {e.type}  {e.reason}: {e.message}" for e in rows[-25:]
75    ) or "No events found."

That's the whole server. Note what isn't there: no JSON Schema. namespace: str, previous: bool = False is the schema — the SDK derives it from the annotations, and the docstring becomes the tool description the model reads. Compared with hand-writing schemas in an agent script, this removes the most common source of drift, where the schema and the function signature disagree and the model gets rejected inputs.

Because the docstring is the description, write it for the model. "Set previous=true … that is where the error is" is not documentation; it's the instruction that stops the agent reading the wrong log.

Run it against the MCP Inspector to poke at the tools by hand before pointing a model at them:

bash
mcp dev server.py

Choosing a transport

MCP servers speak either stdio or Streamable HTTP, and the choice is really a deployment decision.

stdio — the client launches your server as a subprocess and talks over stdin/stdout. Simple, no network, no auth to design, and the server inherits the launching user's kubeconfig. Right for local developer tooling.

Streamable HTTP — the server is a long-lived service clients connect to over the network. Right for anything shared.

bash
mcp run server.py --transport streamable-http

That binds 127.0.0.1:8000, which is right on a laptop and useless in a container — mcp run has no --host flag, and a pod listening on loopback is unreachable from its own Service. For the deployed build, call run yourself so the bind address is explicit:

python
if __name__ == "__main__":
    mcp.run(transport="streamable-http", host="0.0.0.0", port=8000)

The line between them isn't convenience, it's identity. Over stdio the server runs as you, with your kubeconfig and your permissions. Deployed over HTTP it runs as itself, with its own ServiceAccount. That distinction is the thing worth designing around.

Kubernetes Production Readiness Checklist

The pre-launch checks we run before calling a cluster production-ready — probes, resources, RBAC, upgrades, and backups. Plain Markdown you can commit to your repo.

Free. Instant download. You'll also get the occasional deep-dive from the newsletter — unsubscribe anytime.

The real reason to do this: identity

An agent script running on a laptop authenticates with whatever kubeconfig that laptop has. Which is usually a cluster-admin context, because that's what engineers carry.

So the agent's actual ceiling is your permissions. The read-only design in the code is a convention, not a control — nothing stops a future tool from writing, and nothing stops a prompt-injected agent from trying. The security story is "we only wrote read functions," which holds exactly until someone adds a fifth tool.

Deploy the same tools as an in-cluster MCP server and the picture inverts. The server runs as a pod, with a ServiceAccount, and Kubernetes enforces its ceiling regardless of what the code or the model attempts:

yaml
1apiVersion: apps/v1
2kind: Deployment
3metadata:
4  name: k8s-mcp-server
5  namespace: mcp
6spec:
7  replicas: 2
8  selector:
9    matchLabels: { app: k8s-mcp-server }
10  template:
11    metadata:
12      labels: { app: k8s-mcp-server }
13    spec:
14      serviceAccountName: k8s-mcp-server     # the actual boundary
15      containers:
16        - name: server
17          image: ghcr.io/your-org/k8s-mcp-server:v0.1.0
18          command: ["python", "server.py"]        # binds 0.0.0.0:8000
19          ports:
20            - containerPort: 8000
21          securityContext:
22            runAsNonRoot: true
23            readOnlyRootFilesystem: true
24            allowPrivilegeEscalation: false
25            capabilities:
26              drop: ["ALL"]
27          resources:
28            requests: { cpu: 100m, memory: 128Mi }
29            limits:   { memory: 256Mi }

Bind that ServiceAccount to a read-only ClusterRole and the guarantee becomes structural. A tool that tries to delete a Deployment gets a 403 from the API server — not because the code refused, but because the credential cannot. Scoping it properly is its own subject: giving an AI agent read-only Kubernetes access covers which verbs look safe and aren't, and why reads become an egress concern once a model provider is downstream.

There's a bonus: the server sits in the cluster, so it doesn't need a publicly reachable API server endpoint, and no human needs a kubeconfig to use the tooling.

The multi-tenancy problem

Here's what the tutorials skip.

A shared MCP server has one identity. Every client that connects gets the same permissions — the server's — regardless of who is driving the client. If Alice can only see team-a but the MCP server can read every namespace, Alice now reads every namespace by asking an agent nicely.

You have three options, and they're a genuine trade rather than a ranking:

One server per team, each with a namespace-scoped ServiceAccount. Simple, enforced by Kubernetes, no code. It's N deployments to run, which is fine at five teams and irritating at fifty.

Pass the user's identity through and impersonate. The server accepts the caller's token and uses Kubernetes user impersonation so the API server evaluates their permissions. Correct, and considerably more work: you need real authentication on the HTTP transport, and the server's own ServiceAccount needs the impersonate verb — which is a powerful grant that has to be tightly scoped, or you've built a privilege-escalation service.

Accept a shared read-only identity, scoped to what everyone may see. Pragmatic for a platform team's own tooling. Not acceptable if namespaces are a tenancy boundary you rely on.

Pick deliberately and write down which one you chose. The failure mode is drifting into option three by accident — deploying a convenient cluster-wide server for one team and having four others discover it.

Connecting a client

Point Claude Code at it over stdio for local work:

json
1{
2  "mcpServers": {
3    "kubernetes": {
4      "command": "mcp",
5      "args": ["run", "/path/to/server.py"]
6    }
7  }
8}

Or connect to the deployed one over HTTP. Either way, once it's registered you stop writing agent loops for this — the client already has one. Your agent script shrinks to a prompt, and the tools become infrastructure that outlives it.

Operating it

Three things you'll want before this is a real service.

Bound every output, in the server. The truncation in get_logs matters more here than it did in the agent script, because now it's enforced once for every client. A tool returning a 4 MB log doesn't just cost tokens — it's a payload every consumer inherits. The server is the correct place for that limit precisely because clients can't be trusted to add it.

Trace across the boundary. Once the server is a separate process, your traces break at the protocol edge unless you propagate context — MCP carries W3C trace context in its _meta field, which works over stdio where headers don't exist. Tracing MCP tool calls with OpenTelemetry covers the propagation and which spans to create.

Version the tool surface. Tool names and signatures are an API. Renaming a parameter breaks every client and every saved prompt that references it. Treat additions as cheap and changes as breaking, exactly as you would for any other service.

Where this leaves you

The build is an afternoon; three tools and a Dockerfile. The architecture is the point: tools that were an implementation detail of one script become a service with an identity, a permission ceiling the cluster enforces, and a surface any client can consume.

That's also the honest limit. An MCP server doesn't make an agent smarter — it makes a capability reusable and its blast radius explicit. For the wider view of what the protocol changes about infrastructure tooling, the MCP revolution in DevOps is the strategic take; for the workload-identity patterns underneath the ServiceAccount, service accounts and workload identity is the foundation.

Was this article helpful?

Be the first to rate this article

Related Topics

MCP
Kubernetes
AI Agents
Platform Engineering
Python
ServiceAccount

Found this useful? Share it.

Practice this

Related tools

Read Next