MCP-AgentGym
Provides a Kubernetes incident gym that runs a real control plane and exposes MCP tools for agents to diagnose, act on, and verify fixes for injected Kubernetes incidents.
Click on "Deploy Server".
Wait a few minutes for the server to deploy. Once ready, it will show a "Started" state.
In the chat, type
@followed by the MCP server name and your instructions, e.g., "@MCP-AgentGymDiagnose the failing deployment in the gym and fix the root cause."
That's it! The server will respond to your query, and you can continue using it as needed.
Here is a step-by-step guide with screenshots.
MCP-AgentGym
A verifiable Kubernetes incident gym for post-training and serving tool-using agents.
MCP-AgentGym runs a real Kubernetes control plane β etcd, apiserver, controller-manager, scheduler β as unprivileged user processes, and replaces the kubelet with a deterministic workload simulator. Incidents are injected from a seeded catalogue of 14 fault families, an agent works them through 36 typed MCP tools, and an executable verifier reads live cluster state to decide whether the incident was actually fixed. No model judges anything.
That gives the three properties post-training needs and a real cluster cannot provide: sub-second resets, hundreds of parallel episodes per node, and rewards that are computed rather than graded. On top of it the project trains Qwen3-8B with SFT, DPO and two flavours of GRPO, serves the result through vLLM, and measures what survives.
π Results and methodology β Β· π Design document Β· π§ͺ Experiment log Β· π Design decisions
The result: post-training on a closed catalogue is a trade, not an improvement
n = 2,200 per arm, on 5,000 incidents no model has seen (zero id overlap with training).
Bonferroni threshold for the 42 comparisons reported is z > 3.0.
in-distribution | held-out faults | unseen topology | compositional | all | invalid calls | |
base, untrained | 33.4% | 65.8% | 24.6% | 22.4% | 36.3% | 26.3% |
SFT (2 epochs) | 59.4% | 40.6% | 28.2% | 33.4% | 42.1% | 0.35% |
best trained (SFT 3ep + DPO) | 71.3% | 38.2% | 52.0% | 40.8% | 52.5% | 0.32% |
what training buys resolution +16.2 pp z = +10.96
root cause +14.2 pp z = +9.57
invalid tool calls 26.3% β 0.3% ~100Γ
what it costs held-out families β27.6 pp z = β19.06Both sides clear significance by a wide margin, and both dwarf every difference between post-training methods. The untrained model is the best agent in this project on fault families it was never trained for β 65.8% against 33β41% for everything trained β and the worst everywhere else.
The mechanism is measurable. Fine-tuning drives the two never-demonstrated diagnosis
labels to 0 emissions in 599 submissions (base: 25/501, 5.0%), though both sit in the
submit_diagnosis enum and the system prompt on every episode. The models are not blind to
those labels; they can no longer reach them. What they emit instead is confidently wrong and
drawn from the training catalogue β readiness_probe_targets_wrong_endpoint for 17 of 32
NodeNotReady incidents. That is label-prior collapse, not degraded reasoning, and the
distinction decides the fix: degraded reasoning needs better data, a collapsed prior needs
the update attenuated.
And the trade is reversible after training, at serving time. A LoRA update enters each
layer as ΞW = (Ξ±/r)Β·B@A, so scaling lora_B by s scales the update exactly. s = 0 is
the base model, s = 1 the trained one, and every value between is servable from one engine
by multi-LoRA:
s | held-out resolved | root cause | held-out labels emitted |
1.00 (trained) | 36.7% | 0.0% | 0 / 114 |
0.75 | 50.8% | 6.7% | 8 / 112 |
0.50 | 72.5% | 20.0% | 24 / 56 |
Monotone at every step, both adjacent comparisons significant (z = 3.55, z = 2.22), z = 5.97 end-to-end, and no measurable in-distribution cost (67.2% vs 65.6%, z = 0.27). It replicates on a second, independently trained adapter family to within 1.6 pp β the SFT adapter recovers 35.8% β 70.0% (z = 5.65). Two policies trained by different objectives on different data collapse the same way and recover the same way, which is the strongest available evidence that the cause is update magnitude rather than anything incidental to one checkpoint.
One knob, applied after training has finished, moves a model along the specialization axis.
Related MCP server: k8s-aiops
The variance budget: what actually moves the number
Every source of variation this project can measure, all at n = 2,200 per arm on the same
5,000 fresh incidents:
source | Ξ resolution | z |
learning rate, 5e-5 β 1e-4 | +14.23 pp | +9.54 |
DPO on baseline A | +4.18 pp | +2.83 |
training seed, 11 β 12 | β3.09 pp | β2.06 |
DPO on baseline B | β0.27 pp | β0.18 |
nondeterminism, same seed | β0.18 pp | β0.12 |
Tuning the learning rate mattered more than every post-training method tested, by a margin nothing else approaches β and the published baseline had been trained at the slower one. Same seed, same corpus, same epochs: +14.2 pp resolution, +10.4 pp root cause, invalid calls 0.59% β 0.01%.
The seed floor is 3.1 pp, which is the same size as DPO's effect. That is why DPO helped substantially on one baseline (+4.18 pp, z = 2.83) and not at all on another (β0.27 pp), when both baselines were identically configured and scored within 0.2 pp of each other. The quantity being measured is comparable to the noise in what it is measured against, so a single run cannot resolve it.
Two floors, and using the wrong one is the easy mistake: 0.2 pp between two runs of the same configuration, 3.1 pp between independently trained models. Method comparisons are the second case. This document quoted the first as the floor for part of one session, which understated the relevant noise sixteenfold.
The complete grid
All eight arms, n = 2,200 each. Bonferroni threshold for the 56 comparisons is z > 3.0.
variant | resolved | root cause | invalid calls |
SFT 2 epochs, seed 1 β no post-training | 57.7% | 48.9% | 0.01% |
SFT 3 epochs + DPO-309 | 52.5% | 51.8% | 0.32% |
SFT 3 epochs | 50.3% | 50.6% | 0.19% |
SFT 2 epochs + DPO-435 | 44.3% | 41.5% | 0.18% |
SFT 2 epochs + episode-GRPO | 44.2% | 40.5% | 0.28% |
SFT 2 epochs + action-GRPO | 43.0% | 38.5% | 0.28% |
SFT 2 epochs, seed A β the published baseline | 42.1% | 36.9% | 0.35% |
base, untrained | 36.3% | 37.6% | 26.32% |
The best agent here is a plain supervised fine-tune that happened to train at the better learning rate. It beats every post-trained variant and has the cleanest tool use in the project at 0.01% invalid calls. The top row is not a post-training result; it is the absence of one.
What this project does not establish
Whether DPO helps, as a number. With the learning rate matched and the floor measured, the question was asked properly β train DPO from a specific SFT, compare against that SFT. Two pairs, both baselines identically configured, scoring 57.7% and 57.5%:
Ξ resolution | z | Ξ root cause | z | |
pair 1 | +4.18 pp | +2.83 | +5.41 pp | +3.60 |
pair 2 | β0.27 pp | β0.18 | +0.05 pp | +0.03 |
They disagree, and it is not evaluation noise β the floor is 0.2 pp and pair 1's effect is twenty times that. The same recipe on two indistinguishable baselines gives a large gain once and nothing the other time, so the variability is in training, not measurement. DPO can help by a wide margin; what cannot be quoted is a number. Two pairs also cannot support a sign test, and none is offered.
Any ordering among SFT / DPO / episode-GRPO / action-GRPO. Each was trained once, so their differences sit inside the spread one recipe produces by reseeding. Establishing that any of them helps needs several seeds per arm, which has not been done here.
Anything about a second base model. Everything is Qwen3-8B. Mistral is cached in Mistral
format on the machine this ran on, with HF_HUB_OFFLINE=1, so HF-format weights could not be
fetched.
Live AWS behaviour. The S3 artifact store is asserted against moto; the Helm EKS overlay
renders and is diffed in tests. Neither has been run against a real account.
scripts/verify_aws.py does that and has not been executed.
Why believe any of it
The discipline that produced the results above also retracted five of this project's earlier claims β including one that was published here, contradicted by a later run, and withdrawn with its trajectories quarantined rather than deleted:
retracted claim | what it actually was |
"DPO +8.2 pp" | +2.1 pp under power; never significance-tested when first published. Winner's curse. |
"preference pair count matters" | A 437-pair replication scored identically to the 88-pair run. |
"episode-GRPO damages tool-call format" | An adapter initialised two versions behind its own training data. |
"one SFT epoch beats every method" | Confounded; the epoch alone is β4.1 pp. |
"seed variance is 15.6 pp" | Learning rate. No launcher ever forwarded |
Every increase in rigour moved the answer toward "no measurable difference" β until the learning rate was matched, at which point DPO reappeared at +4.2 pp on one baseline and nothing on another. Both halves of that belong in the record, and both are in reports/EXPERIMENTS.md with the defect that caused each retraction and the assertion that now prevents it.
The guards are in the code, not the prose. The report script refuses to render a partial
grid. A test asserts every tool count quoted in the docs matches the registry, another that
the serving numbers are recomputable from their raw artifacts. Training records a weight digest
of its base adapter, so lineage is checkable β that is how the withdrawn GRPO run was caught.
build-datasets refuses to overwrite the corpora the published results were trained on. And
tests/test_launcher_plumbing.py asserts that every result-bearing argument a trainer accepts
is reachable from its launcher, because for most of this project's life --seed was not.
How it works
Post-training an agent needs thousands of resettable, seeded, parallel episodes with verifiable rewards. A real Kubernetes cluster gives none of those: episodes take minutes, never reset cleanly, and are nondeterministic, so the same action yields different rewards on different runs β fatal for DPO preference pairs and GRPO advantages alike. So the stack is split at the API boundary, which is exactly where an agent operates anyway.
Incident generator βββΊ Kubernetes incident environment βββΊ MCP gateway (36 typed tools)
(14 fault families, (real apiserver + simulated + schema-shift perturbation
seeded, held-out kubelet/mesh/telemetry) β
splits) βΌ
β² Agent harness
β (plan β act β verify β recover)
β β
β βΌ
β Executable verifier + reward
β β
β βΌ
βββββ new failure cases βββββ Evaluation βββββ SFT / DPO / GRPO βββ trajectoriesLayer | Real / simulated | Consequence |
etcd, kube-apiserver, controller-manager, scheduler | Real (v1.31.4, user processes) | Real CRDs, RBAC, admission, watch/reconcile. DeploymentβReplicaSetβPod is genuine, as are scheduling, taints and |
MCP protocol | Real ( | A standard MCP client drives this environment unchanged. |
kubelet / container runtime | Simulated | Pod status computed from declared workload physics. Deterministic and instant. |
Traffic, health, metrics, logs | Simulated | Health is a pure function of cluster state, so the verifier is exact. |
The scripted expert | Scripted, not a model | Reference ceiling and SFT demonstration source. Always reported as a scripted policy. |
Container runtimes were unavailable on the target hardware β no root, no Docker, no
/etc/subuid range for rootless Podman β which is what forced this split. But as argued in
PROJECT.md Β§1.1, it is the correct architecture for post-training regardless of
hardware.
Verified results
Everything below was produced by running the code in this repository. Model rows regenerate
with python -m agentgym.eval.report, which reads runs/eval/.
The environment is correct
The foundation everything rests on: for all 14 fault families, the injected fault provably breaks the cluster and the known-correct remediation provably fixes it, with the executable verifier recognising both states.
tests/test_environment.py ......... 19 passed (each fault breaks, then fixes)
tests/test_llm_policy.py .......... 18 passed (parsing, recovery, end-to-end fix)
tests/test_episode_grpo.py ........ 16 passed (advantage algebra, loss, KL, masking)
tests/test_prompt_contract.py ..... 12 passed (the model can see every tool)
tests/test_storage.py ............. 10 passed (artifact store, S3 paths via moto)
tests/test_reward.py .............. 10 passed (reward ordering properties)
tests/test_cli_contract.py ........ 10 passed (every command's --help resolves)
tests/test_report_variants.py ...... 8 passed (no two variants share a table row)
tests/test_mcp_protocol.py ......... 8 passed (real MCP client β real server)
tests/test_launcher_plumbing.py .... 7 passed (every trainer arg reaches its launcher)
tests/test_helm_render.py .......... 6 passed (chart renders, EKS overlay diffs)
tests/test_fidelity.py ............. 6 passed (sim vs real processes and limits)
tests/test_operator.py ............. 5 passed (reconcile, idempotency, finalizer)
tests/test_backend_pluggability.py . 5 passed (vLLM / SGLang / hosted interface)
140 passedThe prompt-contract suite exists because of the worst bug in this project: tool schemas were passed as an OpenAI API field that the project's own chat template ignored, so the model was being asked to call tools it had never been shown. Nothing raised. It would have silently voided both the base-model comparison and the entire schema-shift result. The invariant is now asserted β whatever the gateway presents must be literally present in the text the model receives.
Sim-to-real fidelity is checked where it is checkable: 5/5 on the process layer (healthy,
OOMKilled, BadConfigMap, ReadinessProbeMisconfig, CascadingDependencyDown), agreeing
on both readiness and reason.
Scale and cost
Incidents generated | 1,200 base pool + a 5,000-incident prefix-stable evaluation pool |
Trajectories executed and verified | 11,541 |
Control plane start | ~13 s, amortised over hundreds of episodes |
Median episode wall time | 2.4 s |
GPU cost of the environment | zero β CPU only |
QLoRA SFT (Qwen3-8B, 3 epochs, 614 examples) | 1 h 29 m on one L40S |
Unsafe actions in 3,840 episodes β every post-trained variant, clean and perturbed | 0 |
The scripted expert reference line
The expert is the environment's solver: it has ground truth and resolves tool names through the schema it is shown. It is the ceiling a model could reach by reading the schema perfectly every turn.
variant | n | resolution | root cause | unsafe | invalid calls | tool calls | reward |
expert-scripted | 4,010 | 91.4% | 100.0% | 0.0% | 0.0% | 8.0 | 2.60 |
By split: in-distribution 92.9%, unseen fault 97.8%, unseen topology 95.0%, compositional 50.8%. The compositional number is the informative one β the expert reasons about a single root cause, so when two independent faults are injected at once it fixes one and leaves the other. Single-fault reasoning does not compose, and the environment measures that directly rather than asserting it.
Robustness to tool-schema shift
Retention = resolution under the suite Γ· that variant's own clean resolution, both on
in-distribution incidents, so the tool surface is the only thing that varies. n = 64 per cell.
variant | clean | rename | arg_rename | +required arg | distractors | server dropout | combined |
expert (ceiling) | 1.00 | 1.04 | 1.04 | 1.02 | 1.02 | 0.74 | 1.04 |
base | 1.00 | 0.82 | 1.00 | 0.77 | 0.77 | 0.50 | 0.83ΒΉ |
SFT | 1.00 | 1.12 | 1.21 | 0.76 | 1.15 | 0.56 | 0.82 |
SFT + DPO | 1.00 | 1.09 | 1.18 | 0.88 | 1.29 | 0.62 | 0.91 |
ΒΉ base combined is n = 7; that cell was cut off when the job hit its 8-hour wall clock.
Both post-trained variants are far more robust than base to a renamed tool (1.09β1.12 vs 0.82), a renamed argument (1.18β1.21 vs 1.00) and distractor tools (1.15β1.29 vs 0.77). They learned to read the schema they are given rather than recall tool names from training β the intended effect, and the clearest evidence the gain is not memorisation.
The floor is still the floor. Nobody handles a tool that disappears: even a policy reading the schema perfectly retains only 0.74, because a withdrawn tool cannot be called by anyone.
Serving: what prefix caching is worth to an agent workload
One L40S, Qwen3-8B, with the real 36-tool system prompt. The synthetic conversation deepens exactly as an episode does, so the shared prefix grows while new tokens per turn stay small.
turns | prompt tokens | concurrency | p50 without prefix cache | p50 with | speedup |
2 | 3,735 | 32 | 14,123 ms | 3,250 ms | 4.3Γ |
5 | 6,768 | 32 | 27,511 ms | 3,833 ms | 7.2Γ |
10 | 11,823 | 32 | 49,239 ms | 4,687 ms | 10.5Γ |
20 | 21,943 | 32 | 84,418 ms | 6,576 ms | 12.8Γ |
Throughput at 20 turns / 32 concurrent: 0.26 β 4.67 req/s (18Γ). Read as cost per unit of prompt, ms per 1k prompt tokens goes 3,781 β 3,847 uncached (flat β every turn re-reads the whole conversation) against 870 β 300 cached. Prefix caching does not merely help agent workloads; its benefit compounds with conversation depth, which is the defining property of one.
Multi-LoRA on one engine: base 2,292 ms p50 against the SFT adapter's 1,075 ms. The adapter is
2.1Γ faster because it emits a short tool call where the base emits a long <think> block β a
latency win that comes from behaviour, not from the engine.
Quickstart
source env.sh # paths, venvs, HF cache
bash scripts/fetch_k8s_binaries.sh # etcd + kube-* (once, ~330 MB)
python -m agentgym.cli doctor # starts a control plane and verifies it
python -m agentgym.cli gen-incidents --n 1200 --seed 11
python -m agentgym.cli rollout --policy expert --limit 20 --verbose
python -m agentgym.cli stats $AGENTGYM_RUNS/expert-clean
python -m agentgym.cli build-datasets # SFT/DPO corpora + contamination checkbuild-datasets refuses to overwrite an existing dataset directory, because the published
results were trained on specific corpora and silently regenerating them would invalidate
every number above.
Batch work on Slurm:
sbatch slurm/rollout_expert.sbatch # 1,200 trajectories, CPU, ~5 min wall
sbatch slurm/expert_perturbation_sweep.sbatch # 12 perturbation suites Γ 4 splits
sbatch slurm/train_sft.sbatch # QLoRA SFT on one L40S
sbatch --export=ALL,VARIANT=sft,ADAPTER=<path> slurm/serve_and_eval.sbatch
python -m agentgym.eval.report # rebuild all matricesSite-specific paths (shared storage, node-local scratch, a shared HF cache) go in
env.local.sh β copy env.local.sh.example. It is never committed.
Repository map
src/agentgym/
cluster/ control plane (real), kubelet sim, service mesh, telemetry
incidents/ 14 fault families, topologies, generator, episode, gym
mcp/ 36 typed tools, 4 MCP servers, perturbation layer, gateway
harness/ agent loop, typed state, prompts, scripted expert, LLM policy
backends/ InferenceBackend: vLLM / SGLang / hosted API
data/ trajectory schema, verifier, reward, curation
train/ QLoRA SFT, DPO, action-GRPO, episode-GRPO
eval/ report matrices, serving benchmarks
operator/ IncidentRun CRD + controller
deploy/helm/ serving stack, RBAC, NetworkPolicy, EKS overlay
slurm/ job arrays for rollout, sweep, training, evaluation
tests/ 140 tests: environment contract, MCP protocol, operator, plumbingThe pieces worth reading first:
cluster/kubelet_sim.pyβ the deterministic workload state machine, and why pod reaping is the kubelet's jobincidents/faults.pyβ 14 families, each with an executable resolution predicate and a forbidden-action setmcp/perturb.pyβ the schema-shift layerdata/trajectory.pyβ verification and the rewardtrain/episode_grpo.pyβ group-relative advantages over whole episodes, no criticoperator/controller.pyβ reconciliation, finalizers, owner references
Reward
Episode reward β what SFT data is filtered on and what evaluation reports:
score = 2.0Β·resolved + 0.5Β·root_cause_correct + 0.4Β·validated + 0.2Β·recovered
β 0.05Β·unnecessary_calls β 0.3Β·invalid_calls β 0.8Β·unsafe_actions β 0.1Β·over_budgetAction reward β what action-level GRPO optimises, one tool call at a time:
+1.0 parses Β· +1.0 known tool Β· +1.0 schema-valid Β· +0.5 matches expert Β· β3.5 unsafeEvery term is read from live cluster state, a JSON Schema, or the gateway's call log. No model judges any of it.
The β3.5 is sized deliberately so a well-formed forbidden action scores below emitting
nothing parseable (β0.5 vs 0.0). A smaller penalty would leave the model better off confidently
deleting a pod than staying quiet β the wrong lesson for an agent holding production
credentials. tests/test_reward.py asserts that ordering.
Episode-level GRPO. Action-level optimisation cannot credit-assign across a whole incident,
so train/episode_grpo.py groups G rollouts of the same incident and standardises the
episode reward within the group:
A_i = (R_i β mean(R_group)) / (std(R_group) + Ξ΅)The group mean replaces a learned value function β no critic β and the advantage weights every assistant token in that episode. Over the 2,400 rollouts already sampled for DPO it keeps 597 of 600 groups (2,388 episodes) against 435 DPO pairs from the same data: 5.5Γ the training signal, because a preference pair keeps one best/worst couple above a margin while a group uses the whole reward distribution.
Trained for 149 optimizer steps. KL to the frozen SFT reference drifts 0.011 β 0.022 (max 0.038), and 21 of 2,388 episodes hit the 12,288-token cap β 0.88%, against 1.2% predicted from a length sample before the cap was chosen. The policy-gradient term is near-evenly signed across logged steps (15 negative, 14 positive, mean β0.14), which is the correct signature for this objective rather than a problem: advantages are zero-mean within a group, so over complete groups the expected term sits near zero and the learning signal is the correlation between advantage and log-probability. "Loss went down" is the wrong health check here.
At full power it lands at 44.2% resolution against the matched SFT's 42.1% β inside the seed floor, like every other method in the grid.
Known limitations
Stated because a limitation you can name is worth more than one a reader finds.
The expert is formulaic. SFT reaches eval-loss 0.0215 partly because scripted demonstrations follow a fixed investigation order per fault family. Low loss here indicates format acquisition, not diagnostic skill β which is why the OOD and perturbation tables, not the loss curve, are the result.
Simulated workload physics. Pod status is computed, not observed. Fidelity is verified on the process layer (5/5) and asserted per fault family; it is not a claim about a production cluster.
Post-training regresses on held-out fault families β 38.2% against base's 65.8% at n = 2,200. Diagnosed to label-prior collapse and reversible at serving time, but the sweep does not locate an optimum, and the held-out axis is two fault families.
Every method arm is a single training run. With a 3.1 pp seed floor and method effects of 2β5 pp, the grid establishes that post-training works and does not establish which method is best.
One base model. Qwen3-8B throughout.
PromQL-lite.
query_metricssupports metric names and exact label matches only.Single-node control plane. No HA, no multi-tenancy, no cross-node networking.
Compositional incidents inherit the primary fault's safety policy, so unsafe-action accounting is slightly conservative on that split.
Documents
Design of record: architecture, components, data model, verification, evaluation methodology, limitations | |
Experiment log: every result, every retraction, and the engineering notes worth keeping | |
The four decisions taken against the original brainstorm, and why |
License
MIT β see LICENSE.
This server cannot be deployed
Maintenance
Related MCP Connectors
Protocol-native energy infrastructure orchestration for AI data centers. Provides 46 MCP tools across 8 grid protocols (IEC-61850, DNP3, Modbus, OCPP, OpenADR, IEEE 2030.5, IEC 60870-5-104, ICCP) with 5 core API primitives: connect, dispatch, settle, comply, and intel. Enables AI agents to programmatically interact with substations, grid interfaces, and energy assets for real-time workload-grid coordination.
Control plane for autonomous software labor. Agents claim objectives over MCP with audit trail.
The Google GKE MCP server is a managed Model Context Protocol server that provides AI applications with tools to manage Google Kubernetes Engine (GKE) clusters and Kubernetes resources. It exposes a structured, discoverable interface that allows AI agents to interact with GKE and Kubernetes APIs, enabling them to inspect cluster configurations, retrieve Kubernetes resource YAMLs, monitor operations like cluster upgrades, diagnose issues, and optimize costsβall without needing to parse text output or use complex kubectl commands.
MCP-native AI SRE: ask what's broken in production, get a reviewed GitHub fix PR.
Related MCP Servers
- FlicenseNot gradedqualityDmaintenanceMCP server that gives AI assistants full access to Kubernetes clusters and Helm, exposing 73 tools for managing pods, deployments, services, configs, secrets, logs, exec, port-forwarding, Helm lifecycle, and more.-
- AlicenseAqualityAmaintenanceGoverned Kubernetes operations for AI agents with 15 MCP tools, audit logging, policy engine, and safety features.55MIT
- FlicenseNot gradedqualityCmaintenanceAn MCP server exposing Kubernetes-style diagnostic tools to an LLM agent, with a safety approval gate for destructive actions, all backed by a mock cluster for local testing.-
- AlicenseNot gradedqualityCmaintenanceEnables AI agents to diagnose and recover Kubernetes clusters when the API server is unreachable, using SSH access to nodes and tools like etcdctl, systemctl, and kubeadm with 63 read-only and destructive tools.MIT