diff --git a/.github/workflows/test-pr-ut-mcp.yml b/.github/workflows/test-pr-ut-mcp.yml new file mode 100644 index 0000000000..a4a91ae126 --- /dev/null +++ b/.github/workflows/test-pr-ut-mcp.yml @@ -0,0 +1,54 @@ +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" + version: 0.11.4 + + - 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 b2f8d44fcc..d60351e7f0 100644 --- a/docker-compose.common.yml +++ b/docker-compose.common.yml @@ -40,3 +40,10 @@ 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_KEEP_API_URL=http://keep-backend:8080 + - KEEP_MCP_KEEP_API_KEY=${KEEP_MCP_KEEP_API_KEY:-keepappkey} diff --git a/docker-compose.yml b/docker-compose.yml index 8358c4edc3..5ba1cb3a77 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -29,6 +29,18 @@ 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 + 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..7b54589f2d --- /dev/null +++ b/keep-mcp/README.md @@ -0,0 +1,27 @@ +# 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). + +## 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 new file mode 100644 index 0000000000..caabf3ccb1 --- /dev/null +++ b/keep-mcp/pyproject.toml @@ -0,0 +1,27 @@ +[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.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..b35ec3a9e9 --- /dev/null +++ b/keep-mcp/src/keep_mcp/__main__.py @@ -0,0 +1,117 @@ +"""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 + +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") +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." + ), +) + + +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, **_page(limit, 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]] = list(_page(limit, offset).items()) + 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: + """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=_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) + + +@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: + logger.error("KEEP_MCP_KEEP_API_KEY is not set; refusing to start") + sys.exit(2) + logger.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..f09e6ebb72 --- /dev/null +++ b/keep-mcp/tests/test_tools.py @@ -0,0 +1,62 @@ +import httpx +import pytest +import respx + +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_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}) + ) + 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(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(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