API reference¶
Every route below is also an MCP tool, except the health and meta ones: the
MCP server at /mcp is generated from this API's OpenAPI schema. /search is the
search_hiv_metadata tool and /data is get_hiv_data, and their docstrings are
the tool descriptions a model reads.
Full schema and a try-it console are at /docs when the API is running (disabled in
production with HIVTOOLS_MCP_ENABLE_DOCS=false; /openapi.json stays available).
GET /, GET /version¶
Both return {"name": "hivtools-mcp", "version": "<pyproject version>"}.
GET /health, GET /health/ready¶
/health is a liveness check ({"status": "ok"}). /health/ready also checks the DuckDB
connection answers a query ({"status": "ready"}, or 503). Used by the Azure Container
Apps probes.
GET /search¶
MCP tool: search_hiv_metadata.
Resolves plain-language terms to the IDs /data accepts. Nothing in this dataset
has a guessable ID - the treatment gap is untreated_plhiv_num, Lilongwe is
MWI_3_13_demo, children are Y000_014 - and guessing returns an empty result
rather than an error, so terms are resolved here first.
One index covers indicators, areas, age groups, age partitions, risk groups and
concepts, each result tagged with its field. Repeat q to resolve several terms
in one call:
curl "http://127.0.0.1:8000/search?q=treatment+gap&q=children&q=Lilongwe&country=MWI"
| Parameter | Purpose |
|---|---|
q |
Term to resolve, as a user would phrase it. Repeat for several. |
field |
Restrict to concept, indicator, area, age_group, age_partition or risk_group. Omit when unsure. |
country |
ISO3 code; scopes areas, age groups and coverage to one country. |
limit |
Maximum matches per term (default 5). |
The best match comes back in full (detail: "full"), the rest as summaries. For a
concept that means the indicators it maps to, their units, the dimension values
they actually have rows for, and the caveats that make an answer correct - all of
which the caller needs before querying, not after. An indicator's coverage is
keyed by source, since Naomi and Spectrum cover very different ground; a risk
group says which sources and sexes have it.
ambiguous: true means the top two matches genuinely compete - Lilongwe the
district and Lilongwe the district+metro area, or adults as 15-49 versus 15+.
Both are returned in full so the choice can be made on substance, or put to the
user. Matches of different kinds never compete: a concept and one of the
indicators it names both match "treatment gap", but that is not a choice.
Matching is layered and dependency-free - exact, then token-set (so "burden of HIV" and "HIV burden" are identical), then word-boundary substring, then difflib for typos. At a few hundred entries that is microseconds and every score is explainable; rapidfuzz and DuckDB's FTS extension are the upgrade path if quality, not speed, becomes the limit.
GET /data¶
MCP tool: get_hiv_data.
Filtered rows from the indicators dataset as JSON:
{"meta": { ... }, "total": <n>, "data": [ ... ]}, where total is the number of
rows matching the filters ignoring limit/offset (so a caller can page and show
"N of total").
When nothing matches, the response carries a diagnostic explaining why - either
a value the dataset does not define (with the nearest real ones, via the same
matcher /search uses) or a valid combination with no rows, naming the filter
responsible and the values that would have worked.
Rows carry what varies and nothing else - the common query pins five of the six dimensions and varies one, so what repeats per row is most of the payload and none of the information. Three things are lifted out of the rows:
- Dimensions identical across every matching row go into
meta. It also carries each one's label, theunitneeded to interpret a value (proportionis a fraction 0-1, so0.108means 10.8%), and thesourceof the estimates, whose label says how the figures must be described (orsources, one per source, when the rows come from several). A dimension is only hoisted when it is provably constant across the whole result, not merely constant on the page in hand. - Labels for dimensions that vary go into
meta.labels, as{dimension: {code: label}}- stated once per distinct code rather than once per row. Rows therefore hold codes only: look a code up inmeta.labels, or read it offmetadirectly when the dimension is constant. - Measures with no value are dropped from the row. Which measures exist is a
property of the model - SHIPP is a point estimate, Naomi has intervals - so
this is detected from the rows, not hardcoded, and applies per row: a Naomi row
carries
lower/upperand a Spectrum row beside it does not. When a measure is absent from every row on the page,meta.measures_omittednames it, so the absence reads as "this model does not produce it" rather than as a gap in the data.
Together these cut a typical long result by roughly 50-60%.
Filters¶
To match any of several values (OR), repeat the parameter
(?indicator=a&indicator=b) or comma-separate it (?indicator=a,b); different
parameters combine with AND:
| Parameter | Notes |
|---|---|
country |
ISO3, e.g. MWI - the partition key, so this is the cheapest filter |
source |
naomi, spectrum or shipp. An indicator from two sources comes back as two rows: pick one, never add them |
area_level |
integer, 0-4 |
area_id |
e.g. MWI, MWI_1_1_demo |
sex |
both, female, male |
age_group |
e.g. Y015_049 |
risk_group |
e.g. sexpaid12m, male_key_pop; all for Naomi and Spectrum rows |
calendar_quarter |
e.g. CY2024Q3 |
indicator |
e.g. prevalence, art_coverage |
age_partition takes the name of an age-group set instead - five_year_bands,
child_adult - and expands it to that set's age groups. The groups mix a partition
with overlapping aggregates (Y015_049 and Y000_999 sit in the same column as
the five-year bands), so a caller assembling a breakdown by hand can easily build
one that overlaps, and nothing in the result would say so. Naming the set instead
means the rows returned tile their population exactly and can be added up.
search?field=age_partition lists what is available; it cannot be combined with
age_group.
Shape¶
| Parameter | Default | Notes |
|---|---|---|
columns |
mean,lower,upper |
Which estimate columns to return: mean, se, median, mode, lower, upper. Repeat or comma-separate. This is a projection, not a row filter - the categorical columns above are always returned. The default is the estimate and its interval, which is what a reported figure uses; se, median and mode describe the posterior's shape and are only worth asking for when the question is about the model. A requested measure that has no value on a row is dropped from it rather than returned as null (see meta.measures_omitted above). |
limit |
1000 |
1-5000 (HIVTOOLS_MCP_MAX_ROWS) |
offset |
0 |
Results are ordered by the categorical columns, so limit/offset paginate deterministically. |
sig_figs |
6 (HIVTOOLS_MCP_RESPONSE_SIG_FIGS) |
Significant figures the measure values are rounded to on the way out, 1-15. The Parquet dataset keeps full model precision; this is presentation only. |
An unknown columns value, a non-integer area_level, or a limit/sig_figs outside its
range, is a 422. /data is rate limited per client IP (HIVTOOLS_MCP_DATA_RATE_LIMIT,
default 300/minute); over that is a 429. Responses carry Cache-Control: public,
max-age=300 (HIVTOOLS_MCP_CACHE_MAX_AGE).
Examples¶
# HIV prevalence for 15-49s, national level, just the point estimate and CI
curl "http://127.0.0.1:8000/data?indicator=prevalence&area_id=MWI&age_group=Y015_049&columns=mean,lower,upper"
# ART coverage for two countries, women, most recent quarter
curl "http://127.0.0.1:8000/data?indicator=art_coverage&country=MWI,ZWE&sex=female&calendar_quarter=CY2024Q3"
# National PLHIV for every year, from Spectrum
curl "http://127.0.0.1:8000/data?source=spectrum&indicator=plhiv&country=TZA&sex=both&age_group=Y000_999&columns=mean"
# Female sex workers by district, from SHIPP (with auth on)
curl -H "Authorization: Bearer $TOKEN" "http://127.0.0.1:8000/data?source=shipp&risk_group=sexpaid12m&indicator=population&country=TZA&area_level=4&sex=female&age_group=Y015_049"
Authentication¶
When HIVTOOLS_MCP_API_TOKEN is set, every request needs the standard bearer
token header Authorization: Bearer <token> - /data, /search and /mcp
alike - and gets a 401 without it. /, /version, /favicon.ico, /health, /health/ready and the
OpenAPI schema and docs stay open. Locally the token is unset, so the API is
open.
In production the token comes from Terraform
(terraform output -raw api_token), and the claude.ai connector sends it as a
static request header, set by an organisation admin when adding the connector.
Rotating it is covered in
infra/README.md.
The container image sets HIVTOOLS_MCP_REQUIRE_AUTH=true, so it will not start
without a token.
Configuration¶
Environment variables (or a .env file), all prefixed HIVTOOLS_MCP_. They are
defined in
app/settings.py.
| Variable | Default | Purpose |
|---|---|---|
HIVTOOLS_MCP_NAOMI_DATA_DIR |
data-prep/naomi-data |
Root of the Parquet dataset to serve. |
HIVTOOLS_MCP_API_TOKEN |
unset (open) | Token required on every data request. See Authentication. |
HIVTOOLS_MCP_REQUIRE_AUTH |
false (true in the image) |
Refuse to start without HIVTOOLS_MCP_API_TOKEN. |
HIVTOOLS_MCP_SEARCH_RATE_LIMIT |
600/minute |
Per-IP limit on /search; looser than /data since an agent makes several lookups per question. |
HIVTOOLS_MCP_DUCKDB_THREADS |
unset (one per core) | Caps threads per query; lower it if many concurrent requests oversubscribe the CPU. |
HIVTOOLS_MCP_RESPONSE_SIG_FIGS |
6 |
Default significant figures for measure values in responses; a request can override with ?sig_figs=. |
HIVTOOLS_MCP_MAX_ROWS |
5000 |
Largest page ?limit= may request. |
HIVTOOLS_MCP_DEFAULT_ROWS |
1000 |
Page size when ?limit= is omitted. |
HIVTOOLS_MCP_DATA_RATE_LIMIT |
300/minute |
Per-IP rate limit on /data. Every claude.ai user arrives from a few Anthropic addresses, so this is shared between them. |
HIVTOOLS_MCP_RATE_LIMIT_ENABLED |
true |
Master switch for the rate limiter. |
HIVTOOLS_MCP_CACHE_MAX_AGE |
300 |
Cache-Control max-age (seconds) on /data responses. |
HIVTOOLS_MCP_ENABLE_DOCS |
true |
Serve the interactive /docs and /redoc consoles. Set false in production. |
HIVTOOLS_MCP_LOG_JSON |
false |
Log JSON lines to stdout (for Log Analytics) instead of readable text to stderr. On in production. |
HIVTOOLS_MCP_LOG_LEVEL |
INFO |
Minimum log level. |
HIVTOOLS_MCP_LOG_RESPONSE_CHARS |
2000 |
How much of each MCP tool response goes into its log line. |