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/README.md b/keep-mcp/README.md new file mode 100644 index 0000000000..680cda7c82 --- /dev/null +++ b/keep-mcp/README.md @@ -0,0 +1,20 @@ +# keep-mcp + +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`). + +## Tools + +`search_alerts` · `list_incidents` · `get_incident` · `list_incident_alerts` · `get_topology` + +Query language for `search_alerts` is CEL — see [docs.keephq.dev](https://docs.keephq.dev). + +## Run + +```bash +KEEP_MCP_KEEP_API_KEY= docker compose --profile mcp up keep-mcp +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). + +Write tools and LGTM passthrough land in v0.2/v0.3. diff --git a/keep-mcp/pyproject.toml b/keep-mcp/pyproject.toml new file mode 100644 index 0000000000..a0e429801f --- /dev/null +++ b/keep-mcp/pyproject.toml @@ -0,0 +1,31 @@ +[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" + +[tool.poetry.group.dev.dependencies] +pytest = "^8.0" +pytest-asyncio = "^0.23" +respx = "^0.21" + +[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..cbc9fd18d9 --- /dev/null +++ b/keep-mcp/src/keep_mcp/__main__.py @@ -0,0 +1,104 @@ +"""keep-mcp — Model Context Protocol server for Keep.""" + +import logging +import os +import sys + +import httpx +from mcp.server.fastmcp import FastMCP +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) + + +@mcp.custom_route("/healthz", methods=["GET"]) +async def healthz(_): + return PlainTextResponse("ok") + + +def main() -> None: + logging.basicConfig( + level=logging.INFO, + format="%(asctime)s %(levelname)s %(name)s: %(message)s", + stream=sys.stderr, + ) + if not API_KEY: + logging.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) + if TRANSPORT == "stdio": + mcp.run(transport="stdio") + else: + mcp.settings.host = HTTP_HOST + mcp.settings.port = HTTP_PORT + mcp.run(transport="streamable-http") + + +if __name__ == "__main__": + main() 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="!!!")