malaria-forecast-mcp
README.md
# malaria-forecast-mcp
An MCP server that gives an AI agent access to provincial malaria surveillance and short-horizon outbreak forecasting for Angola — with the guardrails that make model output safe for an agent to act on.
Built by [Joaquim Timóteo](https://github.com/joaquimtimoteo). The forecasting work it wraps is described in *Operational Malaria Forecasting in Angola Using Ensemble Models, Regional Clusters, and Epidemiological Memory Features* (ResearchGate, Feb 2026).
---
## Why MCP instead of a REST API
A REST endpoint gives a model a URL and hopes the prompt explains the rest. MCP ships the **contract** alongside the capability, and three consequences follow that matter for anything forecasting-shaped:
**Discovery is dynamic.** Tool schemas are read at connect time. Adding `compare_provinces` made it available to every connected client without a single prompt being rewritten.
**Provenance travels with the capability.** `malaria://model-card` is a resource the model can read *before* quoting a number — validation method, measured skill, known failure modes. With a REST API that context lives in a PDF somewhere, which is to say it does not reach the model at all.
**Refusals are structured.** Ask for a 20-week horizon and you get a typed error naming the validated range, not a plausible-looking wrong number:
```json
{
"error": "horizon_out_of_range",
"detail": "horizon_weeks must be between 1 and 8; got 20. The model was validated only to 8 weeks and will not extrapolate beyond it.",
"max_validated_horizon_weeks": 8
}
```
That last one is the whole argument. A forecasting model wired to an agent without guardrails will answer any question it is asked, including the ones it has no business answering.
---
## What it exposes
### Tools
| Tool | Purpose |
|---|---|
| `list_provinces` | All 18 provinces with epidemiological stratum (K-means burden clustering) |
| `get_incidence_history` | Weekly incidence and the rainfall driver, filtered by date range |
| `forecast_incidence` | 1–8 week forecast with empirical 80% intervals |
| `detect_outbreak_signals` | Weeks running above the **same-calendar-week** seasonal baseline |
| `compare_provinces` | Ranked forecast across provinces, for resource prioritisation |
### Resources
- `malaria://model-card` — architecture, validation method, measured metrics, limitations, guardrails
- `malaria://provinces` — province directory for grounding
### Prompts
- `outbreak_briefing` — walks the agent through model card → history → signals → forecast, then writes a briefing that always states intervals rather than point estimates
- `compare_and_prioritise` — ranks provinces and requires the agent to say when two are not meaningfully separable
---
## Guardrails
1. **Horizons outside 1–8 weeks are refused**, with the reason, rather than extrapolated.
2. **Provinces with under 52 weeks of history are refused** rather than forecast on a season the model has never seen.
3. **Every point carries an empirical 80% interval** from rolling-origin residuals — no distributional assumption.
4. **Anomaly flags are seasonal.** A flag means "high for this week of the year" against prior years, not "high in absolute terms" — which in a seasonal disease is the difference between a signal and a calendar.
---
## Evaluation
The harness was written before the tools, and it earns its place: it caught a real defect.
```
python evals/backtest.py
```
Rolling-origin backtest, 26 origins per province per horizon — 468 scored forecasts at each horizon:
```
h origins MAE baseline skill cov80
--------------------------------------------------
1 468 0.5677 0.8666 0.3450 79.70%
2 468 0.5992 0.8666 0.3085 80.13%
3 468 0.6137 0.8666 0.2919 80.77%
4 468 0.6100 0.8666 0.2961 82.69%
5 468 0.6051 0.8666 0.3018 82.69%
6 468 0.6299 0.8666 0.2732 85.26%
7 468 0.6461 0.8666 0.2544 86.11%
8 468 0.6538 0.8666 0.2456 86.11%
```
`skill` is `1 − (model MAE / seasonal-naive MAE)`. The script exits non-zero if any horizon stops beating the baseline, so this is a gate rather than a report.
**Two findings worth stating plainly, because they are the reason the harness exists:**
*Fixed ensemble weights lost to the baseline at 7–8 weeks.* Local trend and climate signal decay with range while seasonal structure survives. Weights are now horizon-dependent, and skill is positive across the full range. Intuition said the ensemble was fine; the backtest said otherwise.
*The intervals were miscalibrated.* The textbook 0.80 quantile of absolute residuals produced 90–95% measured coverage — too wide, because residuals estimated on recent origins are systematically harder than the weeks being forecast. The quantile was calibrated down to 0.60, which measures at ~80% at short horizons and stays conservative (~86%) at long ones. Coverage is reported on every run so it cannot drift silently.
---
## Data
**The bundled dataset is synthetic.** Provincial surveillance records are not redistributable, so the series reproduces the statistical shape of the real thing — rainy-season seasonality, burden strata, interannual variability, outbreak excursions — without exposing restricted data.
Every metric in this README describes *this reimplementation on synthetic data*. The published research model reports R² 0.985, MAE 6.9 per 1,000 and an 87.5% skill score on real surveillance across all 18 provinces, 2000–2024. Those are different numbers about a different artefact and the model card keeps them clearly separated.
To run against real data, implement the `SurveillanceStore` interface in `data.py`. No tool signature changes.
---
## Install and run
```bash
git clone https://github.com/joaquimtimoteo/malaria-forecast-mcp
cd malaria-forecast-mcp
pip install -e .
python -m malaria_forecast_mcp # stdio server
python scripts/smoke_check.py # 26 end-to-end protocol checks
python evals/backtest.py # evaluation gate
pytest tests/ # full suite
```
### Claude Desktop
Add to `claude_desktop_config.json`:
```json
{
"mcpServers": {
"malaria-forecast": {
"command": "python",
"args": ["-m", "malaria_forecast_mcp"],
"env": { "PYTHONPATH": "/absolute/path/to/malaria-forecast-mcp/src" }
}
}
}
```
Then ask: *"Which three provinces should we prioritise six weeks out, and how confident are you?"* — the agent reads the model card, ranks provinces, checks each against seasonal baselines, and reports intervals rather than point estimates.
---
## Layout
```
src/malaria_forecast_mcp/
server.py MCP tools, resources, prompts
forecasting.py ensemble, intervals, guardrails
data.py surveillance store + synthetic generator
model_card.py machine-readable provenance
evals/backtest.py rolling-origin evaluation gate
scripts/smoke_check.py
tests/
```
## Roadmap
- RAG over published epidemiological literature, so briefings cite evidence
- Real-data adapter for DHIS2 surveillance exports
- Intervention-effect handling (bed-net campaigns, IRS rounds)
## Licence
MIT
TDQS
A4.7/5.0
Scored across 5 tools
Disambiguation5/5
Each tool has a clearly distinct purpose: listing provinces, fetching historical incidence, forecasting, detecting outbreak signals, and comparing provinces. No overlap in functionality.
Naming Consistency5/5
All tool names follow a consistent verb_noun snake_case pattern (list_, get_, forecast_, detect_, compare_).
Tool Count5/5
Five tools perfectly cover the core workflow (list, historical, forecast, detect, compare) without bloat or missing essentials.
Completeness5/5
The set covers the full forecasting workflow: resolve province names, fetch history, forecast, detect anomalies, and rank provinces for prioritization. No obvious dead ends.
Maintenance
ActivitySlowing
ResponsivenessNo issues