MCP Server
stratum mcp serves Stratum's own tools, and a generic Kubernetes toolset, over
the Model Context Protocol. An MCP-speaking
client — an editor, an agent runner, a chat client — can then read cluster state
and, if you let it, change it.
The server is CIQ's own code. It takes no dependency on any third-party MCP server.
sudo stratum mcp
That starts a read-only server on stdio. Most clients spawn it themselves rather than having you run it by hand:
{
"mcpServers": {
"stratum": {
"command": "sudo",
"args": ["stratum", "mcp"]
}
}
}
The three tiers
What the server exposes is decided when you start it, not per call.
| Started with | Tools available | What they can do |
|---|---|---|
| no flags | 19 | read cluster and node state |
--allow-write | +8 | apply manifests, scale, snapshot, mint join tokens |
--allow-destructive | +11 | upgrade, roll back, rotate CAs, restore etcd, remove restore copies, uninstall |
The flags are independent — --allow-destructive on its own does not enable the
write tier.
A tool above the tier you enabled is not registered at all. It does not appear in the client's tool list, so the agent never learns it exists. That is deliberate: a tool that appeared and then refused would tell the model the capability is there and invite it to look for another way to the same effect.
Kubernetes RBAC is the only hard boundary. The tiers decide what this server offers; RBAC decides what the credential is actually allowed to do.
A server started with no flags but running as cluster-admin is one
misconfiguration away from full cluster access, and nothing at the protocol
layer can take that back. Give it a dedicated ServiceAccount with the narrowest
role that works — see Least privilege.
Some tools do not use that credential at all: they run on this node's own
super-admin.conf, and for them the tier flag and running the server as root are
the only controls. The table under The destructive tier
lists the destructive ones, and Choosing a cluster
names the read-tier exceptions (token_list, upgrade_plan).
What it exposes
Cluster state, through the Kubernetes API. pod_list, pod_get,
pod_logs, node_list, namespace_list, event_list, and resource_list /
resource_get for anything else the cluster serves, including custom resources.
Stratum's own state, from the node. cluster_status reports the supervised
process table — what the supervisor intends to be running versus what is,
which is not visible from the Kubernetes API at all. certs_check,
hardening_check, snapshot_list, snapshot_restore_copies, token_list and
registry_check report the rest. upgrade_plan and uninstall_preview show what a change would do without
doing it.
Every list is bounded and says so. A truncated result carries truncated: true
and a token to page with, because an agent handed the first hundred of four
thousand pods with no indication will reason about them as though they were all
of them.
There is no kubectl passthrough, and no tool takes a free-form command. Every
input is a typed field, and the resource to act on is resolved through discovery
rather than pasted into an argument list.
Every result is fenced as untrusted data. A pod log line, an event or an
annotation can say anything its author likes, including text written to look
like instructions. So the text of each result, and of each error, arrives
between <untrusted-data id="..."> and </untrusted-data id="..."> markers that
share a random id chosen for that call, and anything inside that resembles a
marker is defused first. That includes errors the server raises before a tool
runs, such as an argument of the wrong type. The server's instructions tell the
client the fenced text is data, never instructions.
The structured form of a result (structuredContent) is not fenced, so clients
that parse it against the tool's output schema keep working. Anything in its
strings that resembles a marker is defused in the same way, and the server's
instructions tell the client those strings are untrusted too. Because the
fenced text is no longer bare JSON, a client that needs the result as JSON
should read structuredContent.
Choosing a cluster
With no configuration the server looks, in order, at --kubeconfig,
$KUBECONFIG, ~/.kube/config, this node's own admin.conf, and finally
in-cluster credentials.
Files come before in-cluster on purpose: if you have a kubeconfig, that is the cluster you mean, even when the server happens to be running inside a different one.
The generic Kubernetes tools take an optional context, so one
server can address several clusters. context_list reports the valid values,
and is the first call worth making when more than one cluster is in play.
upgrade_plan is not one of them: it takes no context.
token_list is a local-admin exception. Bootstrap tokens are Secrets in kube-system,
which the least-privilege role below deliberately cannot read, so that one tool
uses this node's own super-admin.conf and always reports on the cluster the node
belongs to. It returns token IDs and expiry only — never the secret half.
upgrade_plan is a local-admin read too. It reads this node's internal credential
(super-admin.conf when present, otherwise admin.conf),
so it always previews the cluster this node belongs to — the same cluster
upgrade_apply with scope: cluster rolls — and --kubeconfig and
--context do not move it.
hardening_encrypt_existing is also a local-admin operation: it always uses
this node's super-admin.conf to read and rewrite every Secret in its cluster.
--kubeconfig and --context do not constrain that operation. Only enable
--allow-destructive when this node-wide authority is intended.
sudo stratum mcp --kubeconfig /path/to/config --context staging
ha_repoint with cilium makes its cluster-wide API calls through the
credential and default context the server was started with, so it needs the
rights listed under The destructive tier. It refuses
any cluster but this node's own: its kube-system namespace must be the one
this node's super-admin.conf sees, compared by UID.
upgrade_apply with scope: cluster runs its rollout — the drains, the
per-node upgrade Job and the upgrade lease — through this node's own
super-admin.conf, so --kubeconfig and --context cannot point it at another
cluster, and the server's credential needs no upgrade rights. The per-node
upgrade work itself runs as root on each node and is not bounded by RBAC.
Running it over HTTP
sudo stratum mcp --transport http --addr 127.0.0.1:8092
It binds loopback by default for that reason. Binding it to a routable address publishes your cluster's control surface to anything that can reach the port.
If it has to be reachable off-box, put something that authenticates in front of it — and remember that whatever you put there is then the only thing between the network and the tier you enabled.
Logs go to stderr. On the stdio transport stdout carries the protocol, so nothing else may be written there.
Least privilege
Do not point the server at admin.conf for day-to-day use. It is
cluster-admin, so every tier gets the same unlimited credential and the tier
flags become the only thing between an agent and the cluster.
Give it a ServiceAccount instead:
apiVersion: v1
kind: ServiceAccount
metadata:
name: stratum-mcp
namespace: kube-system
---
apiVersion: rbac.authorization.k8s.io/v1
kind: ClusterRole
metadata:
name: stratum-mcp-read
rules:
- apiGroups: [""]
resources: [pods, namespaces, nodes, events, services, configmaps,
persistentvolumeclaims, persistentvolumes]
verbs: [get, list, watch]
- apiGroups: [""]
resources: [pods/log]
verbs: [get]
- apiGroups: [apps]
resources: [deployments, statefulsets, daemonsets, replicasets]
verbs: [get, list, watch]
- apiGroups: [batch]
resources: [jobs, cronjobs]
verbs: [get, list, watch]
Secrets are deliberately absent, and the server refuses them anyway.
resource_get and resource_list will not return a Secret at any tier, even
with cluster-admin — a Secret's contents in an agent's context window is the
worst read this server could perform, and it cannot be taken back. Leaving
Secrets out of the role is still worth doing: it is the layer that holds if the
refusal is ever changed, and it covers the token the kubelet would mount into a
pod the server applies. If a workflow genuinely needs a Secret, read it with
kubectl.
Check what you granted by asking the API server rather than re-reading the YAML:
kubectl auth can-i get secrets --as=system:serviceaccount:kube-system:stratum-mcp
kubectl auth can-i create clusterrolebindings --as=system:serviceaccount:kube-system:stratum-mcp
Both should answer no. A yes to either means the tier flags are the only
thing protecting the cluster.
What it will never do
Some things are refused regardless of tier, because they would let an agent grant itself more than you gave it:
- Reading Secrets —
resource_getandresource_listrefuse them at every tier, including read. This is the one refusal that applies to a read. - Writing RBAC — roles and bindings. Writing a ClusterRoleBinding is
self-promotion to
cluster-admin, which would make the tiers meaningless. - Writing admission control — webhook configurations, and the CEL-native
ValidatingAdmissionPolicyandMutatingAdmissionPolicyobjects with their bindings. All of them can disable or intercept the rules meant to contain the server; a MutatingAdmissionPolicy needs no endpoint or certificate to do it. - Writing Secrets, ServiceAccounts, CSRs or APIServices — identity and credentials by another route.
- Writing a Node object — clearing a control-plane taint places workloads where they can read the cluster's credentials. Deleting a node is a real administrative action and stays at the destructive tier instead.
- Installing a CRD with a conversion webhook — the API server would then call that endpoint on every read and write of the kind, and the reply replaces the object. The CRD itself is fine; the webhook is not.
- Creating or modifying PVs/PVCs — a hostPath PV mounted through a claim would bypass the direct pod hostPath check. Deletion still requires the destructive tier.
- Writing a HelmChart or a HelmChartConfig — the pod specs a chart renders
never pass through this server, so none of the checks below could apply to
them. A HelmChart in a privileged namespace (
kube-systemby default) is installed with the operator's own cluster-admin identity, and a HelmChartConfig is merged into the HelmChart of the same name, so writing either one there changes what the operator installs as cluster-admin. - Applying a pod spec that escapes its namespace —
hostPathvolumes,hostNetwork,hostPID,hostIPC,privileged, added Linux capabilities and an unmaskedprocMount. Each reaches the node itself. - Applying a pod spec that carries a Secret — a secret volume, a projected
secret or
serviceAccountTokensource, asecretKeyRef, anenvFromsecretRef, or animagePullSecretsreference. A container that mounts a Secret can print it, andpod_logsis read-tier, so this is the read above by a longer route.imagePullSecretsneeds no container at all: the kubelet presents the credential to whatever registry the image names. - Mounting a claim bound to a node-path volume — writing a
PersistentVolume or a claim is already refused, but an operator may have left
a
hostPathorlocalPV behind. Mounting a claim bound to one reaches the node's filesystem with nohostPathanywhere in the pod spec, so the claim is resolved and refused.
These checks apply to any object in a manifest that carries a containers
list, at any depth: a Pod's spec, a workload's template, a PodTemplate's, or one
inside a custom resource. A pod spec a custom resource holds in another shape,
such as a single container or a list of steps, is not recognised, so keep
the boundary for those in RBAC and admission control. A manifest nested more
than 64 levels deep is refused.
Because server-side apply keeps fields owned by other managers, these checks run against the object already in the cluster as well as against your manifest. An apply that only changes a container's command is still refused if the workload it is updating carries one of the fields above.
Among the generic Kubernetes tools, resource_delete and resource_apply are
registered at the write tier and then refuse some targets without
--allow-destructive. Deleting a namespace, PersistentVolume,
PersistentVolumeClaim, node or CRD needs it, as does creating or changing a
namespace or CRD, because an apply can be as hard to undo as a delete: it can
rewrite a CRD's schema or a namespace's finalizers.
And some tools simply do not exist: there is no etcdctl (raw datastore access,
including removing a member), no up (it stands up host state with no
confirmation gate), and no operator or dash (daemons, not request/response).
The destructive tier
--allow-destructive reaches tools that drain nodes, wipe etcd, replace the
cluster CA and uninstall Stratum from a node. Each states its blast radius in
the first line of its description, because that is what the model reads before
deciding to call it.
There is no second confirmation per call. Starting the server with the flag is
the confirmation — it is the equivalent of having typed --yes, decided by a
human once rather than by the agent each time.
The practical advice: do not leave a server running with
--allow-destructive. Start one for the operation, watch it, and stop it.
Most of what this tier does is host work — certificates on disk, the supervisor, the node's own state — so RBAC narrows less of it than you might expect. One tool runs on the server's credential, and RBAC bounds it:
| Tool | Credential |
|---|---|
ha_repoint with cilium | the server's |
upgrade_apply with scope: cluster | this node's super-admin.conf: the rollout's drains, Job and lease ride it, and --kubeconfig and --context cannot point it elsewhere |
upgrade_apply with scope: node, rollback, certs_rotate, snapshot_restore, snapshot_rejoin, snapshot_prune_restores, uninstall, ha_repoint without cilium | this node's own: host work on this node, and API writes through super-admin.conf (snapshot_restore restarts the cluster's Stratum-managed workloads; a node upgrade cordons and uncordons this node) |
certs_rotate_ca, certs_migrate_etcd_ca, certs_migrate_kubelet_client_ca | this node's super-admin.conf, for every phase and for the cluster-wide run |
hardening_encrypt_existing | this node's super-admin.conf |
token_create, token_list (write and read tiers) | this node's super-admin.conf |
For the tools on this node's credentials, RBAC on the server's credential
neither enables nor limits what they do: the tier flag, and running the server
as root on this node, are the only controls. hardening_encrypt_existing reads
and rewrites every Secret, and token_create writes a bootstrap Secret, the
bindings that let it join a node, and the shared control-plane certificates,
none of which the server's own credential should ever be able to write. Do not
add Secret or RBAC permissions to the selected MCP kubeconfig for these: they
do not use it.
upgrade_apply with scope: cluster runs only an upgrade binary an operator
staged in /var/lib/stratum/upgrade. from is the binary's name there, or its
full path. The binary must be a regular file, not a symlink, owned by root and
writable by no one else, in a directory that is the same. Each node checks
the file at that path the same way before it runs it as root, and a node of
the same architecture also checks that it has the same sha256 as the binary on
the node serving MCP, so the model chooses among the binaries you staged and
cannot name another program. Stage the same binary at that path on every node
first, as for stratum upgrade --cluster.
certs_rotate_ca takes the same options as the CLI, and refuses the same
combinations. Set cluster to run every phase across the cluster, and leave
phase empty; set phase to run one phase on this node. Not both. It takes the
same workload choice as the CLI. Set
restart_workloads (with cluster) to have Stratum restart the Deployments,
DaemonSets and StatefulSets that call the API server before sign-new, or
skip_workload_check to turn the check off. Set one, not both. With neither, a cluster rotation on
a cluster running such workloads refuses to start, and a sign-new phase refuses
until they have restarted. See
Workloads that call the API server.
Set skip_token_check to finalize without checking for mounted tokens signed by
the old service-account key, for compromise recovery. It needs force, and
without it the tool refuses. See
Before finalize: mounted tokens.
The one tool on the server's credential needs these rights. Bind them for the
operation and remove the binding afterwards. The objects in kube-system go
in a Role and RoleBinding there:
# Role in kube-system.
# ha_repoint with cilium points the Cilium agents and operator at the VIP.
- apiGroups: [apps]
resources: [daemonsets]
resourceNames: [cilium]
verbs: [get, update]
- apiGroups: [apps]
resources: [deployments]
resourceNames: [cilium-operator]
verbs: [get, update]
The rest is cluster-wide, in a ClusterRole:
# ha_repoint: find this node, and compare kube-system's UID with this node's
# own cluster.
- apiGroups: [""]
resources: [nodes]
verbs: [get]
- apiGroups: [""]
resources: [namespaces]
resourceNames: [kube-system]
verbs: [get]
upgrade_apply with scope: cluster needs none of these: it lists, cordons,
drains and stamps every node, runs the per-node upgrade Job and holds the
upgrade lease through this node's internal credential (super-admin.conf when
present, otherwise admin.conf), which already has them. The
right to update the Cilium DaemonSet is still the right to run a program as
root on every node — its agent runs privileged on every node — so treat these
bindings as cluster-admin for as long as they exist. They are also the
credential the read and write tools use, so while they are bound,
resource_apply, pod_logs and the rest have these rights too. Bind them to a
kubeconfig used only by a server started for the operation.
Snapshot and token listings accept limit and continue_token. Pass the returned
continue value to retrieve the next page. Snapshot cursors are tied to the
inventory: if snapshots are added, removed, or changed between calls, restart
without a cursor. Token pages use the Kubernetes API continuation token and the
same request timeout and response-size cap as the generic Kubernetes tools.