Skip to main content
Glama
joaquimtimoteo

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