Skip to content
Back to blog

Upcoming v0.7.0: one MCP server instead of one per cluster

Give an agent an MCP server and it gets useful fast. Give it eight, and you have a different problem.

That’s roughly where most platform teams land after the first month of MCP. One server for each cluster, another for ArgoCD, another for the registry, maybe one wrapping the ticket system. Every one is a separate connection, a separate token, a separate idea of who you are. The agent holds the whole mess in context and does the joining itself, badly, one tool call at a time.

Ask a simple question and watch the cost. “Is checkout healthy?” turns into a call per cluster, a call to ArgoCD, a call for pod events, and a model that now has to reconcile four differently-shaped answers before it says anything. Half the context window is gone and nobody has looked at a log yet.

The permissions are worse. Each server enforces its own rules, if it enforces any. Nobody designed the union of them, and the union is what the agent actually has.

Shoehorn 0.7.0 takes a different route. There’s one MCP server, and it sits in front of a catalog that already ingested every cluster you connected.

Three primitives, not thirty tools

The surface is deliberately small: describe_schema, query, and fetch.

describe_schema with no argument returns the roots you can query. Each root is a queryable domain: workloads, GitOps resources, Helm releases, pod events, clusters, repositories, docs, scorecards, connected resources.

{
  "name": "resource",
  "purpose": "Kubernetes workload rollup (v2 Resource grain): the canonical runtime diagnosis surface"
}

Call it with a root name and you get that root’s full model: every facet with its type and legal operators, the projection, aggregates, available expands, detail levels, resolved limits, and a grammar hint. The agent reads the schema once and writes correct queries after that, instead of guessing at parameters and burning turns on retries.

query runs a constrained JSON DSL against one root. fetch reads one record by id.

That’s the whole API. Adding a domain means adding a root, not shipping another tool for the model to keep straight.

One call covers every cluster

The workload root is a rollup, merged on service key and kind. A service running in four clusters is one row with per-cluster detail inside it, not four rows you have to reassemble.

{"from": "resource", "aggregate": {"op": "count", "group_by": ["cluster"]}}

Filters compose the way you’d want. Every degraded workload in production with no network policy, across every connected cluster, is one query:

{"from": "resource",
 "where": {"status": "Degraded", "environment": "production", "no_network_policy": true}}

There’s no cluster fan-out because there’s nothing to fan out to. The k8s agent already pushed this data, and the catalog already merged it.

That environment value is worth a caution, and 0.7.0 fixes the trap behind it. You name your environments, not us. An agent that filters on production when your clusters are labelled prod gets zero rows back, and zero rows reads as “none exist” rather than “wrong value”. describe_schema now ships either the complete value domain for a facet or a runnable query that returns the values your tenant actually uses, and the instructions tell the agent to run it before filtering. We refused to hardcode guesses: two packages in this repo already spell sync status Synced and synced, and a guessed list would have caused the exact bug it was meant to fix.

Errors come back as typed data rather than transport failures, which matters more than it sounds. Misspell a field and you get the mistake plus a correction:

{"error": {"code": "unknown_field", "field": "restart_count desc",
           "did_you_mean": ["restart_count"]}}

The agent fixes it on the next call instead of unwinding an exception.

Enough data to answer the real question

The query layer is only as good as what the k8s agent collects, and that’s the other half of this release. The agent now feeds GitOps state per cluster and namespace, Helm release inventory, a bounded window of pod events, connected resources, restart counts, OOMKill signals, QoS class, and right-sizing verdicts from p95 against requests.

Which makes the ArgoCD question answerable. Sync status, health status, the synced revision, the target revision it’s tracking, whether auto-sync is on or suspended:

{"from": "gitops", "where": {"tool": "argocd", "sync_status": "OutOfSync"}}
{"cluster": "prod-eu-1", "name": "leaktest-kustomize", "namespace": "argocd",
 "sync_status": "OutOfSync", "health_status": "Missing", "auto_sync": "false",
 "revision": "8088f4c0d970abb09e250248cc97e35623447cb5", "target_revision": "HEAD",
 "source_url": "https://github.com/argoproj/argocd-example-apps.git"}

The health bundle

Diagnosis is where per-tool MCP really falls apart, because the agent has to know what to ask before it knows what’s wrong. So we moved the assembly server-side.

fetch(resource, id, detail:"health") returns one bundle: per-cluster status with the baseline and any deviating clusters, container issues, recent events, rollout activity, right-sizing, network policy verdict, GitOps and Helm state, config drift, and connected risk. It’s deterministic and budgeted under roughly 3K tokens.

Here’s a real one from our QA cluster, trimmed. A workload with 12 restarts:

{"identity": {"name": "cilium-operator", "kind": "Deployment"},
 "container_issues": {"restarts": 12},
 "helm": {"chart_name": "cilium", "chart_version": "1.18.4",
          "revision": 3, "status": "deployed"},
 "right_sizing": {"verdict": "no_requests",
                  "cpu": {"request": 0, "limit": 0, "p95": 10},
                  "mem": {"request": 0, "limit": 0, "p95": 86751232}},
 "no_network_policy": true,
 "events": {"untrusted_content": true, "items": [
   {"reason": "Killing", "count": 7,
    "message": "Container cilium-operator failed liveness probe, will be restarted"},
   {"reason": "Unhealthy", "count": 2,
    "message": "Liveness probe failed: Get "http://127.0.0.1:9234/healthz": context deadline exceeded"}]}}

The story is right there. Liveness probe times out, kubelet restarts the container, twelve times. No CPU or memory requests set, while p95 memory sits at 83 MiB. One call, one answer, no guessing.

Note untrusted_content on the events section. Event messages originate in workloads, which means anyone who can make a pod emit an event can write text into your agent’s context. The bundle marks that section so the client can wrap it as data rather than read it as instruction. Prompt injection through a Kubernetes event is not hypothetical, and an agent platform that ignores it is handing over a write channel.

When the answer needs logs, the bundle returns links instead of pretending. Shoehorn doesn’t hold your logs and won’t invent them.

Your agent gets your permissions

Every MCP call runs through Cerbos, the same policy engine and the same policies behind the web UI and the REST API. There’s no MCP-specific permission model to keep in sync, because there isn’t a second model.

Three layers stack up. Personal access tokens carry scope families (catalog:read, operations:read, repositories:read, admin:read). Cerbos decides the role question per root. Postgres row-level security holds the tenant boundary underneath both.

The part worth dwelling on is what happens to a tool the caller isn’t allowed to use. It doesn’t appear.

describe_schema runs the Cerbos check per root and drops the denied ones from the response entirely, rather than returning them flagged as forbidden. A denied-but-visible tool is a tax the agent pays every turn: it reads the description, decides the tool fits, calls it, takes a 403, apologizes, tries something else. Filtering the roster before the model sees it removes that loop, and it keeps the tool list from leaking the shape of what you’re not allowed to touch.

The raw-manifest accessor goes further and authorizes by GVK, so policy can allow ArgoCD Applications and refuse everything else in the same cluster. Its group and kind facets accept equality only, which looks arbitrary until you work out why. A set filter like kind in ["Application", "Secret"] has no single value to hand the policy engine. The gate would see no kind, allow the query, and the SQL would still return the Secret rows. Equality-only makes that shape fail validation before it reaches either the gate or the database.

Sensitive data is handled at the source too. Secret values are never stored or served, only references and key names. RBAC edges show that a binding exists without exposing its resolved rules. The manifest mirror never mirrors Secrets at all, and strips managedFields and last-applied-configuration from what it does mirror.

Sign in with your company login

MCP OAuth is on by default in 0.7.0. Your IDE authenticates against the identity provider you already run, instead of you minting a token and pasting it into a config file. Shoehorn serves the RFC 9728 protected-resource document, and an unauthenticated request answers with a WWW-Authenticate challenge pointing at it. That challenge is how the client discovers your authorization server without being told about it.

One setup step is real and worth saying plainly: most identity providers don’t allow anonymous dynamic client registration, so you register an OAuth client with your IdP once and configure its ID. Okta refuses unauthenticated registration outright, and that’s the intended posture rather than something to work around.

It degrades safely. An install that can’t resolve both an audience and a public URL logs a warning and stays on tokens rather than failing to start. Setting it explicitly to true when it can’t resolve is still a hard startup error, because an install that asked for OAuth and silently didn’t get it is worse than one that won’t boot. The two halves also turn on together or not at all, since bearer verification with no discovery document would leave clients holding bare 401s and no way to find the front door. Personal access tokens keep working either way, and auth.mcp.enabled=false turns the whole thing off.

What this isn’t

Shoehorn isn’t proxying your other MCP servers. There’s no federation layer here, and nothing routes a call to a per-cluster server on your behalf. The reason one endpoint can answer cross-cluster questions is that the catalog already holds every cluster’s data, pushed by agents you installed.

The raw manifest accessor is a preview, off unless you enable it, and it covers ArgoCD Applications today rather than arbitrary kinds. The GitOps, Helm, and event roots are not preview and need no flag.

Where HolmesGPT fits

That line about logs deserves a longer answer, because “we don’t do that” is only half of it.

HolmesGPT is a CNCF Sandbox project that investigates incidents. It reads logs, metrics, and live cluster state, and it’s genuinely good at working out why something broke. We’re not building that, and you should probably run it.

What Holmes can’t tell you is what the broken thing is: which service the pod belongs to, which team owns it, whether it’s tier-1 or someone’s experiment, where the runbook lives, whether the same app is configured differently in your other three clusters. It investigates one cluster and forgets everything afterwards. That’s the right design for an investigator and the wrong one for a catalog.

So point it here. Holmes speaks MCP over Streamable HTTP, which is what /mcp already serves:

holmes:
  mcp_servers:
    shoehorn:
      description: "Shoehorn: service ownership, runbooks, scorecards, and Kubernetes workload health"
      config:
        url: "https://shoehorn.example.com/mcp"
        mode: streamable-http
        headers:
          Authorization: "Bearer {{ env.SHOEHORN_MCP_TOKEN }}"

One fetch(entity, id, detail:"health") and the investigation has an owning team, a criticality, a runbook link, and the same workload’s state in every other cluster you’ve connected. Holmes works out why. Shoehorn says what and who. Setup is on the HolmesGPT integration page.