From dc0c17082ec5e52cf769f648803e9988163ed5b2 Mon Sep 17 00:00:00 2001 From: harisrujanc Date: Sun, 5 Jul 2026 14:58:25 +0300 Subject: [PATCH 1/5] feat(keep-mcp): add v0.1 read-only MCP server as sidecar container MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Introduces keep-mcp/ — a standalone Python package that exposes Keep's alerts, incidents, and topology to LLM agents via the Model Context Protocol. Ships as an independent container alongside keep-backend and keep-frontend, matching the existing keep-websocket-server sidecar pattern. Tools (v0.1, read-only): - search_alerts(cel, limit, offset) - list_incidents(status, severity, limit, offset, cel) - get_incident_with_alerts(incident_id, alerts_limit) - get_topology(service) Resources: - keep://alerts - keep://incidents - keep://topology Transports: stdio (local desktop clients) and streamable-http (remote). Auth: X-API-KEY forwarded from KEEP_MCP_KEEP_API_KEY; no direct DB or secret-manager access — RBAC and tenancy stay in keep-backend. Ops: /healthz + /readyz, Docker HEALTHCHECK, Alpine python:3.13.5 base matching Dockerfile.api, non-root user (UID 1000). Wired into docker-compose behind the 'mcp' profile so it stays out of the default 'docker compose up'. --- docker-compose.common.yml | 11 +++ docker-compose.yml | 13 +++ docker/Dockerfile.mcp | 54 +++++++++++ keep-mcp/.gitignore | 9 ++ keep-mcp/README.md | 80 ++++++++++++++++ keep-mcp/pyproject.toml | 35 +++++++ keep-mcp/src/keep_mcp/__init__.py | 3 + keep-mcp/src/keep_mcp/__main__.py | 40 ++++++++ keep-mcp/src/keep_mcp/client.py | 127 +++++++++++++++++++++++++ keep-mcp/src/keep_mcp/config.py | 30 ++++++ keep-mcp/src/keep_mcp/server.py | 151 ++++++++++++++++++++++++++++++ keep-mcp/tests/__init__.py | 0 keep-mcp/tests/test_client.py | 81 ++++++++++++++++ 13 files changed, 634 insertions(+) create mode 100644 docker/Dockerfile.mcp create mode 100644 keep-mcp/.gitignore create mode 100644 keep-mcp/README.md create mode 100644 keep-mcp/pyproject.toml create mode 100644 keep-mcp/src/keep_mcp/__init__.py create mode 100644 keep-mcp/src/keep_mcp/__main__.py create mode 100644 keep-mcp/src/keep_mcp/client.py create mode 100644 keep-mcp/src/keep_mcp/config.py create mode 100644 keep-mcp/src/keep_mcp/server.py create mode 100644 keep-mcp/tests/__init__.py create mode 100644 keep-mcp/tests/test_client.py diff --git a/docker-compose.common.yml b/docker-compose.common.yml index b2f8d44fcc..6ee73cbfe9 100644 --- a/docker-compose.common.yml +++ b/docker-compose.common.yml @@ -40,3 +40,14 @@ services: - SOKETI_DEFAULT_APP_ID=1 - SOKETI_DEFAULT_APP_KEY=keepappkey - SOKETI_DEFAULT_APP_SECRET=keepappsecret + + keep-mcp-common: + ports: + - "8090:8090" + environment: + - KEEP_MCP_TRANSPORT=streamable-http + - KEEP_MCP_HTTP_HOST=0.0.0.0 + - KEEP_MCP_HTTP_PORT=8090 + - KEEP_MCP_KEEP_API_URL=http://keep-backend:8080 + - KEEP_MCP_KEEP_API_KEY=${KEEP_MCP_KEEP_API_KEY:-keepappkey} + - KEEP_MCP_LOG_LEVEL=INFO diff --git a/docker-compose.yml b/docker-compose.yml index 8358c4edc3..45fb0a035c 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -29,6 +29,19 @@ services: file: docker-compose.common.yml service: keep-websocket-server-common + keep-mcp: + extends: + file: docker-compose.common.yml + service: keep-mcp-common + profiles: + - mcp + build: + context: . + dockerfile: docker/Dockerfile.mcp + image: us-central1-docker.pkg.dev/keephq/keep/keep-mcp + depends_on: + - keep-backend + grafana: image: grafana/grafana:latest profiles: diff --git a/docker/Dockerfile.mcp b/docker/Dockerfile.mcp new file mode 100644 index 0000000000..50a1ed9345 --- /dev/null +++ b/docker/Dockerfile.mcp @@ -0,0 +1,54 @@ +FROM python:3.13.5-alpine AS base + +RUN apk add --no-cache bash libstdc++ + +ENV PYTHONFAULTHANDLER=1 \ + PYTHONHASHSEED=random \ + PYTHONUNBUFFERED=1 + +RUN addgroup -g 1000 keep && \ + adduser -u 1000 -G keep -s /bin/sh -D keep + +WORKDIR /app + +FROM base AS builder + +RUN apk add --no-cache gcc g++ musl-dev libffi-dev openssl-dev build-base linux-headers + +ENV PIP_DEFAULT_TIMEOUT=100 \ + PIP_DISABLE_PIP_VERSION_CHECK=1 \ + PIP_NO_CACHE_DIR=1 \ + POETRY_VERSION=1.8.3 + +RUN pip install "poetry==$POETRY_VERSION" +RUN python -m venv /venv + +COPY keep-mcp/pyproject.toml keep-mcp/README.md ./keep-mcp/ +COPY keep-mcp/src ./keep-mcp/src + +RUN cd keep-mcp && \ + poetry export -f requirements.txt --output /tmp/requirements.txt --without-hashes --only main && \ + /venv/bin/python -m pip install --upgrade -r /tmp/requirements.txt && \ + /venv/bin/pip install ./ && \ + pip uninstall -y poetry && \ + rm -rf /root/.cache/pip && \ + find /venv -type d -name "__pycache__" -exec rm -rf {} + 2>/dev/null || true && \ + find /venv -type f -name "*.pyc" -delete 2>/dev/null || true + +FROM base AS final +ENV PATH="/venv/bin:${PATH}" \ + VIRTUAL_ENV="/venv" \ + KEEP_MCP_TRANSPORT=streamable-http \ + KEEP_MCP_HTTP_HOST=0.0.0.0 \ + KEEP_MCP_HTTP_PORT=8090 + +COPY --from=builder /venv /venv + +USER keep + +EXPOSE 8090 + +HEALTHCHECK --interval=30s --timeout=5s --start-period=10s --retries=3 \ + CMD wget -qO- http://127.0.0.1:8090/healthz || exit 1 + +ENTRYPOINT ["python", "-m", "keep_mcp"] diff --git a/keep-mcp/.gitignore b/keep-mcp/.gitignore new file mode 100644 index 0000000000..3b64b512ec --- /dev/null +++ b/keep-mcp/.gitignore @@ -0,0 +1,9 @@ +.venv/ +__pycache__/ +*.pyc +.pytest_cache/ +.ruff_cache/ +dist/ +build/ +*.egg-info/ +.env diff --git a/keep-mcp/README.md b/keep-mcp/README.md new file mode 100644 index 0000000000..f535fa363b --- /dev/null +++ b/keep-mcp/README.md @@ -0,0 +1,80 @@ +# keep-mcp + +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. + +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. + +## v0.1 scope (read-only) + +**Tools** + +- `search_alerts(cel, limit, offset)` — query alerts with a CEL expression (same expression language the Keep UI uses). +- `list_incidents(status, severity, limit, offset)` — list incidents, filterable by status (`firing`, `acknowledged`, `resolved`, `merged`, `deleted`) and severity. +- `get_incident_with_alerts(incident_id, alerts_limit)` — fetch an incident and its linked alerts in one call. +- `get_topology(service)` — service dependency graph, optionally scoped to one service. + +**Resources** + +- `keep://alerts` — recent alerts +- `keep://incidents` — active incidents +- `keep://topology` — full topology + +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. + +## Configuration + +Every setting is an environment variable. Prefix `KEEP_MCP_`. + +| Variable | Default | Description | +|---|---|---| +| `KEEP_MCP_KEEP_API_URL` | `http://keep-backend:8080` | Base URL of the Keep REST API | +| `KEEP_MCP_KEEP_API_KEY` | *(required)* | API key used on every request (`X-API-KEY`) | +| `KEEP_MCP_TRANSPORT` | `stdio` | `stdio` or `streamable-http` | +| `KEEP_MCP_HTTP_HOST` | `0.0.0.0` | Bind host for `streamable-http` | +| `KEEP_MCP_HTTP_PORT` | `8090` | Bind port for `streamable-http` | +| `KEEP_MCP_HTTP_TIMEOUT` | `30` | httpx timeout for calls to `keep-backend` | +| `KEEP_MCP_LOG_LEVEL` | `INFO` | Root log level | + +## Running locally (stdio, for Claude Desktop / Copilot CLI / Cursor) + +```bash +poetry install +KEEP_MCP_KEEP_API_URL=http://localhost:8080 \ +KEEP_MCP_KEEP_API_KEY=your-key \ +poetry run keep-mcp +``` + +Example client config (`~/.copilot/mcp.json`): + +```json +{ + "servers": { + "keep": { + "command": "poetry", + "args": ["run", "keep-mcp"], + "cwd": "/path/to/keep/keep-mcp", + "env": { + "KEEP_MCP_KEEP_API_URL": "http://localhost:8080", + "KEEP_MCP_KEEP_API_KEY": "your-key" + } + } + } +} +``` + +## Running in Docker (streamable-http) + +Ships as `keep-mcp` in the repo's `docker-compose.yml`. Enable it with: + +```bash +KEEP_MCP_KEEP_API_KEY=your-key docker compose up keep-mcp +``` + +Health checks: + +- `GET http://localhost:8090/healthz` — process liveness +- `GET http://localhost:8090/readyz` — verifies `keep-backend` reachable + +## Architecture + +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. diff --git a/keep-mcp/pyproject.toml b/keep-mcp/pyproject.toml new file mode 100644 index 0000000000..9baaa2253c --- /dev/null +++ b/keep-mcp/pyproject.toml @@ -0,0 +1,35 @@ +[tool.poetry] +name = "keep-mcp" +version = "0.1.0" +description = "Model Context Protocol server for Keep — the open-source AIOps and alert management platform." +authors = ["Keep Alerting LTD"] +readme = "README.md" +packages = [{ include = "keep_mcp", from = "src" }] + +[tool.poetry.dependencies] +python = ">=3.11,<3.14" +mcp = "^1.2.0" +httpx = "^0.27.0" +pydantic = "^2.7" +pydantic-settings = "^2.4" +starlette = "^0.38" + +[tool.poetry.group.dev.dependencies] +pytest = "^8.0" +pytest-asyncio = "^0.23" +respx = "^0.21" +ruff = "^0.6" + +[tool.poetry.scripts] +keep-mcp = "keep_mcp.__main__:main" + +[build-system] +requires = ["poetry-core"] +build-backend = "poetry.core.masonry.api" + +[tool.ruff] +line-length = 100 +target-version = "py311" + +[tool.pytest.ini_options] +asyncio_mode = "auto" diff --git a/keep-mcp/src/keep_mcp/__init__.py b/keep-mcp/src/keep_mcp/__init__.py new file mode 100644 index 0000000000..22cf21f42a --- /dev/null +++ b/keep-mcp/src/keep_mcp/__init__.py @@ -0,0 +1,3 @@ +"""keep-mcp — Model Context Protocol server for Keep.""" + +__version__ = "0.1.0" diff --git a/keep-mcp/src/keep_mcp/__main__.py b/keep-mcp/src/keep_mcp/__main__.py new file mode 100644 index 0000000000..cf3bd8aa21 --- /dev/null +++ b/keep-mcp/src/keep_mcp/__main__.py @@ -0,0 +1,40 @@ +from __future__ import annotations + +import logging +import sys + +from .config import load_settings +from .server import build_server + + +def main() -> None: + settings = load_settings() + logging.basicConfig( + level=settings.log_level.upper(), + format="%(asctime)s %(levelname)s %(name)s: %(message)s", + stream=sys.stderr, + ) + log = logging.getLogger("keep_mcp") + + if not settings.keep_api_key: + log.error("KEEP_MCP_KEEP_API_KEY is not set; refusing to start") + sys.exit(2) + + log.info( + "starting keep-mcp transport=%s keep_api_url=%s", + settings.transport, + settings.keep_api_url, + ) + + mcp = build_server(settings) + + if settings.transport == "stdio": + mcp.run(transport="stdio") + else: + mcp.settings.host = settings.http_host + mcp.settings.port = settings.http_port + mcp.run(transport="streamable-http") + + +if __name__ == "__main__": + main() diff --git a/keep-mcp/src/keep_mcp/client.py b/keep-mcp/src/keep_mcp/client.py new file mode 100644 index 0000000000..0555f53c97 --- /dev/null +++ b/keep-mcp/src/keep_mcp/client.py @@ -0,0 +1,127 @@ +from __future__ import annotations + +import logging +from typing import Any + +import httpx + +logger = logging.getLogger(__name__) + + +class KeepAPIError(RuntimeError): + """Raised when keep-backend returns a non-2xx response.""" + + def __init__(self, status_code: int, detail: str, url: str): + self.status_code = status_code + self.detail = detail + self.url = url + super().__init__(f"{status_code} from {url}: {detail}") + + +class KeepClient: + """Async httpx wrapper around the Keep REST API.""" + + def __init__(self, base_url: str, api_key: str, timeout: float = 30.0): + if not api_key: + raise ValueError("KEEP_MCP_KEEP_API_KEY is required") + self._base_url = base_url.rstrip("/") + self._client = httpx.AsyncClient( + base_url=self._base_url, + timeout=timeout, + headers={ + "X-API-KEY": api_key, + "Accept": "application/json", + "User-Agent": "keep-mcp/0.1", + }, + ) + + async def aclose(self) -> None: + await self._client.aclose() + + async def _request(self, method: str, path: str, **kwargs: Any) -> Any: + try: + response = await self._client.request(method, path, **kwargs) + except httpx.HTTPError as exc: + raise KeepAPIError(0, f"transport error: {exc}", path) from exc + + if response.status_code >= 400: + detail = _extract_detail(response) + raise KeepAPIError(response.status_code, detail, path) + + if not response.content: + return None + return response.json() + + # --- alerts --- + + async def query_alerts( + self, + cel: str = "", + limit: int = 25, + offset: int = 0, + ) -> dict[str, Any]: + body = {"cel": cel or "", "limit": limit, "offset": offset} + return await self._request("POST", "/alerts/query", json=body) + + async def get_alerts_by_fingerprints( + self, fingerprints: list[str] + ) -> list[dict[str, Any]]: + return await self._request( + "POST", "/alerts/batch", json={"fingerprints": fingerprints} + ) + + # --- incidents --- + + async def list_incidents( + self, + status: list[str] | None = None, + severity: list[str] | None = None, + limit: int = 25, + offset: int = 0, + cel: str | None = None, + ) -> dict[str, Any]: + params: list[tuple[str, str | int]] = [("limit", limit), ("offset", offset)] + for s in status or []: + params.append(("status", s)) + for s in severity or []: + params.append(("severity", s)) + if cel: + params.append(("cel", cel)) + return await self._request("GET", "/incidents", params=params) + + async def get_incident(self, incident_id: str) -> dict[str, Any]: + return await self._request("GET", f"/incidents/{incident_id}") + + async def get_incident_alerts( + self, incident_id: str, limit: int = 25, offset: int = 0 + ) -> dict[str, Any]: + return await self._request( + "GET", + f"/incidents/{incident_id}/alerts", + params={"limit": limit, "offset": offset}, + ) + + # --- topology --- + + async def get_topology(self, service: str | None = None) -> list[dict[str, Any]]: + params = {"service": service} if service else None + return await self._request("GET", "/topology", params=params) + + # --- health --- + + async def ping(self) -> bool: + try: + resp = await self._client.get("/healthcheck") + return resp.status_code < 500 + except httpx.HTTPError: + return False + + +def _extract_detail(response: httpx.Response) -> str: + try: + data = response.json() + if isinstance(data, dict): + return str(data.get("detail") or data) + return str(data) + except ValueError: + return response.text[:500] diff --git a/keep-mcp/src/keep_mcp/config.py b/keep-mcp/src/keep_mcp/config.py new file mode 100644 index 0000000000..b743d8ad81 --- /dev/null +++ b/keep-mcp/src/keep_mcp/config.py @@ -0,0 +1,30 @@ +from typing import Literal + +from pydantic import Field +from pydantic_settings import BaseSettings, SettingsConfigDict + + +class Settings(BaseSettings): + model_config = SettingsConfigDict( + env_prefix="KEEP_MCP_", + env_file=".env", + env_file_encoding="utf-8", + extra="ignore", + ) + + keep_api_url: str = "http://keep-backend:8080" + keep_api_key: str = Field( + default="", + description="API key sent as X-API-KEY to keep-backend. Required at runtime.", + ) + + transport: Literal["stdio", "streamable-http"] = "stdio" + http_host: str = "0.0.0.0" + http_port: int = 8090 + http_timeout: float = 30.0 + + log_level: str = "INFO" + + +def load_settings() -> Settings: + return Settings() diff --git a/keep-mcp/src/keep_mcp/server.py b/keep-mcp/src/keep_mcp/server.py new file mode 100644 index 0000000000..19d95a6c24 --- /dev/null +++ b/keep-mcp/src/keep_mcp/server.py @@ -0,0 +1,151 @@ +from __future__ import annotations + +import json +import logging +from typing import Any + +from mcp.server.fastmcp import FastMCP +from starlette.requests import Request +from starlette.responses import JSONResponse, PlainTextResponse + +from .client import KeepAPIError, KeepClient +from .config import Settings + +logger = logging.getLogger(__name__) + +INCIDENT_STATUSES = ["firing", "resolved", "acknowledged", "merged", "deleted"] +INCIDENT_SEVERITIES = ["critical", "high", "warning", "info", "low"] + + +def build_server(settings: Settings) -> FastMCP: + """Construct a FastMCP server wired to a Keep API client.""" + client = KeepClient( + base_url=settings.keep_api_url, + api_key=settings.keep_api_key, + timeout=settings.http_timeout, + ) + mcp = FastMCP( + name="keep", + instructions=( + "Read-only access to a Keep AIOps deployment. Use search_alerts and " + "list_incidents to discover what is currently firing, then " + "get_incident_with_alerts to drill into a specific incident. " + "CEL is the query language for search_alerts — see docs.keephq.dev." + ), + ) + + # --- tools --- + + @mcp.tool() + async def search_alerts( + cel: str = "", + limit: int = 25, + offset: int = 0, + ) -> dict[str, Any]: + """Query Keep alerts with an optional CEL expression. + + Args: + cel: CEL filter, e.g. `severity == "critical" && status == "firing"`. + Empty string returns the most recent alerts. + limit: Max alerts to return (1–1000). + offset: Pagination offset. + + Returns a dict with `count`, `limit`, `offset`, and `results` (alert DTOs). + """ + limit = max(1, min(limit, 1000)) + return await client.query_alerts(cel=cel, limit=limit, offset=offset) + + @mcp.tool() + async def list_incidents( + status: list[str] | None = None, + severity: list[str] | None = None, + limit: int = 25, + offset: int = 0, + cel: str | None = None, + ) -> dict[str, Any]: + """List Keep incidents, filterable by status and severity. + + Args: + status: Subset of {firing, resolved, acknowledged, merged, deleted}. + Defaults to active incidents (firing + acknowledged) when omitted. + severity: Subset of {critical, high, warning, info, low}. + limit: Max incidents to return (1–500). + offset: Pagination offset. + cel: Optional CEL filter applied server-side. + """ + limit = max(1, min(limit, 500)) + if status is None: + status = ["firing", "acknowledged"] + _validate_subset("status", status, INCIDENT_STATUSES) + if severity: + _validate_subset("severity", severity, INCIDENT_SEVERITIES) + return await client.list_incidents( + status=status, severity=severity, limit=limit, offset=offset, cel=cel + ) + + @mcp.tool() + async def get_incident_with_alerts( + incident_id: str, + alerts_limit: int = 25, + ) -> dict[str, Any]: + """Fetch an incident by id together with its linked alerts. + + Args: + incident_id: UUID of the incident. + alerts_limit: Max alerts to include (1–200). + """ + alerts_limit = max(1, min(alerts_limit, 200)) + incident = await client.get_incident(incident_id) + alerts = await client.get_incident_alerts(incident_id, limit=alerts_limit) + return {"incident": incident, "alerts": alerts} + + @mcp.tool() + async def get_topology(service: str | None = None) -> list[dict[str, Any]]: + """Return the service dependency graph, optionally scoped to one service.""" + return await client.get_topology(service=service) + + # --- resources --- + + @mcp.resource("keep://alerts") + async def resource_alerts() -> str: + data = await client.query_alerts(limit=50) + return json.dumps(data, default=str, indent=2) + + @mcp.resource("keep://incidents") + async def resource_incidents() -> str: + data = await client.list_incidents( + status=["firing", "acknowledged"], limit=50 + ) + return json.dumps(data, default=str, indent=2) + + @mcp.resource("keep://topology") + async def resource_topology() -> str: + data = await client.get_topology() + return json.dumps(data, default=str, indent=2) + + # --- health endpoints (streamable-http mode only) --- + + @mcp.custom_route("/healthz", methods=["GET"]) + async def healthz(_request: Request) -> PlainTextResponse: + return PlainTextResponse("ok") + + @mcp.custom_route("/readyz", methods=["GET"]) + async def readyz(_request: Request) -> JSONResponse: + ok = await client.ping() + status = 200 if ok else 503 + return JSONResponse( + {"ready": ok, "keep_api_url": settings.keep_api_url}, + status_code=status, + ) + + return mcp + + +def _validate_subset(name: str, values: list[str], allowed: list[str]) -> None: + invalid = [v for v in values if v not in allowed] + if invalid: + raise KeepAPIError( + 422, + f"invalid {name} values {invalid!r}; allowed={allowed}", + f"validation:{name}", + ) diff --git a/keep-mcp/tests/__init__.py b/keep-mcp/tests/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/keep-mcp/tests/test_client.py b/keep-mcp/tests/test_client.py new file mode 100644 index 0000000000..c593b18251 --- /dev/null +++ b/keep-mcp/tests/test_client.py @@ -0,0 +1,81 @@ +from __future__ import annotations + +import httpx +import pytest +import respx + +from keep_mcp.client import KeepAPIError, KeepClient + + +@pytest.fixture +def client() -> KeepClient: + return KeepClient("http://keep-backend:8080", api_key="test-key", timeout=5.0) + + +@respx.mock +async def test_query_alerts_sends_cel_and_pagination(client: KeepClient) -> None: + route = respx.post("http://keep-backend:8080/alerts/query").mock( + return_value=httpx.Response( + 200, + json={"limit": 5, "offset": 0, "count": 1, "results": [{"id": "a"}]}, + ) + ) + + data = await client.query_alerts(cel='severity == "critical"', limit=5) + + assert route.called + assert route.calls.last.request.headers["X-API-KEY"] == "test-key" + assert data["results"] == [{"id": "a"}] + + +@respx.mock +async def test_list_incidents_repeats_status_and_severity(client: KeepClient) -> None: + route = respx.get("http://keep-backend:8080/incidents").mock( + return_value=httpx.Response(200, json={"items": []}) + ) + + await client.list_incidents( + status=["firing", "acknowledged"], severity=["critical"], limit=10 + ) + + assert route.called + url = route.calls.last.request.url + assert url.params.get_list("status") == ["firing", "acknowledged"] + assert url.params.get_list("severity") == ["critical"] + assert url.params["limit"] == "10" + + +@respx.mock +async def test_get_incident_with_alerts_makes_two_calls(client: KeepClient) -> None: + incident_id = "11111111-1111-1111-1111-111111111111" + inc_route = respx.get(f"http://keep-backend:8080/incidents/{incident_id}").mock( + return_value=httpx.Response(200, json={"id": incident_id, "status": "firing"}) + ) + alerts_route = respx.get( + f"http://keep-backend:8080/incidents/{incident_id}/alerts" + ).mock(return_value=httpx.Response(200, json={"count": 0, "items": []})) + + incident = await client.get_incident(incident_id) + alerts = await client.get_incident_alerts(incident_id, limit=5) + + assert inc_route.called and alerts_route.called + assert incident["id"] == incident_id + assert alerts["items"] == [] + + +@respx.mock +async def test_non_2xx_raises_keep_api_error(client: KeepClient) -> None: + respx.post("http://keep-backend:8080/alerts/query").mock( + return_value=httpx.Response(400, json={"detail": "bad cel"}) + ) + + with pytest.raises(KeepAPIError) as exc_info: + await client.query_alerts(cel="!!!") + + assert exc_info.value.status_code == 400 + assert "bad cel" in str(exc_info.value) + + +def test_missing_api_key_raises() -> None: + with pytest.raises(ValueError): + KeepClient("http://x", api_key="") From 88dc1d30379e24e8e2b4849344fe5b899e750ee7 Mon Sep 17 00:00:00 2001 From: harisrujanc Date: Mon, 6 Jul 2026 16:37:36 +0300 Subject: [PATCH 2/5] refactor(keep-mcp): apply ponytail-review cuts (net -147 lines) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - delete KeepAPIError + _extract_detail; rely on httpx.raise_for_status - delete get_alerts_by_fingerprints (no v0.1 caller) - delete client.ping(); inline into /readyz - delete INCIDENT_STATUSES/SEVERITIES/_validate_subset (backend already 422s) - delete keep://alerts/incidents/topology resources (tools cover it) - split get_incident_with_alerts into get_incident + list_incident_alerts - inline build_server() into main() — one caller, no fake factory - drop starlette (transitive via mcp) and ruff (unwired) deps - delete empty tests/__init__.py - update tests to expect httpx.HTTPStatusError All 5 tests pass; MCP handshake + tools/list verified end-to-end. --- keep-mcp/README.md | 11 +-- keep-mcp/pyproject.toml | 2 - keep-mcp/src/keep_mcp/__main__.py | 81 +++++++++++++++- keep-mcp/src/keep_mcp/client.py | 90 +++--------------- keep-mcp/src/keep_mcp/server.py | 151 ------------------------------ keep-mcp/tests/__init__.py | 0 keep-mcp/tests/test_client.py | 26 +++-- 7 files changed, 107 insertions(+), 254 deletions(-) delete mode 100644 keep-mcp/src/keep_mcp/server.py delete mode 100644 keep-mcp/tests/__init__.py diff --git a/keep-mcp/README.md b/keep-mcp/README.md index f535fa363b..652fbe0e14 100644 --- a/keep-mcp/README.md +++ b/keep-mcp/README.md @@ -9,16 +9,11 @@ Runs as an independent container alongside `keep-backend` and `keep-frontend`. T **Tools** - `search_alerts(cel, limit, offset)` — query alerts with a CEL expression (same expression language the Keep UI uses). -- `list_incidents(status, severity, limit, offset)` — list incidents, filterable by status (`firing`, `acknowledged`, `resolved`, `merged`, `deleted`) and severity. -- `get_incident_with_alerts(incident_id, alerts_limit)` — fetch an incident and its linked alerts in one call. +- `list_incidents(status, severity, limit, offset, cel)` — list incidents, defaulting to active (`firing` + `acknowledged`). +- `get_incident(incident_id)` — fetch a single incident by UUID. +- `list_incident_alerts(incident_id, limit, offset)` — list alerts linked to an incident. - `get_topology(service)` — service dependency graph, optionally scoped to one service. -**Resources** - -- `keep://alerts` — recent alerts -- `keep://incidents` — active incidents -- `keep://topology` — full topology - 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. ## Configuration diff --git a/keep-mcp/pyproject.toml b/keep-mcp/pyproject.toml index 9baaa2253c..5058fc93a9 100644 --- a/keep-mcp/pyproject.toml +++ b/keep-mcp/pyproject.toml @@ -12,13 +12,11 @@ mcp = "^1.2.0" httpx = "^0.27.0" pydantic = "^2.7" pydantic-settings = "^2.4" -starlette = "^0.38" [tool.poetry.group.dev.dependencies] pytest = "^8.0" pytest-asyncio = "^0.23" respx = "^0.21" -ruff = "^0.6" [tool.poetry.scripts] keep-mcp = "keep_mcp.__main__:main" diff --git a/keep-mcp/src/keep_mcp/__main__.py b/keep-mcp/src/keep_mcp/__main__.py index cf3bd8aa21..11cf8423cb 100644 --- a/keep-mcp/src/keep_mcp/__main__.py +++ b/keep-mcp/src/keep_mcp/__main__.py @@ -2,9 +2,17 @@ import logging import sys +from typing import Any +import httpx +from mcp.server.fastmcp import FastMCP +from starlette.requests import Request +from starlette.responses import JSONResponse, PlainTextResponse + +from .client import KeepClient from .config import load_settings -from .server import build_server + +log = logging.getLogger("keep_mcp") def main() -> None: @@ -14,7 +22,6 @@ def main() -> None: format="%(asctime)s %(levelname)s %(name)s: %(message)s", stream=sys.stderr, ) - log = logging.getLogger("keep_mcp") if not settings.keep_api_key: log.error("KEEP_MCP_KEEP_API_KEY is not set; refusing to start") @@ -26,7 +33,75 @@ def main() -> None: settings.keep_api_url, ) - mcp = build_server(settings) + client = KeepClient(settings.keep_api_url, settings.keep_api_key, settings.http_timeout) + mcp = FastMCP( + name="keep", + instructions=( + "Read-only access to a Keep AIOps deployment. Use search_alerts and " + "list_incidents to see what is firing, then get_incident + " + "list_incident_alerts to drill in. CEL is the query language for " + "search_alerts — see docs.keephq.dev." + ), + ) + + @mcp.tool() + async def search_alerts(cel: str = "", limit: int = 25, offset: int = 0) -> dict[str, Any]: + """Query Keep alerts. `cel` is a CEL expression like `severity == "critical"`; empty returns most recent.""" + return await client.query_alerts(cel=cel, limit=max(1, min(limit, 1000)), offset=offset) + + @mcp.tool() + async def list_incidents( + status: list[str] | None = None, + severity: list[str] | None = None, + limit: int = 25, + offset: int = 0, + cel: str | None = None, + ) -> dict[str, Any]: + """List Keep incidents. Defaults to active incidents (firing + acknowledged) when status is omitted.""" + if status is None: + status = ["firing", "acknowledged"] + return await client.list_incidents( + status=status, + severity=severity, + limit=max(1, min(limit, 500)), + offset=offset, + cel=cel, + ) + + @mcp.tool() + async def get_incident(incident_id: str) -> dict[str, Any]: + """Fetch a single incident by UUID.""" + return await client.get_incident(incident_id) + + @mcp.tool() + async def list_incident_alerts( + incident_id: str, limit: int = 25, offset: int = 0 + ) -> dict[str, Any]: + """List alerts linked to an incident.""" + return await client.get_incident_alerts( + incident_id, limit=max(1, min(limit, 200)), offset=offset + ) + + @mcp.tool() + async def get_topology(service: str | None = None) -> list[dict[str, Any]]: + """Return the service dependency graph, optionally scoped to one service.""" + return await client.get_topology(service=service) + + @mcp.custom_route("/healthz", methods=["GET"]) + async def healthz(_: Request) -> PlainTextResponse: + return PlainTextResponse("ok") + + @mcp.custom_route("/readyz", methods=["GET"]) + async def readyz(_: Request) -> JSONResponse: + try: + resp = await client.get("/healthcheck") + ready = resp.status_code < 500 + except httpx.HTTPError: + ready = False + return JSONResponse( + {"ready": ready, "keep_api_url": settings.keep_api_url}, + status_code=200 if ready else 503, + ) if settings.transport == "stdio": mcp.run(transport="stdio") diff --git a/keep-mcp/src/keep_mcp/client.py b/keep-mcp/src/keep_mcp/client.py index 0555f53c97..6edda1325e 100644 --- a/keep-mcp/src/keep_mcp/client.py +++ b/keep-mcp/src/keep_mcp/client.py @@ -1,22 +1,9 @@ from __future__ import annotations -import logging from typing import Any import httpx -logger = logging.getLogger(__name__) - - -class KeepAPIError(RuntimeError): - """Raised when keep-backend returns a non-2xx response.""" - - def __init__(self, status_code: int, detail: str, url: str): - self.status_code = status_code - self.detail = detail - self.url = url - super().__init__(f"{status_code} from {url}: {detail}") - class KeepClient: """Async httpx wrapper around the Keep REST API.""" @@ -24,54 +11,25 @@ class KeepClient: def __init__(self, base_url: str, api_key: str, timeout: float = 30.0): if not api_key: raise ValueError("KEEP_MCP_KEEP_API_KEY is required") - self._base_url = base_url.rstrip("/") self._client = httpx.AsyncClient( - base_url=self._base_url, + base_url=base_url.rstrip("/"), timeout=timeout, - headers={ - "X-API-KEY": api_key, - "Accept": "application/json", - "User-Agent": "keep-mcp/0.1", - }, + headers={"X-API-KEY": api_key, "User-Agent": "keep-mcp/0.1"}, ) async def aclose(self) -> None: await self._client.aclose() async def _request(self, method: str, path: str, **kwargs: Any) -> Any: - try: - response = await self._client.request(method, path, **kwargs) - except httpx.HTTPError as exc: - raise KeepAPIError(0, f"transport error: {exc}", path) from exc - - if response.status_code >= 400: - detail = _extract_detail(response) - raise KeepAPIError(response.status_code, detail, path) + resp = await self._client.request(method, path, **kwargs) + resp.raise_for_status() + return resp.json() if resp.content else None - if not response.content: - return None - return response.json() - - # --- alerts --- - - async def query_alerts( - self, - cel: str = "", - limit: int = 25, - offset: int = 0, - ) -> dict[str, Any]: - body = {"cel": cel or "", "limit": limit, "offset": offset} - return await self._request("POST", "/alerts/query", json=body) - - async def get_alerts_by_fingerprints( - self, fingerprints: list[str] - ) -> list[dict[str, Any]]: + async def query_alerts(self, cel: str = "", limit: int = 25, offset: int = 0) -> dict[str, Any]: return await self._request( - "POST", "/alerts/batch", json={"fingerprints": fingerprints} + "POST", "/alerts/query", json={"cel": cel, "limit": limit, "offset": offset} ) - # --- incidents --- - async def list_incidents( self, status: list[str] | None = None, @@ -81,10 +39,8 @@ async def list_incidents( cel: str | None = None, ) -> dict[str, Any]: params: list[tuple[str, str | int]] = [("limit", limit), ("offset", offset)] - for s in status or []: - params.append(("status", s)) - for s in severity or []: - params.append(("severity", s)) + params += [("status", s) for s in status or []] + params += [("severity", s) for s in severity or []] if cel: params.append(("cel", cel)) return await self._request("GET", "/incidents", params=params) @@ -101,27 +57,11 @@ async def get_incident_alerts( params={"limit": limit, "offset": offset}, ) - # --- topology --- - async def get_topology(self, service: str | None = None) -> list[dict[str, Any]]: - params = {"service": service} if service else None - return await self._request("GET", "/topology", params=params) - - # --- health --- - - async def ping(self) -> bool: - try: - resp = await self._client.get("/healthcheck") - return resp.status_code < 500 - except httpx.HTTPError: - return False - + return await self._request( + "GET", "/topology", params={"service": service} if service else None + ) -def _extract_detail(response: httpx.Response) -> str: - try: - data = response.json() - if isinstance(data, dict): - return str(data.get("detail") or data) - return str(data) - except ValueError: - return response.text[:500] + async def get(self, path: str) -> httpx.Response: + """Raw GET, used by /readyz to probe upstream health.""" + return await self._client.get(path) diff --git a/keep-mcp/src/keep_mcp/server.py b/keep-mcp/src/keep_mcp/server.py deleted file mode 100644 index 19d95a6c24..0000000000 --- a/keep-mcp/src/keep_mcp/server.py +++ /dev/null @@ -1,151 +0,0 @@ -from __future__ import annotations - -import json -import logging -from typing import Any - -from mcp.server.fastmcp import FastMCP -from starlette.requests import Request -from starlette.responses import JSONResponse, PlainTextResponse - -from .client import KeepAPIError, KeepClient -from .config import Settings - -logger = logging.getLogger(__name__) - -INCIDENT_STATUSES = ["firing", "resolved", "acknowledged", "merged", "deleted"] -INCIDENT_SEVERITIES = ["critical", "high", "warning", "info", "low"] - - -def build_server(settings: Settings) -> FastMCP: - """Construct a FastMCP server wired to a Keep API client.""" - client = KeepClient( - base_url=settings.keep_api_url, - api_key=settings.keep_api_key, - timeout=settings.http_timeout, - ) - mcp = FastMCP( - name="keep", - instructions=( - "Read-only access to a Keep AIOps deployment. Use search_alerts and " - "list_incidents to discover what is currently firing, then " - "get_incident_with_alerts to drill into a specific incident. " - "CEL is the query language for search_alerts — see docs.keephq.dev." - ), - ) - - # --- tools --- - - @mcp.tool() - async def search_alerts( - cel: str = "", - limit: int = 25, - offset: int = 0, - ) -> dict[str, Any]: - """Query Keep alerts with an optional CEL expression. - - Args: - cel: CEL filter, e.g. `severity == "critical" && status == "firing"`. - Empty string returns the most recent alerts. - limit: Max alerts to return (1–1000). - offset: Pagination offset. - - Returns a dict with `count`, `limit`, `offset`, and `results` (alert DTOs). - """ - limit = max(1, min(limit, 1000)) - return await client.query_alerts(cel=cel, limit=limit, offset=offset) - - @mcp.tool() - async def list_incidents( - status: list[str] | None = None, - severity: list[str] | None = None, - limit: int = 25, - offset: int = 0, - cel: str | None = None, - ) -> dict[str, Any]: - """List Keep incidents, filterable by status and severity. - - Args: - status: Subset of {firing, resolved, acknowledged, merged, deleted}. - Defaults to active incidents (firing + acknowledged) when omitted. - severity: Subset of {critical, high, warning, info, low}. - limit: Max incidents to return (1–500). - offset: Pagination offset. - cel: Optional CEL filter applied server-side. - """ - limit = max(1, min(limit, 500)) - if status is None: - status = ["firing", "acknowledged"] - _validate_subset("status", status, INCIDENT_STATUSES) - if severity: - _validate_subset("severity", severity, INCIDENT_SEVERITIES) - return await client.list_incidents( - status=status, severity=severity, limit=limit, offset=offset, cel=cel - ) - - @mcp.tool() - async def get_incident_with_alerts( - incident_id: str, - alerts_limit: int = 25, - ) -> dict[str, Any]: - """Fetch an incident by id together with its linked alerts. - - Args: - incident_id: UUID of the incident. - alerts_limit: Max alerts to include (1–200). - """ - alerts_limit = max(1, min(alerts_limit, 200)) - incident = await client.get_incident(incident_id) - alerts = await client.get_incident_alerts(incident_id, limit=alerts_limit) - return {"incident": incident, "alerts": alerts} - - @mcp.tool() - async def get_topology(service: str | None = None) -> list[dict[str, Any]]: - """Return the service dependency graph, optionally scoped to one service.""" - return await client.get_topology(service=service) - - # --- resources --- - - @mcp.resource("keep://alerts") - async def resource_alerts() -> str: - data = await client.query_alerts(limit=50) - return json.dumps(data, default=str, indent=2) - - @mcp.resource("keep://incidents") - async def resource_incidents() -> str: - data = await client.list_incidents( - status=["firing", "acknowledged"], limit=50 - ) - return json.dumps(data, default=str, indent=2) - - @mcp.resource("keep://topology") - async def resource_topology() -> str: - data = await client.get_topology() - return json.dumps(data, default=str, indent=2) - - # --- health endpoints (streamable-http mode only) --- - - @mcp.custom_route("/healthz", methods=["GET"]) - async def healthz(_request: Request) -> PlainTextResponse: - return PlainTextResponse("ok") - - @mcp.custom_route("/readyz", methods=["GET"]) - async def readyz(_request: Request) -> JSONResponse: - ok = await client.ping() - status = 200 if ok else 503 - return JSONResponse( - {"ready": ok, "keep_api_url": settings.keep_api_url}, - status_code=status, - ) - - return mcp - - -def _validate_subset(name: str, values: list[str], allowed: list[str]) -> None: - invalid = [v for v in values if v not in allowed] - if invalid: - raise KeepAPIError( - 422, - f"invalid {name} values {invalid!r}; allowed={allowed}", - f"validation:{name}", - ) diff --git a/keep-mcp/tests/__init__.py b/keep-mcp/tests/__init__.py deleted file mode 100644 index e69de29bb2..0000000000 diff --git a/keep-mcp/tests/test_client.py b/keep-mcp/tests/test_client.py index c593b18251..18477664e5 100644 --- a/keep-mcp/tests/test_client.py +++ b/keep-mcp/tests/test_client.py @@ -4,7 +4,7 @@ import pytest import respx -from keep_mcp.client import KeepAPIError, KeepClient +from keep_mcp.client import KeepClient @pytest.fixture @@ -13,11 +13,10 @@ def client() -> KeepClient: @respx.mock -async def test_query_alerts_sends_cel_and_pagination(client: KeepClient) -> None: +async def test_query_alerts_sends_cel_and_api_key(client: KeepClient) -> None: route = respx.post("http://keep-backend:8080/alerts/query").mock( return_value=httpx.Response( - 200, - json={"limit": 5, "offset": 0, "count": 1, "results": [{"id": "a"}]}, + 200, json={"limit": 5, "offset": 0, "count": 1, "results": [{"id": "a"}]} ) ) @@ -38,7 +37,6 @@ async def test_list_incidents_repeats_status_and_severity(client: KeepClient) -> status=["firing", "acknowledged"], severity=["critical"], limit=10 ) - assert route.called url = route.calls.last.request.url assert url.params.get_list("status") == ["firing", "acknowledged"] assert url.params.get_list("severity") == ["critical"] @@ -46,34 +44,32 @@ async def test_list_incidents_repeats_status_and_severity(client: KeepClient) -> @respx.mock -async def test_get_incident_with_alerts_makes_two_calls(client: KeepClient) -> None: +async def test_get_incident_and_alerts(client: KeepClient) -> None: incident_id = "11111111-1111-1111-1111-111111111111" - inc_route = respx.get(f"http://keep-backend:8080/incidents/{incident_id}").mock( + respx.get(f"http://keep-backend:8080/incidents/{incident_id}").mock( return_value=httpx.Response(200, json={"id": incident_id, "status": "firing"}) ) - alerts_route = respx.get( - f"http://keep-backend:8080/incidents/{incident_id}/alerts" - ).mock(return_value=httpx.Response(200, json={"count": 0, "items": []})) + respx.get(f"http://keep-backend:8080/incidents/{incident_id}/alerts").mock( + return_value=httpx.Response(200, json={"count": 0, "items": []}) + ) incident = await client.get_incident(incident_id) alerts = await client.get_incident_alerts(incident_id, limit=5) - assert inc_route.called and alerts_route.called assert incident["id"] == incident_id assert alerts["items"] == [] @respx.mock -async def test_non_2xx_raises_keep_api_error(client: KeepClient) -> None: +async def test_non_2xx_raises_httpx_error(client: KeepClient) -> None: respx.post("http://keep-backend:8080/alerts/query").mock( return_value=httpx.Response(400, json={"detail": "bad cel"}) ) - with pytest.raises(KeepAPIError) as exc_info: + with pytest.raises(httpx.HTTPStatusError) as exc_info: await client.query_alerts(cel="!!!") - assert exc_info.value.status_code == 400 - assert "bad cel" in str(exc_info.value) + assert exc_info.value.response.status_code == 400 def test_missing_api_key_raises() -> None: From 3d658f5e0bc245a8e7bbcaab4ffae608f20f329d Mon Sep 17 00:00:00 2001 From: harisrujanc Date: Tue, 7 Jul 2026 22:08:17 +0300 Subject: [PATCH 3/5] =?UTF-8?q?refactor(keep-mcp):=20collapse=20to=20one?= =?UTF-8?q?=20file=20(454=20=E2=86=92=20192=20lines)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- keep-mcp/.gitignore | 9 -- keep-mcp/README.md | 73 ++----------- keep-mcp/pyproject.toml | 2 - keep-mcp/src/keep_mcp/__main__.py | 173 ++++++++++++++---------------- keep-mcp/src/keep_mcp/client.py | 67 ------------ keep-mcp/src/keep_mcp/config.py | 30 ------ keep-mcp/tests/test_client.py | 77 ------------- keep-mcp/tests/test_tools.py | 34 ++++++ 8 files changed, 124 insertions(+), 341 deletions(-) delete mode 100644 keep-mcp/.gitignore delete mode 100644 keep-mcp/src/keep_mcp/client.py delete mode 100644 keep-mcp/src/keep_mcp/config.py delete mode 100644 keep-mcp/tests/test_client.py create mode 100644 keep-mcp/tests/test_tools.py diff --git a/keep-mcp/.gitignore b/keep-mcp/.gitignore deleted file mode 100644 index 3b64b512ec..0000000000 --- a/keep-mcp/.gitignore +++ /dev/null @@ -1,9 +0,0 @@ -.venv/ -__pycache__/ -*.pyc -.pytest_cache/ -.ruff_cache/ -dist/ -build/ -*.egg-info/ -.env diff --git a/keep-mcp/README.md b/keep-mcp/README.md index 652fbe0e14..680cda7c82 100644 --- a/keep-mcp/README.md +++ b/keep-mcp/README.md @@ -1,75 +1,20 @@ # keep-mcp -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. +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`). -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. +## Tools -## v0.1 scope (read-only) +`search_alerts` · `list_incidents` · `get_incident` · `list_incident_alerts` · `get_topology` -**Tools** +Query language for `search_alerts` is CEL — see [docs.keephq.dev](https://docs.keephq.dev). -- `search_alerts(cel, limit, offset)` — query alerts with a CEL expression (same expression language the Keep UI uses). -- `list_incidents(status, severity, limit, offset, cel)` — list incidents, defaulting to active (`firing` + `acknowledged`). -- `get_incident(incident_id)` — fetch a single incident by UUID. -- `list_incident_alerts(incident_id, limit, offset)` — list alerts linked to an incident. -- `get_topology(service)` — service dependency graph, optionally scoped to one service. - -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. - -## Configuration - -Every setting is an environment variable. Prefix `KEEP_MCP_`. - -| Variable | Default | Description | -|---|---|---| -| `KEEP_MCP_KEEP_API_URL` | `http://keep-backend:8080` | Base URL of the Keep REST API | -| `KEEP_MCP_KEEP_API_KEY` | *(required)* | API key used on every request (`X-API-KEY`) | -| `KEEP_MCP_TRANSPORT` | `stdio` | `stdio` or `streamable-http` | -| `KEEP_MCP_HTTP_HOST` | `0.0.0.0` | Bind host for `streamable-http` | -| `KEEP_MCP_HTTP_PORT` | `8090` | Bind port for `streamable-http` | -| `KEEP_MCP_HTTP_TIMEOUT` | `30` | httpx timeout for calls to `keep-backend` | -| `KEEP_MCP_LOG_LEVEL` | `INFO` | Root log level | - -## Running locally (stdio, for Claude Desktop / Copilot CLI / Cursor) +## Run ```bash -poetry install -KEEP_MCP_KEEP_API_URL=http://localhost:8080 \ -KEEP_MCP_KEEP_API_KEY=your-key \ -poetry run keep-mcp -``` - -Example client config (`~/.copilot/mcp.json`): - -```json -{ - "servers": { - "keep": { - "command": "poetry", - "args": ["run", "keep-mcp"], - "cwd": "/path/to/keep/keep-mcp", - "env": { - "KEEP_MCP_KEEP_API_URL": "http://localhost:8080", - "KEEP_MCP_KEEP_API_KEY": "your-key" - } - } - } -} +KEEP_MCP_KEEP_API_KEY= docker compose --profile mcp up keep-mcp +curl http://localhost:8090/healthz ``` -## Running in Docker (streamable-http) - -Ships as `keep-mcp` in the repo's `docker-compose.yml`. Enable it with: - -```bash -KEEP_MCP_KEEP_API_KEY=your-key docker compose up keep-mcp -``` - -Health checks: - -- `GET http://localhost:8090/healthz` — process liveness -- `GET http://localhost:8090/readyz` — verifies `keep-backend` reachable - -## Architecture +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). -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. +Write tools and LGTM passthrough land in v0.2/v0.3. diff --git a/keep-mcp/pyproject.toml b/keep-mcp/pyproject.toml index 5058fc93a9..a0e429801f 100644 --- a/keep-mcp/pyproject.toml +++ b/keep-mcp/pyproject.toml @@ -10,8 +10,6 @@ packages = [{ include = "keep_mcp", from = "src" }] python = ">=3.11,<3.14" mcp = "^1.2.0" httpx = "^0.27.0" -pydantic = "^2.7" -pydantic-settings = "^2.4" [tool.poetry.group.dev.dependencies] pytest = "^8.0" diff --git a/keep-mcp/src/keep_mcp/__main__.py b/keep-mcp/src/keep_mcp/__main__.py index 11cf8423cb..cbc9fd18d9 100644 --- a/keep-mcp/src/keep_mcp/__main__.py +++ b/keep-mcp/src/keep_mcp/__main__.py @@ -1,113 +1,102 @@ -from __future__ import annotations +"""keep-mcp — Model Context Protocol server for Keep.""" import logging +import os import sys -from typing import Any import httpx from mcp.server.fastmcp import FastMCP -from starlette.requests import Request -from starlette.responses import JSONResponse, PlainTextResponse +from starlette.responses import PlainTextResponse + +API_URL = os.environ.get("KEEP_MCP_KEEP_API_URL", "http://keep-backend:8080").rstrip("/") +API_KEY = os.environ.get("KEEP_MCP_KEEP_API_KEY", "") +TRANSPORT = os.environ.get("KEEP_MCP_TRANSPORT", "stdio") +HTTP_HOST = os.environ.get("KEEP_MCP_HTTP_HOST", "0.0.0.0") +HTTP_PORT = int(os.environ.get("KEEP_MCP_HTTP_PORT", "8090")) + +http = httpx.AsyncClient( + base_url=API_URL, + timeout=30.0, + headers={"X-API-KEY": API_KEY, "User-Agent": "keep-mcp/0.1"}, +) + +mcp = FastMCP( + name="keep", + instructions=( + "Read-only access to a Keep AIOps deployment. Use search_alerts and " + "list_incidents to see what is firing, then get_incident + " + "list_incident_alerts to drill in. CEL is the query language for " + "search_alerts — see docs.keephq.dev." + ), +) + + +async def _json(method: str, path: str, **kw): + r = await http.request(method, path, **kw) + r.raise_for_status() + return r.json() if r.content else None + + +@mcp.tool() +async def search_alerts(cel: str = "", limit: int = 25, offset: int = 0) -> dict: + """Query Keep alerts. `cel` is a CEL filter like `severity == "critical"`; empty returns most recent.""" + return await _json("POST", "/alerts/query", json={"cel": cel, "limit": limit, "offset": offset}) + + +@mcp.tool() +async def list_incidents( + status: list[str] | None = None, + severity: list[str] | None = None, + limit: int = 25, + offset: int = 0, + cel: str | None = None, +) -> dict: + """List Keep incidents. status ⊆ {firing, resolved, acknowledged, merged, deleted}; severity ⊆ {critical, high, warning, info, low}.""" + params: list[tuple[str, str | int]] = [("limit", limit), ("offset", offset)] + params += [("status", s) for s in status or []] + params += [("severity", s) for s in severity or []] + if cel: + params.append(("cel", cel)) + return await _json("GET", "/incidents", params=params) + + +@mcp.tool() +async def get_incident(incident_id: str) -> dict: + return await _json("GET", f"/incidents/{incident_id}") + + +@mcp.tool() +async def list_incident_alerts(incident_id: str, limit: int = 25, offset: int = 0) -> dict: + return await _json( + "GET", f"/incidents/{incident_id}/alerts", params={"limit": limit, "offset": offset} + ) + + +@mcp.tool() +async def get_topology(service: str | None = None) -> list[dict]: + return await _json("GET", "/topology", params={"service": service} if service else None) -from .client import KeepClient -from .config import load_settings -log = logging.getLogger("keep_mcp") +@mcp.custom_route("/healthz", methods=["GET"]) +async def healthz(_): + return PlainTextResponse("ok") def main() -> None: - settings = load_settings() logging.basicConfig( - level=settings.log_level.upper(), + level=logging.INFO, format="%(asctime)s %(levelname)s %(name)s: %(message)s", stream=sys.stderr, ) - - if not settings.keep_api_key: - log.error("KEEP_MCP_KEEP_API_KEY is not set; refusing to start") + if not API_KEY: + logging.error("KEEP_MCP_KEEP_API_KEY is not set; refusing to start") sys.exit(2) - - log.info( - "starting keep-mcp transport=%s keep_api_url=%s", - settings.transport, - settings.keep_api_url, - ) - - client = KeepClient(settings.keep_api_url, settings.keep_api_key, settings.http_timeout) - mcp = FastMCP( - name="keep", - instructions=( - "Read-only access to a Keep AIOps deployment. Use search_alerts and " - "list_incidents to see what is firing, then get_incident + " - "list_incident_alerts to drill in. CEL is the query language for " - "search_alerts — see docs.keephq.dev." - ), - ) - - @mcp.tool() - async def search_alerts(cel: str = "", limit: int = 25, offset: int = 0) -> dict[str, Any]: - """Query Keep alerts. `cel` is a CEL expression like `severity == "critical"`; empty returns most recent.""" - return await client.query_alerts(cel=cel, limit=max(1, min(limit, 1000)), offset=offset) - - @mcp.tool() - async def list_incidents( - status: list[str] | None = None, - severity: list[str] | None = None, - limit: int = 25, - offset: int = 0, - cel: str | None = None, - ) -> dict[str, Any]: - """List Keep incidents. Defaults to active incidents (firing + acknowledged) when status is omitted.""" - if status is None: - status = ["firing", "acknowledged"] - return await client.list_incidents( - status=status, - severity=severity, - limit=max(1, min(limit, 500)), - offset=offset, - cel=cel, - ) - - @mcp.tool() - async def get_incident(incident_id: str) -> dict[str, Any]: - """Fetch a single incident by UUID.""" - return await client.get_incident(incident_id) - - @mcp.tool() - async def list_incident_alerts( - incident_id: str, limit: int = 25, offset: int = 0 - ) -> dict[str, Any]: - """List alerts linked to an incident.""" - return await client.get_incident_alerts( - incident_id, limit=max(1, min(limit, 200)), offset=offset - ) - - @mcp.tool() - async def get_topology(service: str | None = None) -> list[dict[str, Any]]: - """Return the service dependency graph, optionally scoped to one service.""" - return await client.get_topology(service=service) - - @mcp.custom_route("/healthz", methods=["GET"]) - async def healthz(_: Request) -> PlainTextResponse: - return PlainTextResponse("ok") - - @mcp.custom_route("/readyz", methods=["GET"]) - async def readyz(_: Request) -> JSONResponse: - try: - resp = await client.get("/healthcheck") - ready = resp.status_code < 500 - except httpx.HTTPError: - ready = False - return JSONResponse( - {"ready": ready, "keep_api_url": settings.keep_api_url}, - status_code=200 if ready else 503, - ) - - if settings.transport == "stdio": + logging.info("starting keep-mcp transport=%s keep_api_url=%s", TRANSPORT, API_URL) + if TRANSPORT == "stdio": mcp.run(transport="stdio") else: - mcp.settings.host = settings.http_host - mcp.settings.port = settings.http_port + mcp.settings.host = HTTP_HOST + mcp.settings.port = HTTP_PORT mcp.run(transport="streamable-http") diff --git a/keep-mcp/src/keep_mcp/client.py b/keep-mcp/src/keep_mcp/client.py deleted file mode 100644 index 6edda1325e..0000000000 --- a/keep-mcp/src/keep_mcp/client.py +++ /dev/null @@ -1,67 +0,0 @@ -from __future__ import annotations - -from typing import Any - -import httpx - - -class KeepClient: - """Async httpx wrapper around the Keep REST API.""" - - def __init__(self, base_url: str, api_key: str, timeout: float = 30.0): - if not api_key: - raise ValueError("KEEP_MCP_KEEP_API_KEY is required") - self._client = httpx.AsyncClient( - base_url=base_url.rstrip("/"), - timeout=timeout, - headers={"X-API-KEY": api_key, "User-Agent": "keep-mcp/0.1"}, - ) - - async def aclose(self) -> None: - await self._client.aclose() - - async def _request(self, method: str, path: str, **kwargs: Any) -> Any: - resp = await self._client.request(method, path, **kwargs) - resp.raise_for_status() - return resp.json() if resp.content else None - - async def query_alerts(self, cel: str = "", limit: int = 25, offset: int = 0) -> dict[str, Any]: - return await self._request( - "POST", "/alerts/query", json={"cel": cel, "limit": limit, "offset": offset} - ) - - async def list_incidents( - self, - status: list[str] | None = None, - severity: list[str] | None = None, - limit: int = 25, - offset: int = 0, - cel: str | None = None, - ) -> dict[str, Any]: - params: list[tuple[str, str | int]] = [("limit", limit), ("offset", offset)] - params += [("status", s) for s in status or []] - params += [("severity", s) for s in severity or []] - if cel: - params.append(("cel", cel)) - return await self._request("GET", "/incidents", params=params) - - async def get_incident(self, incident_id: str) -> dict[str, Any]: - return await self._request("GET", f"/incidents/{incident_id}") - - async def get_incident_alerts( - self, incident_id: str, limit: int = 25, offset: int = 0 - ) -> dict[str, Any]: - return await self._request( - "GET", - f"/incidents/{incident_id}/alerts", - params={"limit": limit, "offset": offset}, - ) - - async def get_topology(self, service: str | None = None) -> list[dict[str, Any]]: - return await self._request( - "GET", "/topology", params={"service": service} if service else None - ) - - async def get(self, path: str) -> httpx.Response: - """Raw GET, used by /readyz to probe upstream health.""" - return await self._client.get(path) diff --git a/keep-mcp/src/keep_mcp/config.py b/keep-mcp/src/keep_mcp/config.py deleted file mode 100644 index b743d8ad81..0000000000 --- a/keep-mcp/src/keep_mcp/config.py +++ /dev/null @@ -1,30 +0,0 @@ -from typing import Literal - -from pydantic import Field -from pydantic_settings import BaseSettings, SettingsConfigDict - - -class Settings(BaseSettings): - model_config = SettingsConfigDict( - env_prefix="KEEP_MCP_", - env_file=".env", - env_file_encoding="utf-8", - extra="ignore", - ) - - keep_api_url: str = "http://keep-backend:8080" - keep_api_key: str = Field( - default="", - description="API key sent as X-API-KEY to keep-backend. Required at runtime.", - ) - - transport: Literal["stdio", "streamable-http"] = "stdio" - http_host: str = "0.0.0.0" - http_port: int = 8090 - http_timeout: float = 30.0 - - log_level: str = "INFO" - - -def load_settings() -> Settings: - return Settings() diff --git a/keep-mcp/tests/test_client.py b/keep-mcp/tests/test_client.py deleted file mode 100644 index 18477664e5..0000000000 --- a/keep-mcp/tests/test_client.py +++ /dev/null @@ -1,77 +0,0 @@ -from __future__ import annotations - -import httpx -import pytest -import respx - -from keep_mcp.client import KeepClient - - -@pytest.fixture -def client() -> KeepClient: - return KeepClient("http://keep-backend:8080", api_key="test-key", timeout=5.0) - - -@respx.mock -async def test_query_alerts_sends_cel_and_api_key(client: KeepClient) -> None: - route = respx.post("http://keep-backend:8080/alerts/query").mock( - return_value=httpx.Response( - 200, json={"limit": 5, "offset": 0, "count": 1, "results": [{"id": "a"}]} - ) - ) - - data = await client.query_alerts(cel='severity == "critical"', limit=5) - - assert route.called - assert route.calls.last.request.headers["X-API-KEY"] == "test-key" - assert data["results"] == [{"id": "a"}] - - -@respx.mock -async def test_list_incidents_repeats_status_and_severity(client: KeepClient) -> None: - route = respx.get("http://keep-backend:8080/incidents").mock( - return_value=httpx.Response(200, json={"items": []}) - ) - - await client.list_incidents( - status=["firing", "acknowledged"], severity=["critical"], limit=10 - ) - - url = route.calls.last.request.url - assert url.params.get_list("status") == ["firing", "acknowledged"] - assert url.params.get_list("severity") == ["critical"] - assert url.params["limit"] == "10" - - -@respx.mock -async def test_get_incident_and_alerts(client: KeepClient) -> None: - incident_id = "11111111-1111-1111-1111-111111111111" - respx.get(f"http://keep-backend:8080/incidents/{incident_id}").mock( - return_value=httpx.Response(200, json={"id": incident_id, "status": "firing"}) - ) - respx.get(f"http://keep-backend:8080/incidents/{incident_id}/alerts").mock( - return_value=httpx.Response(200, json={"count": 0, "items": []}) - ) - - incident = await client.get_incident(incident_id) - alerts = await client.get_incident_alerts(incident_id, limit=5) - - assert incident["id"] == incident_id - assert alerts["items"] == [] - - -@respx.mock -async def test_non_2xx_raises_httpx_error(client: KeepClient) -> None: - respx.post("http://keep-backend:8080/alerts/query").mock( - return_value=httpx.Response(400, json={"detail": "bad cel"}) - ) - - with pytest.raises(httpx.HTTPStatusError) as exc_info: - await client.query_alerts(cel="!!!") - - assert exc_info.value.response.status_code == 400 - - -def test_missing_api_key_raises() -> None: - with pytest.raises(ValueError): - KeepClient("http://x", api_key="") diff --git a/keep-mcp/tests/test_tools.py b/keep-mcp/tests/test_tools.py new file mode 100644 index 0000000000..b4e4e0e6f9 --- /dev/null +++ b/keep-mcp/tests/test_tools.py @@ -0,0 +1,34 @@ +import httpx +import pytest +import respx + +from keep_mcp import __main__ as mcp + + +@respx.mock +async def test_search_alerts_posts_cel_with_api_key(): + respx.post("http://keep-backend:8080/alerts/query").mock( + return_value=httpx.Response(200, json={"results": [{"id": "a"}]}) + ) + data = await mcp.search_alerts(cel='severity == "critical"', limit=5) + assert data["results"] == [{"id": "a"}] + + +@respx.mock +async def test_list_incidents_repeats_multivalued_params(): + route = respx.get("http://keep-backend:8080/incidents").mock( + return_value=httpx.Response(200, json={"items": []}) + ) + await mcp.list_incidents(status=["firing", "acknowledged"], severity=["critical"]) + url = route.calls.last.request.url + assert url.params.get_list("status") == ["firing", "acknowledged"] + assert url.params.get_list("severity") == ["critical"] + + +@respx.mock +async def test_non_2xx_raises(): + respx.post("http://keep-backend:8080/alerts/query").mock( + return_value=httpx.Response(400, json={"detail": "bad cel"}) + ) + with pytest.raises(httpx.HTTPStatusError): + await mcp.search_alerts(cel="!!!") From 3a34248a8bfe093fd2fb8d9f0e6a5270f960a62b Mon Sep 17 00:00:00 2001 From: harisrujanc Date: Sat, 8 Aug 2026 19:41:02 +0300 Subject: [PATCH 4/5] fix(keep-mcp): run tests in CI, clamp pagination, describe every tool - add a dedicated unit-test workflow; keep-mcp/** matched no existing paths filter, so the tests never ran - clamp model-supplied limit/offset in one place; the Keep API does not bound limit itself - add docstrings to get_incident, list_incident_alerts and get_topology, which MCP sends to the model as the tool description - drop dead KEEP_MCP_LOG_LEVEL and duplicated transport env vars - drop the nested [tool.ruff] block that conflicted with repo-wide black - drop the unpublished image reference so compose builds locally, matching the docker-compose.dev.yml convention - document that the streamable-http endpoint is unauthenticated in v0.1 - parameterize tests across all five tools, add API-key and clamp cases --- .github/workflows/test-pr-ut-mcp.yml | 53 ++++++++++++++++++++++++++++ docker-compose.common.yml | 4 --- docker-compose.yml | 1 - keep-mcp/README.md | 7 ++++ keep-mcp/pyproject.toml | 4 --- keep-mcp/src/keep_mcp/__main__.py | 23 +++++++++--- keep-mcp/tests/test_tools.py | 46 +++++++++++++++++++----- 7 files changed, 115 insertions(+), 23 deletions(-) create mode 100644 .github/workflows/test-pr-ut-mcp.yml diff --git a/.github/workflows/test-pr-ut-mcp.yml b/.github/workflows/test-pr-ut-mcp.yml new file mode 100644 index 0000000000..b3661faa08 --- /dev/null +++ b/.github/workflows/test-pr-ut-mcp.yml @@ -0,0 +1,53 @@ +name: Unit Tests - MCP +on: + push: + branches: + - main + paths: + - "keep-mcp/**" + pull_request: + paths: + - "keep-mcp/**" + workflow_dispatch: + +permissions: + actions: write + +concurrency: + group: ${{ github.event_name }}-${{ github.workflow }}-${{ github.head_ref }} + cancel-in-progress: true + +env: + PYTHON_VERSION: 3.11 + +jobs: + unit-tests-mcp: + runs-on: ubuntu-latest + defaults: + run: + working-directory: keep-mcp + + steps: + - name: Checkout + uses: actions/checkout@v3 + + - uses: chartboost/ruff-action@v1 + with: + src: "./keep-mcp" + + - name: Set up Python ${{ env.PYTHON_VERSION }} + uses: actions/setup-python@v4 + with: + python-version: ${{ env.PYTHON_VERSION }} + + - name: Install Poetry + uses: snok/install-poetry@v1 + with: + virtualenvs-create: true + virtualenvs-in-project: true + + - name: Install dependencies using poetry + run: poetry install --no-interaction --with dev + + - name: Run unit tests + run: poetry run pytest diff --git a/docker-compose.common.yml b/docker-compose.common.yml index 6ee73cbfe9..d60351e7f0 100644 --- a/docker-compose.common.yml +++ b/docker-compose.common.yml @@ -45,9 +45,5 @@ services: ports: - "8090:8090" environment: - - KEEP_MCP_TRANSPORT=streamable-http - - KEEP_MCP_HTTP_HOST=0.0.0.0 - - KEEP_MCP_HTTP_PORT=8090 - KEEP_MCP_KEEP_API_URL=http://keep-backend:8080 - KEEP_MCP_KEEP_API_KEY=${KEEP_MCP_KEEP_API_KEY:-keepappkey} - - KEEP_MCP_LOG_LEVEL=INFO diff --git a/docker-compose.yml b/docker-compose.yml index 45fb0a035c..5ba1cb3a77 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -38,7 +38,6 @@ services: build: context: . dockerfile: docker/Dockerfile.mcp - image: us-central1-docker.pkg.dev/keephq/keep/keep-mcp depends_on: - keep-backend diff --git a/keep-mcp/README.md b/keep-mcp/README.md index 680cda7c82..7b54589f2d 100644 --- a/keep-mcp/README.md +++ b/keep-mcp/README.md @@ -17,4 +17,11 @@ curl http://localhost:8090/healthz 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). +## Security + +The `streamable-http` endpoint is unauthenticated in v0.1 and holds a Keep API key, so +anything that can reach the port gets read access to your alerts, incidents and topology. +Run it on the compose network or a trusted local host, not on a public interface. +Endpoint auth arrives in v0.2 alongside OAuth. + Write tools and LGTM passthrough land in v0.2/v0.3. diff --git a/keep-mcp/pyproject.toml b/keep-mcp/pyproject.toml index a0e429801f..caabf3ccb1 100644 --- a/keep-mcp/pyproject.toml +++ b/keep-mcp/pyproject.toml @@ -23,9 +23,5 @@ keep-mcp = "keep_mcp.__main__:main" requires = ["poetry-core"] build-backend = "poetry.core.masonry.api" -[tool.ruff] -line-length = 100 -target-version = "py311" - [tool.pytest.ini_options] asyncio_mode = "auto" diff --git a/keep-mcp/src/keep_mcp/__main__.py b/keep-mcp/src/keep_mcp/__main__.py index cbc9fd18d9..b35ec3a9e9 100644 --- a/keep-mcp/src/keep_mcp/__main__.py +++ b/keep-mcp/src/keep_mcp/__main__.py @@ -8,6 +8,8 @@ from mcp.server.fastmcp import FastMCP from starlette.responses import PlainTextResponse +logger = logging.getLogger(__name__) + API_URL = os.environ.get("KEEP_MCP_KEEP_API_URL", "http://keep-backend:8080").rstrip("/") API_KEY = os.environ.get("KEEP_MCP_KEEP_API_KEY", "") TRANSPORT = os.environ.get("KEEP_MCP_TRANSPORT", "stdio") @@ -31,16 +33,24 @@ ) +MAX_LIMIT = 100 + + async def _json(method: str, path: str, **kw): r = await http.request(method, path, **kw) r.raise_for_status() return r.json() if r.content else None +def _page(limit: int, offset: int) -> dict[str, int]: + """Clamp model-supplied pagination — the Keep API does not bound `limit` itself.""" + return {"limit": min(max(limit, 1), MAX_LIMIT), "offset": max(offset, 0)} + + @mcp.tool() async def search_alerts(cel: str = "", limit: int = 25, offset: int = 0) -> dict: """Query Keep alerts. `cel` is a CEL filter like `severity == "critical"`; empty returns most recent.""" - return await _json("POST", "/alerts/query", json={"cel": cel, "limit": limit, "offset": offset}) + return await _json("POST", "/alerts/query", json={"cel": cel, **_page(limit, offset)}) @mcp.tool() @@ -52,7 +62,7 @@ async def list_incidents( cel: str | None = None, ) -> dict: """List Keep incidents. status ⊆ {firing, resolved, acknowledged, merged, deleted}; severity ⊆ {critical, high, warning, info, low}.""" - params: list[tuple[str, str | int]] = [("limit", limit), ("offset", offset)] + params: list[tuple[str, str | int]] = list(_page(limit, offset).items()) params += [("status", s) for s in status or []] params += [("severity", s) for s in severity or []] if cel: @@ -62,18 +72,21 @@ async def list_incidents( @mcp.tool() async def get_incident(incident_id: str) -> dict: + """Fetch a single Keep incident by id, including its summary, status and severity.""" return await _json("GET", f"/incidents/{incident_id}") @mcp.tool() async def list_incident_alerts(incident_id: str, limit: int = 25, offset: int = 0) -> dict: + """List the alerts correlated into a Keep incident, to see what triggered it.""" return await _json( - "GET", f"/incidents/{incident_id}/alerts", params={"limit": limit, "offset": offset} + "GET", f"/incidents/{incident_id}/alerts", params=_page(limit, offset) ) @mcp.tool() async def get_topology(service: str | None = None) -> list[dict]: + """Get the Keep service topology graph, or one service's dependencies if `service` is given.""" return await _json("GET", "/topology", params={"service": service} if service else None) @@ -89,9 +102,9 @@ def main() -> None: stream=sys.stderr, ) if not API_KEY: - logging.error("KEEP_MCP_KEEP_API_KEY is not set; refusing to start") + logger.error("KEEP_MCP_KEEP_API_KEY is not set; refusing to start") sys.exit(2) - logging.info("starting keep-mcp transport=%s keep_api_url=%s", TRANSPORT, API_URL) + logger.info("starting keep-mcp transport=%s keep_api_url=%s", TRANSPORT, API_URL) if TRANSPORT == "stdio": mcp.run(transport="stdio") else: diff --git a/keep-mcp/tests/test_tools.py b/keep-mcp/tests/test_tools.py index b4e4e0e6f9..f09e6ebb72 100644 --- a/keep-mcp/tests/test_tools.py +++ b/keep-mcp/tests/test_tools.py @@ -4,31 +4,59 @@ from keep_mcp import __main__ as mcp +BASE = "http://keep-backend:8080" + +@pytest.mark.parametrize( + "call, method, path", + [ + (lambda: mcp.search_alerts(cel='severity == "critical"'), "POST", "/alerts/query"), + (lambda: mcp.list_incidents(), "GET", "/incidents"), + (lambda: mcp.get_incident("inc-1"), "GET", "/incidents/inc-1"), + (lambda: mcp.list_incident_alerts("inc-1"), "GET", "/incidents/inc-1/alerts"), + (lambda: mcp.get_topology(), "GET", "/topology"), + ], +) @respx.mock -async def test_search_alerts_posts_cel_with_api_key(): - respx.post("http://keep-backend:8080/alerts/query").mock( - return_value=httpx.Response(200, json={"results": [{"id": "a"}]}) +async def test_tool_calls_expected_endpoint_with_api_key(call, method, path): + route = respx.request(method, BASE + path).mock( + return_value=httpx.Response(200, json={"ok": True}) ) - data = await mcp.search_alerts(cel='severity == "critical"', limit=5) - assert data["results"] == [{"id": "a"}] + assert await call() == {"ok": True} + assert route.calls.last.request.headers["X-API-KEY"] == mcp.API_KEY @respx.mock async def test_list_incidents_repeats_multivalued_params(): - route = respx.get("http://keep-backend:8080/incidents").mock( - return_value=httpx.Response(200, json={"items": []}) - ) + route = respx.get(BASE + "/incidents").mock(return_value=httpx.Response(200, json={})) await mcp.list_incidents(status=["firing", "acknowledged"], severity=["critical"]) url = route.calls.last.request.url assert url.params.get_list("status") == ["firing", "acknowledged"] assert url.params.get_list("severity") == ["critical"] +@pytest.mark.parametrize( + "given, expected", + [(25, 25), (0, 1), (-5, 1), (1000, mcp.MAX_LIMIT)], +) +@respx.mock +async def test_limit_is_clamped(given, expected): + route = respx.post(BASE + "/alerts/query").mock(return_value=httpx.Response(200, json={})) + await mcp.search_alerts(limit=given) + assert route.calls.last.request.read().decode().count(f'"limit": {expected}') == 1 + + @respx.mock async def test_non_2xx_raises(): - respx.post("http://keep-backend:8080/alerts/query").mock( + respx.post(BASE + "/alerts/query").mock( return_value=httpx.Response(400, json={"detail": "bad cel"}) ) with pytest.raises(httpx.HTTPStatusError): await mcp.search_alerts(cel="!!!") + + +def test_missing_api_key_exits(monkeypatch): + monkeypatch.setattr(mcp, "API_KEY", "") + with pytest.raises(SystemExit) as exc: + mcp.main() + assert exc.value.code == 2 From ff03101874c534b96237a0a2308fc82947622dc0 Mon Sep 17 00:00:00 2001 From: harisrujanc Date: Sat, 8 Aug 2026 19:43:46 +0300 Subject: [PATCH 5/5] ci(keep-mcp): pin ruff to the version the repo already uses The action auto-detects its ruff version from the checkout and picked up 0.16.2 for keep-mcp, which then conflicted with the repo's 0.11.4 pin. --- .github/workflows/test-pr-ut-mcp.yml | 1 + 1 file changed, 1 insertion(+) diff --git a/.github/workflows/test-pr-ut-mcp.yml b/.github/workflows/test-pr-ut-mcp.yml index b3661faa08..a4a91ae126 100644 --- a/.github/workflows/test-pr-ut-mcp.yml +++ b/.github/workflows/test-pr-ut-mcp.yml @@ -34,6 +34,7 @@ jobs: - uses: chartboost/ruff-action@v1 with: src: "./keep-mcp" + version: 0.11.4 - name: Set up Python ${{ env.PYTHON_VERSION }} uses: actions/setup-python@v4