Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 11 additions & 0 deletions docker-compose.common.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
13 changes: 13 additions & 0 deletions docker-compose.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
54 changes: 54 additions & 0 deletions docker/Dockerfile.mcp
Original file line number Diff line number Diff line change
@@ -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"]
20 changes: 20 additions & 0 deletions keep-mcp/README.md
Original file line number Diff line number Diff line change
@@ -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=<your-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.
31 changes: 31 additions & 0 deletions keep-mcp/pyproject.toml
Original file line number Diff line number Diff line change
@@ -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"
3 changes: 3 additions & 0 deletions keep-mcp/src/keep_mcp/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
"""keep-mcp — Model Context Protocol server for Keep."""

__version__ = "0.1.0"
104 changes: 104 additions & 0 deletions keep-mcp/src/keep_mcp/__main__.py
Original file line number Diff line number Diff line change
@@ -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()
34 changes: 34 additions & 0 deletions keep-mcp/tests/test_tools.py
Original file line number Diff line number Diff line change
@@ -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="!!!")
Loading