From 4fa8d5465ebc42d4dd65c6960581364f4b21e942 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Adri=C3=A1n=20Pe=C3=B1a?= Date: Tue, 19 May 2026 09:51:32 +0200 Subject: [PATCH] refactor(mcp): align /health with IETF health-check format (#11207) --- .github/workflows/mcp-container-checks.yml | 1 + docker-compose-dev.yml | 1 + docker-compose.yml | 1 + mcp_server/prowler_mcp_server/server.py | 16 +++++-- mcp_server/pyproject.toml | 8 ++++ mcp_server/tests/__init__.py | 0 mcp_server/tests/test_health.py | 46 ++++++++++++++++++++ mcp_server/uv.lock | 50 ++++++++++++++++++++++ 8 files changed, 120 insertions(+), 3 deletions(-) create mode 100644 mcp_server/tests/__init__.py create mode 100644 mcp_server/tests/test_health.py diff --git a/.github/workflows/mcp-container-checks.yml b/.github/workflows/mcp-container-checks.yml index 5b750f0998..217bd4c7bb 100644 --- a/.github/workflows/mcp-container-checks.yml +++ b/.github/workflows/mcp-container-checks.yml @@ -79,6 +79,7 @@ jobs: registry-1.docker.io:443 auth.docker.io:443 production.cloudflare.docker.com:443 + production.cloudfront.docker.com:443 ghcr.io:443 pkg-containers.githubusercontent.com:443 files.pythonhosted.org:443 diff --git a/docker-compose-dev.yml b/docker-compose-dev.yml index eef275472f..2020c7d21a 100644 --- a/docker-compose-dev.yml +++ b/docker-compose-dev.yml @@ -211,6 +211,7 @@ services: interval: 10s timeout: 5s retries: 3 + start_period: 60s volumes: outputs: diff --git a/docker-compose.yml b/docker-compose.yml index 42ea7b435e..a9d2e03c3d 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -176,6 +176,7 @@ services: interval: 10s timeout: 5s retries: 3 + start_period: 60s volumes: output: diff --git a/mcp_server/prowler_mcp_server/server.py b/mcp_server/prowler_mcp_server/server.py index 12060366ad..e6cf399e72 100644 --- a/mcp_server/prowler_mcp_server/server.py +++ b/mcp_server/prowler_mcp_server/server.py @@ -41,12 +41,22 @@ async def setup_main_server(): logger.error(f"Failed to import Prowler Documentation server: {e}") -# Add health check endpoint +# Response follows the IETF Health Check Response Format +# (draft-inadarei-api-health-check-06). `version` is the contract version of +# this endpoint; `releaseId` is the package build version. @prowler_mcp_server.custom_route("/health", methods=["GET"]) -async def health_check(request) -> JSONResponse: +async def health_check(_request) -> JSONResponse: """Health check endpoint.""" return JSONResponse( - {"status": "healthy", "service": "prowler-mcp-server", "version": __version__} + { + "status": "pass", + "version": "1", + "releaseId": __version__, + "serviceId": "prowler-mcp-server", + "description": "Prowler MCP Server", + }, + media_type="application/health+json", + headers={"Cache-Control": "no-store"}, ) diff --git a/mcp_server/pyproject.toml b/mcp_server/pyproject.toml index 2a6885fedb..1d18dfdc8e 100644 --- a/mcp_server/pyproject.toml +++ b/mcp_server/pyproject.toml @@ -2,6 +2,11 @@ build-backend = "setuptools.build_meta" requires = ["setuptools>=61.0", "wheel"] +[dependency-groups] +dev = [ + "pytest==8.3.5" +] + [project] dependencies = [ "fastmcp==2.14.0", @@ -16,5 +21,8 @@ version = "0.5.0" [project.scripts] prowler-mcp = "prowler_mcp_server.main:main" +[tool.pytest.ini_options] +testpaths = ["tests"] + [tool.uv] package = true diff --git a/mcp_server/tests/__init__.py b/mcp_server/tests/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/mcp_server/tests/test_health.py b/mcp_server/tests/test_health.py new file mode 100644 index 0000000000..47a676960d --- /dev/null +++ b/mcp_server/tests/test_health.py @@ -0,0 +1,46 @@ +"""Tests for the Prowler MCP Server health endpoint.""" + +from starlette.testclient import TestClient + +from prowler_mcp_server import __version__ +from prowler_mcp_server.server import app + + +def test_health_returns_ietf_pass_response(): + """GET /health returns 200 with the IETF health-check body and headers.""" + client = TestClient(app) + + response = client.get("/health") + + assert response.status_code == 200 + assert response.headers["content-type"] == "application/health+json" + assert response.headers["cache-control"] == "no-store" + assert response.json() == { + "status": "pass", + "version": "1", + "releaseId": __version__, + "serviceId": "prowler-mcp-server", + "description": "Prowler MCP Server", + } + + +def test_health_release_id_matches_package_version(): + """The endpoint must surface the current package __version__ as releaseId. + + Drift between the response and the installed package would mislead any + monitoring tool that uses releaseId to identify the running build. + """ + client = TestClient(app) + + response = client.get("/health") + + assert response.json()["releaseId"] == __version__ + + +def test_health_rejects_non_get_methods(): + """The endpoint only exposes GET; other verbs return 405.""" + client = TestClient(app) + + response = client.post("/health") + + assert response.status_code == 405 diff --git a/mcp_server/uv.lock b/mcp_server/uv.lock index b657ad6e92..6d6d271cd9 100644 --- a/mcp_server/uv.lock +++ b/mcp_server/uv.lock @@ -443,6 +443,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/fa/5e/f8e9a1d23b9c20a551a8a02ea3637b4642e22c2626e3a13a9a29cdea99eb/importlib_metadata-8.7.1-py3-none-any.whl", hash = "sha256:5a1f80bf1daa489495071efbb095d75a634cf28a8bc299581244063b53176151", size = 27865, upload-time = "2025-12-21T10:00:18.329Z" }, ] +[[package]] +name = "iniconfig" +version = "2.3.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/72/34/14ca021ce8e5dfedc35312d08ba8bf51fdd999c576889fc2c24cb97f4f10/iniconfig-2.3.0.tar.gz", hash = "sha256:c76315c77db068650d49c5b56314774a7804df16fee4402c1f19d6d15d8c4730", size = 20503, upload-time = "2025-10-18T21:55:43.219Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/cb/b1/3846dd7f199d53cb17f49cba7e651e9ce294d8497c8c150530ed11865bb8/iniconfig-2.3.0-py3-none-any.whl", hash = "sha256:f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12", size = 7484, upload-time = "2025-10-18T21:55:41.639Z" }, +] + [[package]] name = "jaraco-classes" version = "3.4.0" @@ -676,6 +685,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/cf/df/d3f1ddf4bb4cb50ed9b1139cc7b1c54c34a1e7ce8fd1b9a37c0d1551a6bd/opentelemetry_api-1.39.1-py3-none-any.whl", hash = "sha256:2edd8463432a7f8443edce90972169b195e7d6a05500cd29e6d13898187c9950", size = 66356, upload-time = "2025-12-11T13:32:17.304Z" }, ] +[[package]] +name = "packaging" +version = "26.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d7/f1/e7a6dd94a8d4a5626c03e4e99c87f241ba9e350cd9e6d75123f992427270/packaging-26.2.tar.gz", hash = "sha256:ff452ff5a3e828ce110190feff1178bb1f2ea2281fa2075aadb987c2fb221661", size = 228134, upload-time = "2026-04-24T20:15:23.917Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/df/b2/87e62e8c3e2f4b32e5fe99e0b86d576da1312593b39f47d8ceef365e95ed/packaging-26.2-py3-none-any.whl", hash = "sha256:5fc45236b9446107ff2415ce77c807cee2862cb6fac22b8a73826d0693b0980e", size = 100195, upload-time = "2026-04-24T20:15:22.081Z" }, +] + [[package]] name = "pathable" version = "0.4.4" @@ -703,6 +721,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/73/cb/ac7874b3e5d58441674fb70742e6c374b28b0c7cb988d37d991cde47166c/platformdirs-4.5.0-py3-none-any.whl", hash = "sha256:e578a81bb873cbb89a41fcc904c7ef523cc18284b7e3b3ccf06aca1403b7ebd3", size = 18651, upload-time = "2025-10-08T17:44:47.223Z" }, ] +[[package]] +name = "pluggy" +version = "1.6.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f9/e2/3e91f31a7d2b083fe6ef3fa267035b518369d9511ffab804f839851d2779/pluggy-1.6.0.tar.gz", hash = "sha256:7dcc130b76258d33b90f61b658791dede3486c3e6bfb003ee5c9bfb396dd22f3", size = 69412, upload-time = "2025-05-15T12:30:07.975Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/54/20/4d324d65cc6d9205fabedc306948156824eb9f0ee1633355a8f7ec5c66bf/pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746", size = 20538, upload-time = "2025-05-15T12:30:06.134Z" }, +] + [[package]] name = "prometheus-client" version = "0.24.1" @@ -721,12 +748,20 @@ dependencies = [ { name = "httpx" }, ] +[package.dev-dependencies] +dev = [ + { name = "pytest" }, +] + [package.metadata] requires-dist = [ { name = "fastmcp", specifier = "==2.14.0" }, { name = "httpx", specifier = "==0.28.1" }, ] +[package.metadata.requires-dev] +dev = [{ name = "pytest", specifier = "==8.3.5" }] + [[package]] name = "py-key-value-aio" version = "0.3.0" @@ -906,6 +941,21 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/1e/bc/22540e73c5f5ae18f02924cd3954a6c9a4aa6b713c841a94c98335d333a1/pyperclip-1.10.0-py3-none-any.whl", hash = "sha256:596fbe55dc59263bff26e61d2afbe10223e2fccb5210c9c96a28d6887cfcc7ec", size = 11062, upload-time = "2025-09-18T00:53:59.252Z" }, ] +[[package]] +name = "pytest" +version = "8.3.5" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "colorama", marker = "sys_platform == 'win32'" }, + { name = "iniconfig" }, + { name = "packaging" }, + { name = "pluggy" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/ae/3c/c9d525a414d506893f0cd8a8d0de7706446213181570cdbd766691164e40/pytest-8.3.5.tar.gz", hash = "sha256:f4efe70cc14e511565ac476b57c279e12a855b11f48f212af1080ef2263d3845", size = 1450891, upload-time = "2025-03-02T12:54:54.503Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/30/3d/64ad57c803f1fa1e963a7946b6e0fea4a70df53c1a7fed304586539c2bac/pytest-8.3.5-py3-none-any.whl", hash = "sha256:c69214aa47deac29fad6c2a4f590b9c4a9fdb16a403176fe154b79c0b4d4d820", size = 343634, upload-time = "2025-03-02T12:54:52.069Z" }, +] + [[package]] name = "python-dotenv" version = "1.1.1"