Skip to main content
Glama
hivtools

hivtools-mcp

Official
by hivtools

hivtools-mcp

Release Build status codecov Commit activity License

An HTTP API and MCP 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.

Contents: How it works · Running locally · Testing the MCP server · Deploying · Further documentation

How it works

Architecture

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 is a FastAPI app. 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/ 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

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 and app/indicators.py steer it through this flow, and so do the server instructions in app/knowledge/instructions.md. Not every MCP client loads server instructions, so anything the model must know goes in the tool descriptions as well.

Related MCP server: Healthpoint MCP Server

Running locally

Requirements

Tool

Needed for

uv

Everything. It installs Python (3.10+) and the dependencies.

Git

Cloning the repos

Node.js 22.19+

Testing with the MCP Inspector (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

Optional: the private data. Ask a maintainer.

One-time setup

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 repo. You clone it into this repo (the folder is gitignored):

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 covers how the dataset is built and how to update the private data.

Running the server

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 for the full list.

Running the tests and checks

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

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.

Testing the MCP server

Use the MCP 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

  1. For a local server, start it: make dev.

  2. In another terminal, start the Inspector. It opens in your browser.

    npx @modelcontextprotocol/inspector
  3. 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.

  4. 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).

  5. 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 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.

  2. get_hiv_data with those IDs, for example indicator = ["untreated_plhiv_num"] and area_id = ["MWI_3_13_demo"].

  3. 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:

# 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:

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.

  1. Start the local server: make dev.

  2. In Claude Desktop, open Settings → Local MCP servers → Edit config and add an entry:

    {
      "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.

  3. 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:

    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): 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:

    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.

Releasing a new version

  1. In your PR, bump version in pyproject.toml:

    uv version --bump patch   # or minor / major; also updates uv.lock
  2. Merge the PR to main.

  3. 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:

    gh release create v0.3.1 --target main --generate-notes
  4. Watch the release-main workflow in the Actions tab, or with gh run watch.

The release runs .github/workflows/on-release-main.yml, which:

  1. Fails fast if the tag doesn't match the version in pyproject.toml.

  2. Checks out the private data repo and builds the dataset with make data.

  3. Builds the image and pushes it to Azure Container Registry, tagged vX.Y.Z.

  4. Rolls a new Container Apps revision onto that image.

  5. 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.

  6. 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.

  2. 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/. CI only validates it. Someone applies it by hand, which needs Azure access and the local state file. 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: 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: HTTP API reference for /search and /data, authentication, and every configuration setting.

  • infra/README.md: the Azure infrastructure and one-time deployment setup.

  • app/knowledge/: the hand-written knowledge files the tools serve.

  • CONTRIBUTING.md: how to contribute.

Related MCP Connectors

Related MCP Servers