Skip to content

Commit 3d658f5

Browse files
committed
refactor(keep-mcp): collapse to one file (454 → 192 lines)
Ultra pass: this is a proxy to 5 REST endpoints. It doesn't need a client class, a config module, a factory, or three ceilings on limit. - delete client.py, config.py, .gitignore - fold everything into __main__.py: 5 tools call httpx directly - drop pydantic and pydantic-settings deps (os.environ.get is fine) - delete /readyz (add back with the Helm chart) - delete client-side limit clamps and status default (backend handles) - shrink README to 20 lines 3/3 tests pass; streamable-http boots; MCP handshake works.
1 parent 222445c commit 3d658f5

8 files changed

Lines changed: 124 additions & 341 deletions

File tree

keep-mcp/.gitignore

Lines changed: 0 additions & 9 deletions
This file was deleted.

keep-mcp/README.md

Lines changed: 9 additions & 64 deletions
Original file line numberDiff line numberDiff line change
@@ -1,75 +1,20 @@
11
# keep-mcp
22

3-
Model Context Protocol (MCP) server for [Keep](https://github.com/keephq/keep) — exposes Keep's alerts, incidents, and topology to LLM agents as MCP tools and resources.
3+
Read-only Model Context Protocol server for [Keep](https://github.com/keephq/keep). Sidecar container next to `keep-backend` / `keep-frontend`, thin proxy over the Keep REST API (`X-API-KEY`).
44

5-
Runs as an independent container alongside `keep-backend` and `keep-frontend`. Talks to the Keep REST API via `X-API-KEY`; never touches Keep's database or secret manager directly.
5+
## Tools
66

7-
## v0.1 scope (read-only)
7+
`search_alerts` · `list_incidents` · `get_incident` · `list_incident_alerts` · `get_topology`
88

9-
**Tools**
9+
Query language for `search_alerts` is CEL — see [docs.keephq.dev](https://docs.keephq.dev).
1010

11-
- `search_alerts(cel, limit, offset)` — query alerts with a CEL expression (same expression language the Keep UI uses).
12-
- `list_incidents(status, severity, limit, offset, cel)` — list incidents, defaulting to active (`firing` + `acknowledged`).
13-
- `get_incident(incident_id)` — fetch a single incident by UUID.
14-
- `list_incident_alerts(incident_id, limit, offset)` — list alerts linked to an incident.
15-
- `get_topology(service)` — service dependency graph, optionally scoped to one service.
16-
17-
Write tools (`enrich_alert`, `create_incident`, `run_workflow`, …) and LGTM passthrough (`loki_query_range`, `mimir_query_range`, `tempo_search_traces`) are planned for v0.2 / v0.3. See `plan.md` in the parent worktree for the full roadmap.
18-
19-
## Configuration
20-
21-
Every setting is an environment variable. Prefix `KEEP_MCP_`.
22-
23-
| Variable | Default | Description |
24-
|---|---|---|
25-
| `KEEP_MCP_KEEP_API_URL` | `http://keep-backend:8080` | Base URL of the Keep REST API |
26-
| `KEEP_MCP_KEEP_API_KEY` | *(required)* | API key used on every request (`X-API-KEY`) |
27-
| `KEEP_MCP_TRANSPORT` | `stdio` | `stdio` or `streamable-http` |
28-
| `KEEP_MCP_HTTP_HOST` | `0.0.0.0` | Bind host for `streamable-http` |
29-
| `KEEP_MCP_HTTP_PORT` | `8090` | Bind port for `streamable-http` |
30-
| `KEEP_MCP_HTTP_TIMEOUT` | `30` | httpx timeout for calls to `keep-backend` |
31-
| `KEEP_MCP_LOG_LEVEL` | `INFO` | Root log level |
32-
33-
## Running locally (stdio, for Claude Desktop / Copilot CLI / Cursor)
11+
## Run
3412

3513
```bash
36-
poetry install
37-
KEEP_MCP_KEEP_API_URL=http://localhost:8080 \
38-
KEEP_MCP_KEEP_API_KEY=your-key \
39-
poetry run keep-mcp
40-
```
41-
42-
Example client config (`~/.copilot/mcp.json`):
43-
44-
```json
45-
{
46-
"servers": {
47-
"keep": {
48-
"command": "poetry",
49-
"args": ["run", "keep-mcp"],
50-
"cwd": "/path/to/keep/keep-mcp",
51-
"env": {
52-
"KEEP_MCP_KEEP_API_URL": "http://localhost:8080",
53-
"KEEP_MCP_KEEP_API_KEY": "your-key"
54-
}
55-
}
56-
}
57-
}
14+
KEEP_MCP_KEEP_API_KEY=<your-key> docker compose --profile mcp up keep-mcp
15+
curl http://localhost:8090/healthz
5816
```
5917

60-
## Running in Docker (streamable-http)
61-
62-
Ships as `keep-mcp` in the repo's `docker-compose.yml`. Enable it with:
63-
64-
```bash
65-
KEEP_MCP_KEEP_API_KEY=your-key docker compose up keep-mcp
66-
```
67-
68-
Health checks:
69-
70-
- `GET http://localhost:8090/healthz` — process liveness
71-
- `GET http://localhost:8090/readyz` — verifies `keep-backend` reachable
72-
73-
## Architecture
18+
Env: `KEEP_MCP_KEEP_API_URL` (default `http://keep-backend:8080`), `KEEP_MCP_KEEP_API_KEY` (required), `KEEP_MCP_TRANSPORT` (`stdio` | `streamable-http`, default `stdio`), `KEEP_MCP_HTTP_HOST`/`PORT` (streamable-http only).
7419

75-
Thin adapter. All auth/RBAC/tenancy stay in `keep-backend`. This container is a stateless HTTP client — safe to scale horizontally and safe to restart at will.
20+
Write tools and LGTM passthrough land in v0.2/v0.3.

keep-mcp/pyproject.toml

Lines changed: 0 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -10,8 +10,6 @@ packages = [{ include = "keep_mcp", from = "src" }]
1010
python = ">=3.11,<3.14"
1111
mcp = "^1.2.0"
1212
httpx = "^0.27.0"
13-
pydantic = "^2.7"
14-
pydantic-settings = "^2.4"
1513

1614
[tool.poetry.group.dev.dependencies]
1715
pytest = "^8.0"

keep-mcp/src/keep_mcp/__main__.py

Lines changed: 81 additions & 92 deletions
Original file line numberDiff line numberDiff line change
@@ -1,113 +1,102 @@
1-
from __future__ import annotations
1+
"""keep-mcp — Model Context Protocol server for Keep."""
22

33
import logging
4+
import os
45
import sys
5-
from typing import Any
66

77
import httpx
88
from mcp.server.fastmcp import FastMCP
9-
from starlette.requests import Request
10-
from starlette.responses import JSONResponse, PlainTextResponse
9+
from starlette.responses import PlainTextResponse
10+
11+
API_URL = os.environ.get("KEEP_MCP_KEEP_API_URL", "http://keep-backend:8080").rstrip("/")
12+
API_KEY = os.environ.get("KEEP_MCP_KEEP_API_KEY", "")
13+
TRANSPORT = os.environ.get("KEEP_MCP_TRANSPORT", "stdio")
14+
HTTP_HOST = os.environ.get("KEEP_MCP_HTTP_HOST", "0.0.0.0")
15+
HTTP_PORT = int(os.environ.get("KEEP_MCP_HTTP_PORT", "8090"))
16+
17+
http = httpx.AsyncClient(
18+
base_url=API_URL,
19+
timeout=30.0,
20+
headers={"X-API-KEY": API_KEY, "User-Agent": "keep-mcp/0.1"},
21+
)
22+
23+
mcp = FastMCP(
24+
name="keep",
25+
instructions=(
26+
"Read-only access to a Keep AIOps deployment. Use search_alerts and "
27+
"list_incidents to see what is firing, then get_incident + "
28+
"list_incident_alerts to drill in. CEL is the query language for "
29+
"search_alerts — see docs.keephq.dev."
30+
),
31+
)
32+
33+
34+
async def _json(method: str, path: str, **kw):
35+
r = await http.request(method, path, **kw)
36+
r.raise_for_status()
37+
return r.json() if r.content else None
38+
39+
40+
@mcp.tool()
41+
async def search_alerts(cel: str = "", limit: int = 25, offset: int = 0) -> dict:
42+
"""Query Keep alerts. `cel` is a CEL filter like `severity == "critical"`; empty returns most recent."""
43+
return await _json("POST", "/alerts/query", json={"cel": cel, "limit": limit, "offset": offset})
44+
45+
46+
@mcp.tool()
47+
async def list_incidents(
48+
status: list[str] | None = None,
49+
severity: list[str] | None = None,
50+
limit: int = 25,
51+
offset: int = 0,
52+
cel: str | None = None,
53+
) -> dict:
54+
"""List Keep incidents. status ⊆ {firing, resolved, acknowledged, merged, deleted}; severity ⊆ {critical, high, warning, info, low}."""
55+
params: list[tuple[str, str | int]] = [("limit", limit), ("offset", offset)]
56+
params += [("status", s) for s in status or []]
57+
params += [("severity", s) for s in severity or []]
58+
if cel:
59+
params.append(("cel", cel))
60+
return await _json("GET", "/incidents", params=params)
61+
62+
63+
@mcp.tool()
64+
async def get_incident(incident_id: str) -> dict:
65+
return await _json("GET", f"/incidents/{incident_id}")
66+
67+
68+
@mcp.tool()
69+
async def list_incident_alerts(incident_id: str, limit: int = 25, offset: int = 0) -> dict:
70+
return await _json(
71+
"GET", f"/incidents/{incident_id}/alerts", params={"limit": limit, "offset": offset}
72+
)
73+
74+
75+
@mcp.tool()
76+
async def get_topology(service: str | None = None) -> list[dict]:
77+
return await _json("GET", "/topology", params={"service": service} if service else None)
1178

12-
from .client import KeepClient
13-
from .config import load_settings
1479

15-
log = logging.getLogger("keep_mcp")
80+
@mcp.custom_route("/healthz", methods=["GET"])
81+
async def healthz(_):
82+
return PlainTextResponse("ok")
1683

1784

1885
def main() -> None:
19-
settings = load_settings()
2086
logging.basicConfig(
21-
level=settings.log_level.upper(),
87+
level=logging.INFO,
2288
format="%(asctime)s %(levelname)s %(name)s: %(message)s",
2389
stream=sys.stderr,
2490
)
25-
26-
if not settings.keep_api_key:
27-
log.error("KEEP_MCP_KEEP_API_KEY is not set; refusing to start")
91+
if not API_KEY:
92+
logging.error("KEEP_MCP_KEEP_API_KEY is not set; refusing to start")
2893
sys.exit(2)
29-
30-
log.info(
31-
"starting keep-mcp transport=%s keep_api_url=%s",
32-
settings.transport,
33-
settings.keep_api_url,
34-
)
35-
36-
client = KeepClient(settings.keep_api_url, settings.keep_api_key, settings.http_timeout)
37-
mcp = FastMCP(
38-
name="keep",
39-
instructions=(
40-
"Read-only access to a Keep AIOps deployment. Use search_alerts and "
41-
"list_incidents to see what is firing, then get_incident + "
42-
"list_incident_alerts to drill in. CEL is the query language for "
43-
"search_alerts — see docs.keephq.dev."
44-
),
45-
)
46-
47-
@mcp.tool()
48-
async def search_alerts(cel: str = "", limit: int = 25, offset: int = 0) -> dict[str, Any]:
49-
"""Query Keep alerts. `cel` is a CEL expression like `severity == "critical"`; empty returns most recent."""
50-
return await client.query_alerts(cel=cel, limit=max(1, min(limit, 1000)), offset=offset)
51-
52-
@mcp.tool()
53-
async def list_incidents(
54-
status: list[str] | None = None,
55-
severity: list[str] | None = None,
56-
limit: int = 25,
57-
offset: int = 0,
58-
cel: str | None = None,
59-
) -> dict[str, Any]:
60-
"""List Keep incidents. Defaults to active incidents (firing + acknowledged) when status is omitted."""
61-
if status is None:
62-
status = ["firing", "acknowledged"]
63-
return await client.list_incidents(
64-
status=status,
65-
severity=severity,
66-
limit=max(1, min(limit, 500)),
67-
offset=offset,
68-
cel=cel,
69-
)
70-
71-
@mcp.tool()
72-
async def get_incident(incident_id: str) -> dict[str, Any]:
73-
"""Fetch a single incident by UUID."""
74-
return await client.get_incident(incident_id)
75-
76-
@mcp.tool()
77-
async def list_incident_alerts(
78-
incident_id: str, limit: int = 25, offset: int = 0
79-
) -> dict[str, Any]:
80-
"""List alerts linked to an incident."""
81-
return await client.get_incident_alerts(
82-
incident_id, limit=max(1, min(limit, 200)), offset=offset
83-
)
84-
85-
@mcp.tool()
86-
async def get_topology(service: str | None = None) -> list[dict[str, Any]]:
87-
"""Return the service dependency graph, optionally scoped to one service."""
88-
return await client.get_topology(service=service)
89-
90-
@mcp.custom_route("/healthz", methods=["GET"])
91-
async def healthz(_: Request) -> PlainTextResponse:
92-
return PlainTextResponse("ok")
93-
94-
@mcp.custom_route("/readyz", methods=["GET"])
95-
async def readyz(_: Request) -> JSONResponse:
96-
try:
97-
resp = await client.get("/healthcheck")
98-
ready = resp.status_code < 500
99-
except httpx.HTTPError:
100-
ready = False
101-
return JSONResponse(
102-
{"ready": ready, "keep_api_url": settings.keep_api_url},
103-
status_code=200 if ready else 503,
104-
)
105-
106-
if settings.transport == "stdio":
94+
logging.info("starting keep-mcp transport=%s keep_api_url=%s", TRANSPORT, API_URL)
95+
if TRANSPORT == "stdio":
10796
mcp.run(transport="stdio")
10897
else:
109-
mcp.settings.host = settings.http_host
110-
mcp.settings.port = settings.http_port
98+
mcp.settings.host = HTTP_HOST
99+
mcp.settings.port = HTTP_PORT
111100
mcp.run(transport="streamable-http")
112101

113102

keep-mcp/src/keep_mcp/client.py

Lines changed: 0 additions & 67 deletions
This file was deleted.

keep-mcp/src/keep_mcp/config.py

Lines changed: 0 additions & 30 deletions
This file was deleted.

0 commit comments

Comments
 (0)