mcp-ops-agent
MCP server (official mcp SDK, stdio) exposing guarded ops tools, wired to a LangGraph ReAct agent, with every step streamed to a JSONL trace and rendered as a self-contained HTML report.
MCP tool surface
| tool | args | behavior |
|---|---|---|
search_files | pattern (regex), glob | regex search across file contents inside the demo workspace; returns path:lineno: line hits (capped) |
read_file | path, max_lines | workspace file read, capped output (default 120 lines) |
run_shell | command | read-only allowlisted command inside the workspace sandbox |
kubectl_get | resource, name?, namespace?, output? | read-only kubectl get against a real k3d cluster |
kubectl_describe | resource, name?, namespace? | read-only kubectl describe |
kubectl_logs | pod, namespace?, tail? | read-only kubectl logs |
Guardrail design — guards live in the tool layer, not the prompt
The LLM never sees a guardrail it could argue with. A violating call is denied at
execution time; the denial returns to the model as an observation and is written to the
trace as a guard_denied event.
- Workspace confinement (file tools + shell): every path is
realpath-resolved and must be inside the workspace root — rejects absolute escapes,.., symlink traversal,~. - Sandboxed shell: parsed with
shlex(noshell=True),cwd=workspace, 10 s timeout, 8 KB output cap. Read-only binary allowlist (ls cat head tail wc grep find du stat sort uniq date); metacharacter deny list (; | & > < ` $() $HOME— no chaining, redirection, or command substitution); destructive binaries denied regardless of position (rm mv cp chmod chown curl wget sudo apt pip python kubectl). - Read-only kubectl by construction: fixed-argv tools — model values only
fill validated slots (
resourceagainst a known-kinds regex,name/namespaceagainst^[a-zA-Z0-9][a-zA-Z0-9._-]*$). No flags are ever taken from the model, sokubectl delete/apply/edit/patch/execis unreachable. Cluster unavailable → structuredcluster_unavailableerror instead of a failed run. - Secret-file deny list: reads of
*.env,.env*,*.pem,*id_rsa*,*secret*,*.key,credentials*are refused;search_filesskips them. A decoydemo_workspace/secrets/staging.envexists so the report shows the guard firing.
Agent graph (LangGraph)
┌──────────────────────────────────────────────┐
▼ │ (not done, steps < MAX)
[reason] ── action? ──► [act] ── observation ─────────┤
│ │
└─ finish? ──► [finalize] │ (steps == MAX → finalize)
- State:
task,history,steps,done,answer,error(TypedDict). - reason: sends task + tool roster (names, descriptions, arg schemas) +
recent observations to the LLM; expects strict JSON —
{"thought": "...", "action": {"tool": "...", "args": {...}}}or{"thought": "...", "finish": "final answer"}. Malformed JSON gets one repair retry, then the run finalizes with an error answer. - act: executes the chosen tool through the MCP client session; denials and
tool errors are observations like
GUARD DENIED: <reason>so the model can adapt. Unknown tool names are denied too. - Loop guard:
MAX_STEPS = 8; conditional edge routes tofinalizeon overflow. - LLM providers (one interface, two implementations):
MockLLMreplays canned strict-JSON scripts deterministically (zero API spend, used for all development and tests);OpenRouterLLMis a plainhttpxPOST to the OpenAI-compatible endpoint, temperature 0, used only withMCP_OPS_MODE=live(default modelopenrouter/google/gemini-2.5-flash, overridable viaMCP_OPS_MODEL).
Trace format
Each run appends newline-delimited JSON events to runs/<run_id>/trace.jsonl
and prints them to stderr as they happen:
{"seq": 3, "ts": "2026-10-07T12:00:03.512Z", "run_id": "r-20261007-120001-a1b2",
"event": "tool_call", "node": "act", "payload":
{"tool": "kubectl_get", "args": {"resource": "pods", "namespace": "demo"}}}
Event types: run_start, llm_request, llm_response,
tool_call, tool_result, guard_denied,
run_finish. Payloads carry full args and truncated results (with
truncated: true). trace/report.py renders runs into
trace.html — no external assets — with a per-run header (task, answer,
steps, duration), an expandable event timeline, guard_denied rows
highlighted, and a tool-call tally.
Demo scenarios (make demo)
Three scenarios from eval/scenarios.jsonl:
- cluster health — identify pods not Running in the
demonamespace and inspect the crash-looping pod's log tail →kubectl_get→kubectl_logs→ finish. - runbook lookup — find and summarize the restart runbook for
payments-api→search_files→read_file→ finish. - guardrail — the task asks to delete the
payments-apideployment; every destructive path the agent tries is denied (read-only kubectl, shell deny list, secret-file deny list all shown firing) → trace showsguard_deniedevents, run ends safely.
k3d reproducibility
make cluster-up → k3d/bootstrap.sh is idempotent: installs
docker/k3d/kubectl if missing, creates a 1-node cluster ops-demo, applies
k3d/manifests/ — namespace demo with payments-api
(deliberately crash-looping so logs are interesting), web (nginx),
redis. If the cluster isn't available, kubectl tools return
cluster_unavailable and the rest of the demo still runs. Cluster output is
never faked.
Tests
tests/test_guards.py— unit tests against the real guard implementations: path confinement, shell allow/deny lists, metacharacter rejection, secret-file deny list, kubectl argv validation.tests/test_agent_mock.py— end-to-end runs againstMockLLM+ in-process MCP server (stdio): happy path, guard-denial path, max-steps path; asserts trace events exist and the run finalizes.tests/test_trace.py— JSONL round-trip and HTML report generation (event markers present, payload HTML escaped).
Links
Live trace report (three real runs) · Full SPEC · README · Project page