Skip to content

Commit 9105bac

Browse files
shahfazalclaude
andauthored
feat: add deep health check and call_tool dev script (#100)
* feat: add deep health check and call_tool dev script - helpers/health_probe.py: _run_deep_check() runs full MCP handshake and calls search_datasets(page_size=1) to validate end-to-end stack - main.py: /health now returns 503 if probe fails, 200 if healthy - tests/test_deep_health.py: @pytest.mark.deep_health, excluded from CI, run manually with: uv run pytest -m deep_health - tests/test_health_endpoint.py: updated to accept 200 or 503 - scripts/call_tool.py: CLI helper, no more manual curl handshaking - pyproject.toml: deep_health marker registered and excluded from CI Closes #22 Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * refactor: address review comments, extract mcp_client helper and drop deep naming --------- Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
1 parent 69f584a commit 9105bac

8 files changed

Lines changed: 196 additions & 21 deletions

File tree

README.md

Lines changed: 20 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -352,7 +352,7 @@ The MCP server is built using the [official Python SDK for MCP servers and clien
352352

353353
**Streamable HTTP transport (standards-compliant):**
354354
- `POST /mcp` - JSON-RPC messages (client → server)
355-
- `GET /health` - Simple JSON health probe (`{"status":"ok","timestamp":"..."}`)
355+
- `GET /health` - Health check endpoint: runs a full MCP handshake and tool call. Returns `{"status":"ok",...}` with HTTP 200 if healthy, or `{"status":"mcp_unavailable"}` with HTTP 503 if the MCP stack is not responding correctly.
356356

357357
## 🛠️ Available Tools
358358

@@ -442,6 +442,25 @@ uv run pytest -m stress
442442

443443
Currently includes a test that mixes normal requests with abrupt client TCP disconnects, verifying the server stays healthy and keeps serving despite the disruption. It uses `MCP_PORT` (default: `8000`) to connect to the local server.
444444

445+
### 🩺 Run a Health Check from the CLI
446+
447+
Runs a full MCP handshake and calls `search_datasets` to validate end-to-end stack health. Requires a running server and is excluded from default `pytest` runs.
448+
449+
```shell
450+
# Start the server first, then in another terminal:
451+
uv run pytest -m health_check
452+
```
453+
454+
### 🛠️ Local Tool Testing Script
455+
456+
`scripts/call_tool.py` lets you call any MCP tool directly without manually managing the curl handshake. Requires a running server.
457+
458+
```shell
459+
# Start the server first, then in another terminal:
460+
python scripts/call_tool.py search_datasets '{"query": "IRVE"}'
461+
python scripts/call_tool.py get_resource_info '{"resource_id": "<id>"}'
462+
```
463+
445464
### 🔍 Interactive Testing with MCP Inspector
446465

447466
Use the official [MCP Inspector](https://modelcontextprotocol.io/docs/tools/inspector) to interactively test the server tools and resources.

helpers/health_probe.py

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,41 @@
1+
"""
2+
Health probe that runs a check on the MCP itself (doing a full handshake)
3+
and calls the `search_datasets` tool with page_size=1 for a full round-trip validation.
4+
5+
Checks if the call response actually contains `data` for valid MCP response
6+
7+
returns True if OK
8+
False if round-trip failed (meaning the probe failed)
9+
"""
10+
11+
import logging
12+
13+
from mcp.types import TextContent
14+
15+
from helpers.logging import MAIN_LOGGER_NAME
16+
from helpers.mcp_client import call_tool_on_mcp
17+
18+
logger = logging.getLogger(MAIN_LOGGER_NAME)
19+
20+
21+
async def _run_health_check() -> bool:
22+
logger.debug("health probe: starting health check")
23+
try:
24+
result = await call_tool_on_mcp(
25+
"search_datasets", {"query": "transport", "page_size": 1}
26+
)
27+
# result.content is a list of content blocks from the tool response
28+
# search_datasets always returns a TextContent block
29+
# we check it's non-empty to confirm a valid round-trip
30+
if not result.content or not isinstance(result.content[0], TextContent):
31+
logger.error("health probe: unexpected response from search_datasets")
32+
return False
33+
if not result.content[0].text:
34+
logger.error("health probe: empty response from search_datasets")
35+
return False
36+
37+
return True
38+
39+
except Exception as e:
40+
logger.error(f"health probe check failed: {e}")
41+
return False

helpers/mcp_client.py

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
"""
2+
Common helper to invoke an MCP tool call with the given params.
3+
"""
4+
5+
import os
6+
7+
from mcp.client.session import ClientSession
8+
from mcp.client.streamable_http import streamable_http_client
9+
from mcp.types import CallToolResult
10+
11+
12+
async def call_tool_on_mcp(tool_name: str, params: dict) -> CallToolResult:
13+
port = os.getenv("MCP_PORT", "8000")
14+
url = f"http://localhost:{port}/mcp"
15+
16+
async with streamable_http_client(url) as (read, write, _):
17+
async with ClientSession(read, write) as session:
18+
await session.initialize()
19+
result = await session.call_tool(tool_name, params)
20+
21+
return result

main.py

Lines changed: 22 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@
1111
from mcp.server.fastmcp import FastMCP
1212
from mcp.server.transport_security import TransportSecuritySettings
1313

14+
from helpers.health_probe import _run_health_check
1415
from helpers.logging import MAIN_LOGGER_NAME, UVICORN_LOGGING_CONFIG
1516
from helpers.matomo import (
1617
apply_matomo_request_context,
@@ -74,21 +75,32 @@ async def app(scope, receive, send):
7475
except PackageNotFoundError:
7576
app_version = "unknown"
7677

77-
body = json.dumps(
78-
{
79-
"status": "ok",
80-
"uptime_since": SERVER_START_TIME.isoformat(),
81-
"version": app_version,
82-
"env": os.getenv("MCP_ENV", "unknown"),
83-
"data_env": os.getenv("DATAGOUV_API_ENV", "unknown"),
84-
}
85-
).encode("utf-8")
78+
is_healthy = await _run_health_check()
79+
if is_healthy:
80+
body = json.dumps(
81+
{
82+
"status": "ok",
83+
"uptime_since": SERVER_START_TIME.isoformat(),
84+
"version": app_version,
85+
"env": os.getenv("MCP_ENV", "unknown"),
86+
"data_env": os.getenv("DATAGOUV_API_ENV", "unknown"),
87+
}
88+
).encode("utf-8")
89+
http_status = 200
90+
else:
91+
body = json.dumps({"status": "mcp_unavailable"}).encode("utf-8")
92+
http_status = 503
93+
8694
headers = [
8795
(b"content-type", b"application/json"),
8896
(b"content-length", str(len(body)).encode("utf-8")),
8997
]
9098
await send(
91-
{"type": "http.response.start", "status": 200, "headers": headers}
99+
{
100+
"type": "http.response.start",
101+
"status": http_status,
102+
"headers": headers,
103+
}
92104
)
93105
await send({"type": "http.response.body", "body": body})
94106
return

pyproject.toml

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -37,5 +37,8 @@ testpaths = ["tests"]
3737
python_files = ["test_*.py"]
3838
python_classes = ["Test*"]
3939
python_functions = ["test_*"]
40-
markers = ["stress: stress tests requiring a running MCP server (not run by default)"]
41-
addopts = "-m 'not stress'"
40+
markers = [
41+
"stress: stress tests requiring a running MCP server (not run by default)",
42+
"health_check: health check requiring a running MCP server (not run by default)",
43+
]
44+
addopts = "-m 'not stress and not health_check'"

scripts/call_tool.py

Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,50 @@
1+
"""
2+
CLI helper to call any MCP tool without manual curl handshaking.
3+
4+
Usage:
5+
python scripts/call_tool.py <tool_name> '<json_args>'
6+
7+
Example:
8+
python scripts/call_tool.py search_datasets '{"query": "IRVE"}'
9+
python scripts/call_tool.py get_resource_info '{"resource_id": "abc123"}'
10+
"""
11+
12+
import asyncio
13+
import json
14+
import logging
15+
import sys
16+
17+
from mcp.types import TextContent
18+
19+
from helpers.logging import MAIN_LOGGER_NAME
20+
from helpers.mcp_client import call_tool_on_mcp
21+
22+
logger = logging.getLogger(MAIN_LOGGER_NAME)
23+
24+
25+
async def call_tool(tool_name: str, args: dict) -> None:
26+
"""
27+
Connect to MCP server and call a tool with given arguments.
28+
"""
29+
logger.debug(f"Initiating tool call: {tool_name}")
30+
31+
result = await call_tool_on_mcp(tool_name, args)
32+
33+
if result.content and isinstance(result.content[0], TextContent):
34+
print(result.content[0].text)
35+
elif not result.content:
36+
print(f"Error: empty response from tool '{tool_name}'", file=sys.stderr)
37+
else:
38+
print(
39+
f"Error: unexpected content type from tool '{tool_name}': {type(result.content[0]).__name__}",
40+
file=sys.stderr,
41+
)
42+
43+
44+
if __name__ == "__main__":
45+
if len(sys.argv) < 2:
46+
print("Usage: python scripts/call_tool.py <tool_name> '<json_args>'")
47+
sys.exit(1)
48+
tool = sys.argv[1]
49+
arguments = json.loads(sys.argv[2]) if len(sys.argv) > 2 else {}
50+
asyncio.run(call_tool(tool, arguments))

tests/test_health_check.py

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
"""
2+
Health check: full MCP handshake + search_datasets tool call.
3+
4+
Requires a running MCP server (not started by the test).
5+
Excluded from normal pytest runs -- launch explicitly with:
6+
7+
uv run pytest -m health_check
8+
"""
9+
10+
import pytest
11+
12+
from helpers.health_probe import _run_health_check
13+
14+
pytestmark = pytest.mark.health_check
15+
16+
17+
async def test_health_check():
18+
"""
19+
Runs the full MCP handshake and calls search_datasets with page_size=1.
20+
Asserts a valid non-empty response to confirm end-to-end stack is healthy.
21+
"""
22+
is_healthy = await _run_health_check()
23+
assert is_healthy, (
24+
"Health check failed: MCP handshake or tool call returned unexpected result"
25+
)

tests/test_health_endpoint.py

Lines changed: 12 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -5,16 +5,20 @@
55

66

77
@pytest.mark.asyncio
8-
async def test_health_endpoint_returns_ok_status():
8+
async def test_health_endpoint_returns_valid_response():
99
transport = ASGITransport(app=asgi_app)
1010
async with AsyncClient(transport=transport, base_url="http://testserver") as client:
1111
response = await client.get("/health")
1212

13-
assert response.status_code == 200
13+
assert response.status_code in (200, 503)
1414
payload = response.json()
15-
assert payload.get("status") == "ok"
16-
assert "uptime_since" in payload
17-
assert "version" in payload
18-
assert isinstance(payload.get("version"), str)
19-
assert "env" in payload
20-
assert "data_env" in payload
15+
assert "status" in payload
16+
if response.status_code == 200:
17+
assert payload["status"] == "ok"
18+
assert "uptime_since" in payload
19+
assert "version" in payload
20+
assert isinstance(payload.get("version"), str)
21+
assert "env" in payload
22+
assert "data_env" in payload
23+
else:
24+
assert payload["status"] == "mcp_unavailable"

0 commit comments

Comments
 (0)