Skip to main content
Glama
hivtools

hivtools-mcp

Official
by hivtools
README.md
# hivtools-mcp

[![Release](https://img.shields.io/github/v/release/hivtools/hivtools-mcp)](https://img.shields.io/github/v/release/hivtools/hivtools-mcp)
[![Build status](https://img.shields.io/github/actions/workflow/status/hivtools/hivtools-mcp/main.yml?branch=main)](https://github.com/hivtools/hivtools-mcp/actions/workflows/main.yml?query=branch%3Amain)
[![codecov](https://codecov.io/gh/hivtools/hivtools-mcp/branch/main/graph/badge.svg)](https://codecov.io/gh/hivtools/hivtools-mcp)
[![Commit activity](https://img.shields.io/github/commit-activity/m/hivtools/hivtools-mcp)](https://img.shields.io/github/commit-activity/m/hivtools/hivtools-mcp)
[![License](https://img.shields.io/github/license/hivtools/hivtools-mcp)](https://img.shields.io/github/license/hivtools/hivtools-mcp)

An HTTP API and [MCP](https://modelcontextprotocol.io) server that lets an LLM,
such as Claude through a claude.ai custom connector, answer questions using
modelled HIV estimates from Naomi, Spectrum and SHIPP. It gives the model two
tools: `search_hiv_metadata` turns plain-language terms into IDs, and
`get_hiv_data` returns the estimates for those IDs.

- **GitHub repository**: <https://github.com/hivtools/hivtools-mcp/>
- **Documentation**: <https://hivtools.github.io/hivtools-mcp/>

**Contents:** [How it works](#how-it-works) ·
[Running locally](#running-locally) ·
[Testing the MCP server](#testing-the-mcp-server) ·
[Deploying](#deploying) ·
[Further documentation](#further-documentation)

## How it works

### Architecture

```mermaid
flowchart LR
    subgraph clients["Clients"]
        claude["claude.ai<br/>custom connector"]
        curl["curl / browser"]
    end

    subgraph image["Docker image, run on Azure Container Apps"]
        auth["Token check"]
        mcp["MCP server at /mcp"]
        knowledge[("Knowledge files")]
        subgraph routes["API routes = MCP tools"]
            search["GET /search<br/>search_hiv_metadata"]
            data["GET /data<br/>get_hiv_data"]
        end
        duckdb["DuckDB, in memory"]
        parquet[("Parquet dataset")]

        auth --> mcp
        auth --> routes
        mcp -- "tool calls, in-process" --> routes
        knowledge -. "server instructions" .-> mcp
        knowledge -. "concepts, units, aliases" .-> search
        routes --> duckdb --> parquet
    end

    subgraph build["make data, at build time"]
        demo[("Demo inputs<br/>data-prep/raw-data/")]
        private[("Private inputs<br/>hivtools-mcp-data repo")]
        extract["extract_indicators.py<br/>+ extract_spectrum_shipp.R"]
        demo --> extract
        private --> extract
    end

    claude -- "MCP over HTTP" --> auth
    curl -- "HTTP" --> auth
    extract -- "copied into the image" --> parquet
```

- **One app, two interfaces.** [`app/main.py`](app/main.py) is a FastAPI app.
  [`app/mcp.py`](app/mcp.py) generates the MCP server from that app's OpenAPI
  schema, so each route is also a tool. A route's docstring is the tool
  description the model reads.
- **The data is built ahead of time.** `make data` turns the model outputs into
  a Parquet dataset, which is copied into the Docker image. Nothing is fetched at
  runtime, so shipping new data means cutting a new release.
- **Hand-written knowledge.** [`app/knowledge/`](app/knowledge/) holds the facts
  the model outputs don't include: concepts such as "treatment gap", units, age
  groups that are safe to sum, and the server instructions.
- **One API token**, sent as a standard bearer token
  (`Authorization: Bearer <token>`), protects both the API and `/mcp`. Locally no token is set, so everything is open.

### How an LLM uses the tools

```mermaid
flowchart TD
    question(["User asks a question, e.g.<br/>'Where is the treatment gap largest in Malawi?'"])
    search["<b>search_hiv_metadata</b><br/>one q per term:<br/>'treatment gap', 'Malawi'"]
    ambiguous{"ambiguous?"}
    choose["Choose on substance,<br/>or ask the user"]
    answerable{"answerable:<br/>false?"}
    cannot(["Say the data<br/>cannot answer it"])
    plan["Take the IDs from the matches.<br/>For a concept, also follow<br/>its notes, coverage and<br/>default_disaggregation"]
    get["<b>get_hiv_data</b><br/>resolved IDs + one source"]
    rows{"Any rows?"}
    diagnostic["Read the diagnostic:<br/>which value or filter is wrong,<br/>and what would work"]
    read["Read meta first:<br/>unit, basis, source label"]
    answer(["Answer in the source label's<br/>wording, with lower–upper<br/>intervals. Never sum<br/>overlapping rows."])

    question --> search --> ambiguous
    ambiguous -- yes --> choose --> answerable
    ambiguous -- no --> answerable
    answerable -- yes --> cannot
    answerable -- no --> plan --> get --> rows
    rows -- no --> diagnostic -- "fix the filter" --> get
    rows -- yes --> read --> answer
```

The model can't guess IDs (the treatment gap is `untreated_plhiv_num`), so it
always searches first. The tool descriptions in [`app/search.py`](app/search.py)
and [`app/indicators.py`](app/indicators.py) steer it through this flow, and so do
the server instructions in
[`app/knowledge/instructions.md`](app/knowledge/instructions.md). Not every MCP
client loads server instructions, so anything the model must know goes in the
tool descriptions as well.

## Running locally

### Requirements

| Tool | Needed for |
| --- | --- |
| [uv](https://docs.astral.sh/uv/getting-started/installation/) | Everything. It installs Python (3.10+) and the dependencies. |
| Git | Cloning the repos |
| [Node.js](https://nodejs.org/) 22.19+ | [Testing with the MCP Inspector](#testing-the-mcp-server) (`npx`) |
| Docker | *Optional:* building and running the image |
| R | *Optional:* building the Spectrum and SHIPP files in the private data |
| Access to [hivtools-mcp-data](https://github.com/hivtools/hivtools-mcp-data) | *Optional:* the private data. Ask a maintainer. |

### One-time setup

```bash
git clone git@github.com:hivtools/hivtools-mcp.git
cd hivtools-mcp
make install   # creates .venv with uv and installs the pre-commit hooks
make data      # builds the public demo dataset into data-prep/naomi-data/
```

Without `make data` the server still starts, but every query returns no rows.

**Optional: private data.** The data that isn't public lives in the private
[hivtools-mcp-data](https://github.com/hivtools/hivtools-mcp-data) repo. You clone
it into this repo (the folder is gitignored):

```bash
git clone git@github.com:hivtools/hivtools-mcp-data.git data-prep/private-data
make r-deps   # the R packages that read Spectrum and SHIPP files
make data     # now builds the demo data and the private data
```

> [!WARNING]
> This repo is public. Never commit private inputs or anything built from them,
> and never print them in CI logs.

With the private data checked out, `make test` also checks the knowledge files
against it. [docs/data.md](docs/data.md) covers how the dataset is built and how to
update the private data.

### Running the server

```bash
make dev
```

This serves the app at <http://127.0.0.1:8000> and reloads when you change a file.

| URL | What it is |
| --- | --- |
| <http://127.0.0.1:8000/docs> | Interactive API docs |
| <http://127.0.0.1:8000/mcp> | The MCP server (streamable HTTP) |
| <http://127.0.0.1:8000/search?q=Lilongwe&country=MWI> | An example search |

Every MCP tool call is logged in this terminal, with its arguments and the start
of the response.

To switch auth on, as in production, set a token:
`HIVTOOLS_MCP_API_TOKEN=some-token make dev`. Settings are environment variables,
or can go in a `.env` file. See [Configuration](docs/api.md#configuration) for the
full list.

### Running the tests and checks

```bash
make test    # pytest, with coverage
make check   # lock file, pre-commit (ruff), ty type check, deptry
```

CI runs both, and runs the tests on Python 3.10 to 3.14. `make help` lists every
target.

### Running with Docker

```bash
docker build -t hivtools-mcp .   # public demo data only
make docker                      # the dataset `make data` builds, private data included
docker run -p 80:80 -e HIVTOOLS_MCP_API_TOKEN=some-token hivtools-mcp
```

The app is then at <http://127.0.0.1:80>. The image refuses to start without a
token, because its data may not be public. For a throwaway local run, add
`-e HIVTOOLS_MCP_REQUIRE_AUTH=false`. For how data gets into the image, see
[docs/data.md](docs/data.md#how-data-gets-into-the-docker-image).

## Testing the MCP server

Use the [MCP Inspector](https://github.com/modelcontextprotocol/inspector), which
needs Node.js 22.19 or later. It works against a local server or production:

| | Local | Production |
|---|---|---|
| URL | `http://localhost:8000/mcp` | `https://hivtools.org/mcp` |
| `Authorization` header | Only if you set `HIVTOOLS_MCP_API_TOKEN`: `Bearer <its value>` | `Bearer <production API key>`, see [below](#getting-the-production-api-key) |

1. For a local server, start it: `make dev`.
1. In another terminal, start the Inspector. It opens in your browser.
   ```bash
   npx @modelcontextprotocol/inspector
   ```
1. Add the server: click **Add Servers → Add manually**. Choose the
   **streamable-http** transport and enter the URL from the table. Add a header
   named `Authorization` whose value is `Bearer ` followed by the key.
   Click **Add**.
1. Switch the server's toggle on to connect, then open **Tools**. You should see
   **Search HIV Metadata** (`search_hiv_metadata`) and **Get HIV Data**
   (`get_hiv_data`).
1. Pick a tool, fill in its arguments and click **Execute Tool**. Arguments must
   be valid JSON, so put quotes around strings (`"MWI"`) and write lists like
   `["Lilongwe"]`.

To see what the model sees, follow [the flow above](#how-an-llm-uses-the-tools)
with the demo data:

1. `search_hiv_metadata` with `q` = `["treatment gap", "Lilongwe"]` and
   `country` = `"MWI"`. The first result for each term is a concept or an area
   with its ID.
1. `get_hiv_data` with those IDs, for example `indicator` =
   `["untreated_plhiv_num"]` and `area_id` = `["MWI_3_13_demo"]`.
1. Try a wrong ID, such as `area_id` = `["Lilongwe"]`, to see the `diagnostic`.

### From the command line

The Inspector also has a CLI mode, which is handy for quick checks:

```bash
# List the tools
npx @modelcontextprotocol/inspector --cli http://127.0.0.1:8000/mcp --method tools/list

# Call a tool
npx @modelcontextprotocol/inspector --cli http://127.0.0.1:8000/mcp \
  --method tools/call --tool-name search_hiv_metadata \
  --tool-arg 'q=["Lilongwe"]' --tool-arg country=MWI
```

If auth is on, add `--header "Authorization: Bearer <token>"`. Against production:

```bash
npx @modelcontextprotocol/inspector --cli https://hivtools.org/mcp --method tools/list \
  --header "Authorization: Bearer $(terraform -chdir=infra output -raw api_token)"
```

### From Claude Desktop

Claude Desktop's local MCP servers expect a stdio command, not this server's
streamable-HTTP endpoint directly — bridge the two with
[`mcp-remote`](https://www.npmjs.com/package/mcp-remote).

1. Start the local server: `make dev`.
1. In Claude Desktop, open **Settings → Local MCP servers → Edit config** and
   add an entry:
   ```json
   {
     "mcpServers": {
       "hivtools-local": {
         "command": "npx",
         "args": ["-y", "mcp-remote", "http://localhost:8000/mcp", "--allow-http"]
       }
     }
   }
   ```
   If auth is on, append `"--header", "Authorization:Bearer <token>"` to `args`.
1. Save and reconnect. Claude Desktop starts two `mcp-remote` processes per
   connector; the first time either needs to fetch a dependency, they can race
   on the shared npm cache and both fail with `Server disconnected`. **View
   logs** on the failed connector shows the real cause if this happens: an
   `npm error ... EACCES ... mkdir _cacache` entry. It is npm cache corruption
   from the race, not a real permissions problem — clear it once and
   pre-populate the cache so the race can't recur, then reconnect:
   ```bash
   npm cache clean --force
   npx -y mcp-remote@latest --help
   ```

### Getting the production API key

The key is generated by Terraform, so any of these gives you the same value:

- **Terraform**, if you hold the state (see
  [infra/README.md](infra/README.md#setting-up-as-a-new-deployer)):
  `terraform -chdir=infra output -raw api_token`
- **Azure**, if you have access to the subscription: in the portal, open the
  Container App `ca-hivtools-mcp-prod`, then **Settings → Secrets** and show the
  value of `api-token`. Or from the command line:
  ```bash
  az containerapp secret show --subscription <subscription id> \
    -g rg-hivtools-mcp-prod -n ca-hivtools-mcp-prod \
    --secret-name api-token --query value -o tsv
  ```

Otherwise, ask whoever runs the deployment. It is also stored as the `API_TOKEN`
secret in GitHub, but GitHub never shows secret values again.

## Deploying

Production runs on Azure Container Apps. Publishing a GitHub Release deploys it,
and there are no manual steps.

**What you need:** write access to this repo, so you can merge to `main` and
publish releases. You don't need Azure access to release, only to
[change the infrastructure](#changing-the-infrastructure).

### Releasing a new version

1. In your PR, bump `version` in `pyproject.toml`:
   ```bash
   uv version --bump patch   # or minor / major; also updates uv.lock
   ```
1. Merge the PR to `main`.
1. Publish a GitHub Release. Its tag is the new version with a `v` in front.
   You can do this in the GitHub UI (**Releases → Draft a new release**) or with:
   ```bash
   gh release create v0.3.1 --target main --generate-notes
   ```
1. Watch the **release-main** workflow in the Actions tab, or with `gh run watch`.

The release runs
[`.github/workflows/on-release-main.yml`](.github/workflows/on-release-main.yml),
which:

1. Fails fast if the tag doesn't match the version in `pyproject.toml`.
1. Checks out the private data repo and builds the dataset with `make data`.
1. Builds the image and pushes it to Azure Container Registry, tagged `vX.Y.Z`.
1. Rolls a new Container Apps revision onto that image.
1. Smoke-tests the new revision: it checks the health and version endpoints, that
   `/data` refuses requests without the token, and that each source is served.
1. Publishes the documentation site to GitHub Pages.

To check it afterwards, the workflow run's summary page links to the live app
(the `production` environment). `https://<that host>/version` should return the
new version.

### Shipping new data

The data is built into the image, so new data also ships as a release:

1. Push the data change to hivtools-mcp-data. See
   [Updating the private data](docs/data.md#updating-it).
1. Bump the version and publish a release, as above. A release tag can only be
   used once, so a data-only release still needs a version bump.

The release builds from the data repo's default branch, or from the
`DATA_REPO_REF` variable if it is set on the `production` environment. The image
registry is private, but anyone who can pull the image can read the data.

### Changing the infrastructure

The Azure resources are managed with Terraform in [`infra/`](infra/). CI only
validates it. Someone applies it by hand, which needs Azure access and the local
state file. [`infra/README.md`](infra/README.md) covers this, plus the one-time
setup: provisioning, the GitHub variables and secrets the release needs, and
rotating the API token.

## Further documentation

- [docs/data.md](docs/data.md): the data sources, `datasets.yaml`, the private
  data repo, how the Parquet dataset is built, and how it gets into the image.
- [docs/api.md](docs/api.md): HTTP API reference for `/search` and `/data`,
  authentication, and every configuration setting.
- [infra/README.md](infra/README.md): the Azure infrastructure and one-time
  deployment setup.
- [app/knowledge/](app/knowledge/): the hand-written knowledge files the tools
  serve.
- [CONTRIBUTING.md](CONTRIBUTING.md): how to contribute.