Skip to main content
Glama
kubepulse-mcp

kubepulse-mcp

README.md
# KubePulse MCP Server

KubePulse is a command line tool I wrote to diagnose Kubernetes clusters from a snapshot file. This project wraps it as a Model Context Protocol server, so an LLM client can call the diagnosis as a tool instead of a human running it in a terminal. The client hands over a snapshot, the server runs the `kubepulse` binary, and the report goes back as structured data the model can reason over.

The boundary from KubePulse carries over unchanged: the server can only diagnose a snapshot and diff two snapshots. There is no tool that changes a cluster, because the underlying CLI has no write paths either.

## Tools

* `diagnose_snapshot`: takes a snapshot object and an optional `fail_on` severity. Returns the full KubePulse report as JSON: findings grouped by severity counts, each with its evidence and a suggested next step.
* `diff_snapshots`: takes `before` and `after` snapshot objects and an optional `fail_on`. Returns the diff report: findings grouped into new, resolved, and unchanged.
* `list_detectors`: takes nothing. Returns the eight detectors with their severity and what each one looks for.

## Install

You need Python 3.11 or newer, and the `kubepulse` binary. Build that first:

```sh
git clone https://github.com/UdayKandyanam/kubepulse.git
cd kubepulse
make build
```

Then install this server:

```sh
git clone https://github.com/UdayKandyanam/kubepulse-mcp.git
cd kubepulse-mcp
pip install .
```

That puts a `kubepulse-mcp` command on your PATH. The server finds the binary through the `KUBEPULSE_BIN` environment variable when it is set, and otherwise looks for `kubepulse` on PATH. If neither works, it stops at startup with a message saying what to set.

## Quick start

Point an MCP client at the server over stdio. A client configuration looks like this:

```json
{
  "mcpServers": {
    "kubepulse": {
      "command": "kubepulse-mcp",
      "args": [],
      "env": { "KUBEPULSE_BIN": "/path/to/kubepulse" }
    }
  }
}
```

The server speaks newline delimited JSON-RPC 2.0 on stdin and stdout, which is the MCP stdio transport. It waits for an `initialize` request, answers `tools/list` and `tools/call`, and exits when stdin closes.

## A worked example

Captured from a run against the real binary. First the handshake:

```text
>>> {"jsonrpc": "2.0", "id": 1, "method": "initialize", "params": {"protocolVersion": "2025-03-26", "capabilities": {}, "clientInfo": {"name": "smoke-test", "version": "0.1.0"}}}
<<< {"jsonrpc": "2.0", "id": 1, "result": {"protocolVersion": "2025-03-26", "capabilities": {"tools": {}}, "serverInfo": {"name": "kubepulse-mcp", "version": "0.1.0"}}}
```

Then a `tools/call` for `diagnose_snapshot` with a one pod snapshot (a single container in CrashLoopBackOff, on an invented cluster):

```json
{
  "jsonrpc": "2.0",
  "id": 3,
  "method": "tools/call",
  "params": {
    "name": "diagnose_snapshot",
    "arguments": {
      "snapshot": {
        "cluster": "demo-cluster",
        "pods": [
          {
            "name": "api-7849c6f5d9-xk2lp",
            "namespace": "shop",
            "phase": "Running",
            "containers": [
              {
                "name": "api",
                "restartCount": 14,
                "state": { "waiting": { "reason": "CrashLoopBackOff" } }
              }
            ]
          }
        ],
        "deployments": [],
        "nodes": [],
        "events": []
      }
    }
  }
}
```

The response carries the report twice, once as a text block and once as `structuredContent`. The text block decodes to the report itself:

```json
{
  "cluster": "demo-cluster",
  "summary": { "critical": 0, "high": 1, "low": 0, "medium": 0 },
  "findings": [
    {
      "severity": "high",
      "detector": "crashloop",
      "object": "Pod shop/api-7849c6f5d9-xk2lp",
      "summary": "container api is in CrashLoopBackOff after 14 restarts",
      "evidence": "back-off 5m0s restarting failed container",
      "nextStep": "Read the previous container logs (kubectl logs with the previous flag) and fix the startup failure before raising restart counts further."
    }
  ]
}
```

With `fail_on` set, KubePulse exits 1 when the gate trips. That is not an error to this server: the caller still gets the full report, plus a short note saying the gate tripped and at what severity.

## How it works

```text
MCP client
  |  JSON-RPC 2.0 over stdio, one message per line
  v
kubepulse-mcp (this repo, Python, standard library only)
  |  writes the snapshot to a temp file, runs a subprocess
  v
kubepulse binary (Go, from the KubePulse repo)
  |  prints the report as JSON on stdout
  v
report parsed, checked, and returned as tool content
```

## Design choices

The protocol is implemented directly, with no MCP SDK. At this size the surface is small: an initialize handshake, ping, tools/list, and tools/call, all as newline delimited JSON-RPC. Writing it by hand keeps the dependency count at zero and makes the wire format something I can read in a test, which mattered more to me than saving an afternoon.

The server never imports diagnosis logic. It writes the snapshot argument to a temporary file, runs the CLI with `--format json`, and parses stdout. That keeps one source of truth for what a finding means: the Go detectors and their tests. If KubePulse gains a detector, this server gains it too, with no code change here.

Errors are mapped deliberately. A tool that fails, because the snapshot argument is the wrong shape or the binary rejected it or the binary is missing at call time, comes back as a normal tool result with `isError` set and a readable message, so the model can react to it and the server keeps running. Protocol problems, an unknown method or an unknown tool name, come back as JSON-RPC errors, because those are client bugs rather than tool results.

## Limits

* Snapshots travel through tool arguments, so very large cluster snapshots make for very large requests. This fits incident sized inputs, not a thousand pod fleet capture.
* The server is stdio only and serves one client at a time.
* Diagnosis quality is exactly KubePulse quality. The report is only as good as the snapshot, and KubePulse does not read logs, so a crashloop finding points at the container rather than the stack trace.
* `list_detectors` is static data that mirrors the KubePulse README. If the detectors change, this list needs a matching edit.

## Tests

```sh
pip install -e ".[dev]"
pytest
```

The suite covers the handshake, ping, tools/list, unknown methods, and notifications, plus the tools themselves. Tool tests run against a small stub shell script that stands in for the binary, prints a fixed report, and records the arguments it was called with, so the tests can check both the returned report and the exact command line the server built. A separate set covers the failure cases: a stub that exits 2, a binary missing at call time, and a binary missing at startup.

## Roadmap

* An HTTP transport option alongside stdio, for clients that do not spawn subprocesses.
* A collect tool that builds a snapshot from a live cluster through the CLI, so a client can go from cluster to diagnosis in two calls.
* Resource and prompt templates for the incident questions I reach for most, so clients can ask them the same way every time.

## License

MIT, see `LICENSE`.