DEV Community

Cover image for KMCP Turns MCP Servers Into Boring Infrastructure
hugolesta
hugolesta

Posted on

KMCP Turns MCP Servers Into Boring Infrastructure

Everyone building with LLMs eventually writes an MCP server. It works great on a laptop: npx some-mcp-server, point your client at stdio, done. Then someone asks the obvious question — "can the rest of the team use it?" — and you discover you've just signed up to write a Dockerfile, a Deployment, a Service, an Ingress, a secret wiring story, and a health check, for a process whose entire job is to expose four tools.

Multiply that by every tool your organization wants an agent to reach — ArgoCD, Jira, the incident tracker, the cost API — and you have a small fleet of bespoke microservices, each maintained by whoever wrote it first.

KMCP is the fix. It's a Kubernetes controller plus a CRD that turns "run this MCP server" into about 20 lines of YAML. This is what deploying it into a management EKS cluster actually looked like, and — more usefully — what changed once the platform's tools were on the other side of it.


The architecture

flowchart TD
    A["Helmfile — kmcp-crds + kmcp charts"] -->|"OCI chart from ghcr.io"| B["KMCP controller — kmcp-system namespace"]
    C["Git repo — mcpserver.yaml"] -->|"ArgoCD + cdk8s plugin"| D["MCPServer CR — team namespace"]
    B -->|"watches and reconciles"| D
    D -->|"controller creates"| E["Deployment + Service"]
    E -->|"Traefik IngressRoute — internal only"| F["mcp.internal.example.com"]
    F -->|"streamable HTTP — /mcp"| G["Agents, IDE clients, chat assistants"]
    E -->|"scoped API token"| H[("Platform tool — ArgoCD API")]
Enter fullscreen mode Exit fullscreen mode

Two moving parts, deliberately separated. The controller is platform infrastructure — installed once, per EKS cluster, by the platform team's Helmfile. The MCPServer resources are application config — owned by whoever owns the tool being exposed, shipped through the normal GitOps path. A team adding an MCP server never touches the platform repo.


Installing the controller

KMCP ships as OCI Helm charts, which means the repository entry needs oci: true — easy to miss, and the failure mode is a confusing "not a valid chart repository" error:

repositories:
  - name: kmcp
    url: ghcr.io/kagent-dev/kmcp/helm
    oci: true
Enter fullscreen mode Exit fullscreen mode

Then two releases, CRDs first:

releases:
  - name: kmcp-crds
    namespace: kmcp-system
    chart: kmcp/kmcp-crds
    version: ~>0.1

  - name: kmcp
    namespace: kmcp-system
    chart: kmcp/kmcp
    version: ~>0.1
    needs:
      - kmcp-crds
    installed: {{ .Environment.Values.kmcp.installed }}
Enter fullscreen mode Exit fullscreen mode

Splitting CRDs into their own release is not ceremony. Helm's CRD handling is famously half-hearted — it installs crds/ directory contents on first install and then never upgrades them. A separate chart with an explicit needs dependency means CRD upgrades are a normal Helm operation, and the controller never starts before the types it watches exist.

The installed: flag is wired per environment. In this rollout, kmcp.installed is true only in the management EKS cluster and explicitly false in dev, acc, and prod:

  mng:
    values:
      - kmcp:
          installed: true

  prod:
    values:
      - kmcp:
          installed: false
Enter fullscreen mode Exit fullscreen mode

Setting installed: false explicitly in every other environment rather than relying on a | default false is worth the extra lines. It makes the rollout state legible in one file — you can answer "where is KMCP running?" by reading, not by reasoning about template defaults.

Why the management cluster and not the workload EKS clusters? Because the tools worth exposing over MCP — ArgoCD, the CI control plane, the cost reporting API — already live there. Putting the MCP layer next to its backends avoids cross-cluster networking for what is otherwise a trivial HTTP hop.


An MCP server as 20 lines of YAML

Here's a real one — the ArgoCD MCP server, which gives an agent read access to application state, sync status, and logs:

apiVersion: kagent.dev/v1alpha1
kind: MCPServer
metadata:
  name: argocd-mcp
  namespace: platform-tools
  labels:
    kagent.dev/discovery: "disabled"
spec:
  transportType: http
  httpTransport:
    targetPort: 8770
    path: /mcp
  deployment:
    image: node:24-bookworm
    cmd: npx
    args:
      - argocd-mcp@latest
      - http
      - --port
      - "8770"
    port: 8770
    env:
      ARGOCD_BASE_URL: "https://argocd.internal.example.com"
Enter fullscreen mode Exit fullscreen mode

That's the whole thing. No Dockerfile, no Deployment, no Service, no readiness probe. The controller reconciles this into a Deployment and a Service, wires the port mapping, and manages the lifecycle from there.

A few details that matter more than they look:

transportType: http. MCP's original transport is stdio — the client spawns the server as a subprocess and talks over pipes. That model does not survive contact with Kubernetes: there's nothing to load balance, nothing to health check, and one client per process. Streamable HTTP is what makes an MCP server a service — many clients, one deployment, standard networking. If you're deploying MCP servers to a cluster and reaching for stdio, stop and switch.

image: node:24-bookworm with cmd: npx. This is a deliberate shortcut, not sloppiness. Most published MCP servers are npm packages with no official container image. Rather than maintaining a build pipeline for a wrapper image around a package you don't own, you run the stock Node image and let npx fetch it. The tradeoff is real and you should name it out loud: argocd-mcp@latest resolves at pod start, so a pod restart six weeks from now can silently pick up a different version. Fine for a management cluster running internal tooling; not fine for anything on the critical path. Pin the version (argocd-mcp@1.4.2) the moment this stops being an experiment.

kagent.dev/discovery: "disabled". This opts the server out of automatic tool discovery by kagent agents in the cluster. Default-on discovery is a lovely demo and a bad default in a shared environment — it means every agent gets every tool the moment someone deploys a new MCPServer. Explicitly disabling it and granting access deliberately is the version you want when more than one team shares a cluster.


Routing: internal, and deliberately so

The service gets exposed through the existing ingress stack rather than anything KMCP-specific:

ingressRoute:
  enabled: true
  enableDefaultRoute: true
  publicAccess: false
  internetFacing: true
  routesList:
    - serviceName: argocd-mcp
      portName: http
      host: argocd-mcp.internal.example.com
Enter fullscreen mode Exit fullscreen mode

publicAccess: false is doing the heavy lifting. An MCP server is, by construction, a remote code execution surface with a friendly schema on top — it exists to let a language model call functions against your infrastructure. It belongs on the internal load balancer, reachable over the corporate network, and nowhere else.

The portName: http took three attempts to get right in practice. The ingress controller matches the service port by name, and a name mismatch produces a route that resolves, returns a 502, and gives you no useful signal about why. If your MCP route 502s, check the port name before you check anything else.


Shipping it through GitOps, not a Makefile

The kmcp CLI has a kmcp deploy command, and it's genuinely nice for iterating locally. It has no place in your deployment path. In this setup the MCPServer manifests live in the owning team's repo and get synced by ArgoCD, with cdk8s doing the templating:

import os
import cdk8s

env = os.environ.get("ENV")

class MCPServer(cdk8s.Chart):
    def __init__(self, scope, id, env):
        super().__init__(scope, id)
        cdk8s.Include(self, "mcpserver", url=f"{env}/mcpserver.yaml")

app = cdk8s.App(yaml_output_type=cdk8s.YamlOutputType.FILE_PER_APP)
MCPServer(app, "mcpserver", env)
app.synth()
Enter fullscreen mode Exit fullscreen mode

cdk8s.Include on a plain YAML file looks like a no-op wrapper, and mostly it is. The point is that the MCPServer sits inside the same synth as everything else the team deploys — ingress routes, config, the app itself — so one ArgoCD Application covers the whole unit. The MCP server is not a special deployment path. It's just another manifest in the tree.

That's the real design decision here: an MCP server should be indistinguishable from any other workload you run. Same repo layout, same GitOps sync, same review process, same rollback. The moment it needs its own deployment tooling, it becomes something only one person knows how to operate.


The part that actually changes delivery time

Standing up a controller is a day of work. The interesting question is what an organization gets once the tools are behind MCP, and the honest answer is that most of the value comes from something unglamorous: collapsing the distance between a question and its answer.

Think about what "check the deploy status" costs today. An engineer opens the ArgoCD UI, finds the right project among forty, filters applications, reads sync status, opens the diff, maybe pulls logs. Two minutes if they know exactly where to look, fifteen if they don't, and a Slack message to the platform team if they don't have access. Now consider a developer who has never used ArgoCD asking their assistant "is the checkout service synced in acc, and if not, what changed?" and getting an answer grounded in real cluster state.

That is not a chatbot novelty. It's the removal of a lookup tax that every engineer in the organization pays several times a day.

Three concrete shifts worth planning for:

Tribal knowledge becomes queryable. The platform team's real product is not the cluster; it's knowing which of forty ArgoCD projects owns a given service, and that a Progressing status stuck for ten minutes usually means a failed readiness probe. Exposed as tools, that knowledge stops being a queue in front of three people. The measurable win is fewer interrupts, and interrupts are the thing that actually destroys a platform team's throughput.

Read access spreads without the safety problem. Historically, giving a developer visibility into production meant giving them a console login and hoping. Handing them a read-scoped API token they present through an MCP server gives them answers without giving them the console. Different blast radius, entirely.

Onboarding compresses. A new engineer's first week is mostly discovering which internal tool answers which question, and where it lives. With those tools behind MCP, the discovery step goes away — they ask in the language they already have, and the tooling routes it.


The credential belongs to the caller, not the server

This is the part teams skip, and it's the part that determines whether the rollout survives its first security review.

The instinct when deploying a tool server is to give it a service account and a token — mount an ArgoCD credential into the Deployment and let it act on everyone's behalf. Don't. That builds a confused deputy: a single identity, holding the union of everything anyone might need, sitting behind an endpoint whose whole purpose is executing instructions from a language model.

Look closely at the MCPServer spec earlier in this post. The only thing in env is ARGOCD_BASE_URL. There is no token, no envFrom, no mounted secret — the server knows where ArgoCD is and nothing about who may talk to it. Send it an unauthenticated request and it says so:

{"jsonrpc":"2.0","id":1,"method":"initialize"}
Enter fullscreen mode Exit fullscreen mode
x-argocd-api-token must be provided in the request header
(or the ARGOCD_API_TOKEN env var), or a token registry must be
configured via ARGOCD_TOKEN_REGISTRY_PATH.
Enter fullscreen mode Exit fullscreen mode

That error is the design, not a misconfiguration. The caller presents its own token on every request, in the x-argocd-api-token header. The MCP server is a stateless protocol translator: it turns MCP tool calls into ArgoCD API calls and forwards the caller's credential. It holds no standing authority of its own.

The consequences are worth being explicit about, because they invert the usual threat model:

  • The server is not a prize. Compromising the pod yields a URL. There's no token at rest to exfiltrate, no secret in the namespace, no IRSA role on the ServiceAccount.
  • Authorization is ArgoCD's job, where it belongs. Every call is evaluated against the caller's own RBAC by the system that owns the data. The MCP layer cannot widen anyone's access, because it has none to lend.
  • The audit trail stays truthful. ArgoCD logs the acting account, not a shared mcp-server identity. "Who asked?" remains answerable — which it would not be if one token fronted every request.
  • Revocation is per-team and instant. Killing one team's token stops that team, with no redeploy and no effect on anyone else.

Note the env var mentioned in that error message. ARGOCD_API_TOKEN on the Deployment would work, and it is exactly the shortcut to refuse — it collapses every caller into one identity and undoes everything above. The token registry is the middle path if per-request headers are impractical for some client, but per-request is the model to start from.

This is what makes the per-team ArgoCD accounts load-bearing rather than bureaucratic. Each consuming team gets its own account, scoped to exactly what it owns:

configs:
  params:
    accounts.cloudplatform.enabled: "true"
    accounts.cloudplatform: apiKey
  rbac:
    policy.csv: |
      p, role:cloudplatform-role, projects, *, k8s-tools-project, allow
      p, role:cloudplatform-role, projects, *, kafka*, allow
      p, role:cloudplatform-role, projects, *, vault-project, allow
      p, role:cloudplatform-role, applications, *, k8s-tools-*/*, allow
      p, role:cloudplatform-role, applications, *, kafka*/*, allow
      p, role:cloudplatform-role, applications, *, vault-*/*, allow
      p, role:cloudplatform-role, logs, get, k8s-tools-*/*, allow
      p, role:cloudplatform-role, logs, get, kafka*/*, allow
      p, role:cloudplatform-role, logs, get, vault-*/*, allow
      g, cloudplatform, role:cloudplatform-role
Enter fullscreen mode Exit fullscreen mode

Note apiKey and not login. The account can mint a token for programmatic use and cannot be used to log into the UI as a human. If the token leaks, the blast radius is "read some applications in three projects" instead of "an interactive session".

Note also that logs, get is granted separately from application access. Logs frequently contain more than the deploy metadata does — connection strings in stack traces, customer identifiers in error messages. Deciding about log access explicitly, per team, is worth the extra three lines.

Rolling this out one team at a time — a separate account and policy block per consuming team — is slower than a single shared account and it is the right call. It means an over-permissioned policy affects one team, and revoking access for one team doesn't take down everyone else's assistant.

The pattern generalizes past ArgoCD. Any tool you put behind MCP should answer the same question: whose authority does a call carry? If the answer is "the server's", you've centralized risk and blinded your audit log. If it's "the caller's", the MCP layer stays what it should be — plumbing that translates protocols and carries someone else's identity.


Field notes

  • oci: true is not optional and the error doesn't tell you. Helmfile treats an OCI registry URL as a classic chart repo without it and fails looking for an index.yaml that will never exist.
  • Split the CRD chart from the controller chart, always. Helm will install CRDs from a chart's crds/ directory on first install and then never upgrade them. When KMCP's MCPServer spec gains a field in 0.2, you want a chart upgrade, not a manual kubectl apply.
  • @latest in an MCPServer's args is a time bomb with a slow fuse. The pod that has been running for six weeks and the pod that just restarted are running different code. It won't hurt you until it does, and when it does the symptom will be "it worked yesterday". Pin it before it matters.
  • Service port names, not numbers, are what ingress matches on. A mismatch gives you a 502 with no useful log line on either side. Check this first when a new MCP route doesn't answer.
  • Turn off automatic tool discovery in shared clusters. kagent.dev/discovery: "disabled" plus deliberate grants beats every-agent-gets-every-tool. The default is optimized for a demo, not for a cluster with four teams in it.
  • Terraform-managed IAM roles for the MCP workload were removed, not fixed. The first pass provisioned an IRSA role for the MCP server before anyone had established that it needed AWS access. It didn't — it forwards the caller's ArgoCD token and talks to nothing else. Deleting infrastructure that exists "just in case" is a real win; every unused role is a permission someone will eventually attach a policy to.
  • Check what your MCP server holds at rest before you ship it. kubectl get deploy <name> -o jsonpath='{.spec.template.spec.containers[0].env}' and a look for mounted secrets takes ten seconds. An empty result is the good outcome — it means a compromised pod yields nothing worth stealing.
  • Read-only first, and mean it. There will be pressure to add write tools — "just let it trigger a sync". Resist until the read path has been in production long enough that you trust the identity boundaries. A hallucinated get is a wrong answer; a hallucinated sync is an incident.
  • The controller is not where your time goes. Installing KMCP took an afternoon. Getting ArgoCD accounts, RBAC scoping, and the per-team policy format right took a week and several follow-up PRs. Budget accordingly — the platform piece is easy, the identity piece is the project.

Closing

KMCP's contribution is narrow and worth having: it makes an MCP server a first-class Kubernetes object, so exposing a tool costs a YAML file instead of a service. That reduction is what lets you say yes to the fifth MCP server without inheriting a fleet of snowflakes.

The leverage, though, is in what you connect and whose identity the calls carry. Every tool behind MCP is one fewer thing an engineer has to know where to find, and one fewer interrupt in the platform team's day. Keep the credential with the caller and that scales safely: the MCP layer never accumulates authority, and nobody can ask it for more than they were already allowed to have.

Top comments (1)

Collapse
 
mads_hansen_27b33ebfee4c9 profile image
Mads Hansen

The caller-owned identity model is the right direction, but I’d qualify “the server is not a prize.” A pod that receives reusable ArgoCD bearer tokens may hold nothing valuable at rest and still be an excellent place to harvest credentials in transit—from process memory, debug output, proxy traces, or a malicious package resolved by npx.

The stronger end state is delegated identity without forwarding the original credential: authenticate the caller at the MCP boundary, exchange that identity for a short-lived, audience-bound downstream token scoped to the requested tool/resource, and make the token unusable anywhere else. Sender-constrained tokens or mTLS help where supported.

Until that exists, I’d make several controls explicit: pin package versions and image digests; redact auth headers at every ingress/controller/app telemetry layer; disable body/header debug logging; set strict token lifetimes; apply egress allowlists; and test that a compromised server cannot replay a captured token from another workload.

Per-caller auditability and no standing server secret are major improvements. Treating transit credentials as disposable capabilities closes the remaining gap.