chiron-mcp
by ri-ds
README.md
# chiron-mcp
An [MCP](https://modelcontextprotocol.io) server that exposes **Chiron**, a cohort-discovery
platform for human-subject research data, to an LLM client such as Claude.
Ask *"how many asthma patients are there, and what were their diagnosis dates?"* and the model
finds the right variable, builds the cohort, counts it, and pulls the rows, without anyone
writing SQL, and without bypassing Chiron's own access rules.
```
"How many female patients have asthma?" -> 445 [Query · 445]
"How many patients do not have hypertension?" -> 7,861 [Query · 7,861]
"Asthma patients earning over $50,000" -> 702 [Query · 702]
"Break down asthma patients by gender and race" -> a 2x5 table and chart
"Save a type 2 diabetes cohort as a report" -> 686 [Report]
"Which 5 conditions affect the most patients?" -> a chart
A conversation, each question building on the last:
"How many patients have asthma?" -> 841 [Query · 841]
"How many of them are women?" -> 445 [Query · 445]
"And of those, how many earn over $50,000?" -> 370 [Query · 370]
"Save that as a report called 'Asthma women'" -> [Report]
"Rename it to 'Asthma, women, 50k+'" -> [Report]
"Delete that report" -> gone
```
Figures are from the 10,000-patient Synthea deployment and are checked against raw SQL by
`tests/ask_e2e.py`. Note the first two: the data spells values several ways ("Asthma",
"ASTHMA", "Asthm"; "F" and "f"), and "without hypertension" has a plausible wrong answer
(4,758) that a naive filter produces. Both are handled.
---
## Quickstart
Everything is bundled: Chiron itself, 300 synthetic patients, the MCP server, a chat
page, and the Chiron UI with an **Ask** tab. Nothing external is required beyond Docker,
Python 3.12+, Node 20+, and the [Claude Code CLI](https://claude.com/claude-code) logged in.
```bash
git clone https://github.com/ri-ds/chiron-mcp.git
cd chiron-mcp
./install.sh # python venv + dependencies
docker compose up -d # Postgres warehouse
.venv/bin/python scripts/bootstrap_demo.py # data dictionary + real ETL + demo users
./scripts/run_all.sh # starts all three servers
```
Then open **<http://localhost:5173>** and click **"Login as standard user with deidentified
data access"**. The demo login page is one click; there is no password to type.
The first `run_all.sh` installs the UI's npm dependencies, which takes a few minutes.
After that it starts in seconds.
### What you get
| URL | What |
|---|---|
| **http://localhost:5173** | **Chiron UI with the Ask tab**, start here |
| http://localhost:8001 | Chiron itself (server-rendered pages, admin) |
| http://localhost:8900 | The Ask chat page on its own |
| http://localhost:8765/mcp | The MCP tools over HTTP, for MCP clients on this machine |
In the UI, **Ask** sits in the header beside Aggregate. Type a question and you get an
answer with real figures, markdown tables, charts, and a button through to Chiron's
filter builder:
> What are the most common conditions?
> How many patients have asthma? *then* How many of them are women?
> Break down asthma patients by gender and race
> Show the top 10 medications as a chart
> Save an asthma cohort as a report *then* Rename it *then* Delete it
> Show asthma patients in my results *then* Add birthdate and city *then* Sort by birthdate, newest first
What the Ask tab does:
- **Counts and cohorts**, with spelling variants merged ("Asthma", "ASTHMA", "Asthm") and
negation done right ("without hypertension" is 7,861, not the 4,758 a naive filter gives).
- **Follow-up questions.** The chat is a conversation: "them", "those patients", "now only
women" refer to the previous answer's cohort. **New chat** starts another, and past
conversations stay in a searchable sidebar. Under each answer, three suggested
follow-ups; each new chat offers six fresh starters, one per feature.
- **Breakdowns** by one or two variables ("by gender", "by gender and race"): exact distinct
patients per group and per cell, as a table and chart, with patients who have no recorded
value called out so the table adds up.
- **Results tab**: put a cohort into Chiron's Results, add or remove columns, reorder
them, sort (ask again to reverse), and change how each column is shown: stacked (one row
per value), list, count, most frequent, has value ("whether they take albuterol", as
true/false or a count), or sum, average, median, min, max for numbers and earliest or
latest for dates. Each change is a new step in Chiron's undo history, and a **Results**
button opens the updated table.
- **Reports**: save a cohort as a Chiron report, then rename it, change its filters or
columns (the same column, sort and aggregation changes as Results), make it public or
private, or delete it. Only the report's creator can change or delete it, and deletion
needs its exact name.
- **Query · N** under any cohort answer: one click loads those filters into Chiron's query
builder, and N is Chiron's own count for them.
- **Tables and charts** for rankings and comparisons. Two-variable breakdowns chart as
grouped bars with a legend, so no bar is ever an unlabelled slice of the table.
Questions take roughly 10 to 40 seconds, because the model makes several Chiron tool
calls. Each one shows as a chip while it works.
### Everything in Docker, at your own address
`deploy/` runs the same system as containers behind one HTTPS address and a password,
with Chiron's metadata in Postgres: `cd deploy && ./setup.sh --domain chiron.example.org`.
See [deploy/README.md](deploy/README.md), which also has a prompt to paste into Claude to
do the whole setup, and how to bring the 10,000-patient dataset along.
### The demo data
Four datasets, the useful one being **300 synthetic patients** (Synthea) with conditions,
encounters, observations, medications and procedures:
| Dataset | Subjects | Content |
|---|---|---|
| `synthea-small` | 300 | Synthetic clinical records. The one to demo with |
| `dataset2_stored` | 6 | Chiron's own test fixture |
| `dataset1_stored` | 2 | Test fixture, the only one with event-date rules configured |
| `dataset3_stored` | 0 | Dictionary only, no rows |
Real figures out of the box: obesity 64, hypertension 57, depression 35, asthma 20.
Three demo accounts, each a button on the login page (password `demo1234` if you ever
need it): **`demouser`** (de-identified, what the chat is bound to), **`agguser`**
(aggregate only, refused row-level data), **`admin`** (PHI, staff).
### Pointing at a real deployment
Skip `bootstrap_demo.py` and set `CHIRON_MCP_METADATA_DB` and `CHIRON_MCP_WAREHOUSE_URL`
at your own Chiron. See [Configuration](#configuration).
## Table of contents
- [Quickstart](#quickstart)
- [What Chiron is](#what-chiron-is)
- [What this server does](#what-this-server-does)
- [Why it runs in-process](#why-it-runs-in-process-and-not-over-chirons-rest-api)
- [Install](#install)
- [Docker deployment](deploy/README.md)
- [Setup guide for an AI agent](#setup-guide-for-an-ai-agent)
- [Configuration](#configuration)
- [The tools](#the-tools)
- [How a query actually gets built](#how-a-query-actually-gets-built)
- [Access model and safety](#access-model-and-safety)
- [Known limits](#known-limits)
- [Troubleshooting](#troubleshooting)
- [Project layout](#project-layout)
---
## What Chiron is
Chiron (`is4r-chiron`) is a reusable Django application for exploring research and clinical
data, with a focus on human-subject and longitudinal datasets. Researchers use it to build
patient cohorts and pull reports without writing queries.
Its defining idea is that **the data dictionary is database rows, not a config file**:
| Layer | Holds | Reached via |
|---|---|---|
| **Metadata database** | The data dictionary (`Dataset` → `Collection` → `Concept`), plus users, access grants, saved reports and ETL logs | Django ORM |
| **Warehouse** | The actual subject data, one Postgres **schema per dataset** | SQLAlchemy only |
The warehouse's tables and columns are **generated at runtime from the dictionary rows**, not
migrated. The two databases cannot be joined, because there is no Django database router, so the ORM
describes the *shape* of the data while SQLAlchemy queries the data itself.
A researcher's work is expressed as two JSON documents:
- **`cohort_def`**: which subjects qualify
- **`table_def`**: which columns to show
Chiron compiles those into a single SELECT against the warehouse, injecting the user's
subject-visibility filter into the WHERE clause. A saved "report" is just a stored
`cohort_def` + `table_def` pair.
## What this server does
It exposes that machinery as **23 MCP tools** (19 for analysts, 4 for operators), so cohort
building can happen in conversation.
It is **stateless by design**: every tool takes a `cohort_def` and returns one, so the model
holds the working query and Chiron holds none of it.
It is **read-only by default**. Nothing here runs an ETL or alters a schema. Only with
`CHIRON_MCP_ALLOW_SAVE=1` can tools save or change the identity's own reports, or write its
query workspace (the Results tab), and workspace writes add a snapshot rather than erase
the undo history.
## Why it runs in-process, and not over Chiron's REST API
Three findings from reading the Chiron source, in order of how much they constrain the design:
**1. The query API cannot accept a cohort you give it.**
`QueryToolsViewSet._get_cohort_def` (`chiron/api/viewsets/query_tools.py:204-214`) discards any
supplied definition and returns the caller's *active server-side snapshot*, despite a docstring
promising otherwise. An HTTP wrapper would therefore have to overwrite a researcher's live
workspace on every turn, and `CohortDefSnapshot.save` deletes their redo stack
(`chiron/models/user_models.py:189-193`).
**2. Chiron has no token authentication.** No `rest_framework.authtoken`, no JWT, no OAuth, no
API key, no service-account concept, and no login *endpoint*. `rest_framework.urls` is commented
out. `REST_FRAMEWORK` is unset, so DRF's stock defaults (Session + Basic) apply by accident.
The result is an API split down the middle. HTTP Basic works on the metadata endpoints and is
rejected on **every endpoint that queries data**, because those viewsets set
`authentication_classes = [CsrfExemptSessionAuthentication]`, which *replaces* DRF's default list
rather than extending it:
| Basic auth works | Session cookie only |
|---|---|
| `/api/v2/auth/`, `/api/v2/dataset/` | `query_tools/` (count, preview, export) |
| `{ds}/concepts/`, `{ds}/collections/` | `cohort_def/`, `table_def/`, `analysis_def/` |
| `{ds}/concept_categories/` | `reports/`, `report_tools/`, `analysis_tools/` |
**3. The operator surface has no HTTP API at all.** ETL logs, dictionary validation and the
schema visualiser exist only as staff HTML pages and management commands.
So the server calls Chiron's own Python in the same interpreter, binding to one Django user and
resolving that user's real `ChironUser` per dataset. Chiron's permission logic does the filtering.
## Install
Requires Python 3.12+, a Chiron checkout, and network access to a Chiron metadata database and
warehouse.
```bash
git clone https://github.com/ri-ds/chiron-mcp.git
cd chiron-mcp
./install.sh
```
`install.sh` creates `.venv` and installs `mcp` plus Chiron's own base requirements. It finds
Chiron automatically when `is4r-chiron` sits beside this project or is pip-installed; otherwise
set `CHIRON_MCP_CHIRON_SRC`.
Then confirm the deployment resolves, before touching any MCP client:
```bash
CHIRON_MCP_USERNAME=<user> .venv/bin/python scripts/harness.py
```
It prints the Chiron source, both database locations, and every dataset that user can reach,
including the ones it refuses and why. **If this doesn't work, nothing else will.**
### Register with Claude
Copy the block from `claude_mcp_config.example.json` into your Claude config, filling in
absolute paths. On macOS that file is
`~/Library/Application Support/Claude/claude_desktop_config.json`:
```json
{
"mcpServers": {
"chiron": {
"command": "/abs/path/to/chiron-mcp/.venv/bin/chiron-mcp",
"env": {
"CHIRON_MCP_METADATA_DB": "/abs/path/to/chiron_metadata.sqlite3",
"CHIRON_MCP_WAREHOUSE_URL": "postgresql://user:pass@localhost:5432/chiron",
"CHIRON_MCP_USERNAME": "your-service-user",
"CHIRON_MCP_MAX_ACCESS_LEVEL": "deid"
}
}
}
}
```
Restart the client. The `chiron_*` tools appear (19, or 23 with `CHIRON_MCP_OPERATOR`).
### Grant the service user access
The server **never** creates access records. A Django user needs a `ChironUser` row per dataset:
```bash
.venv/bin/python scripts/grant_access.py --user <user> --datasets <dataset> --level deid
.venv/bin/python scripts/grant_access.py --list
.venv/bin/python scripts/grant_access.py --user <user> --datasets <dataset> --revoke
```
## Setup guide for an AI agent
Written for an AI assistant setting this up on someone's machine. Every step has a check.
Do not skip a check, and do not continue past a failed one.
> **Chiron and its UI are already vendored** at `vendor/is4r-chiron` and
> `vendor/is4r-chiron-ui`. Do not clone or `pip install` them. The `chiron` package on
> PyPI is an unrelated DNA basecaller, and the real repository is behind organisation SSO.
**Do this.** It is four commands, needs nothing external, and ships 300 synthetic
patients. Do not ask the user which setup they want, and do not go looking for an existing
Chiron deployment: everything is bundled.
The only exception is at the very bottom of this section, and it applies only when the
user has already told you, unprompted, that they run their own Chiron and want to query
their real data.
---
### Setup
```bash
./install.sh
docker compose up -d
.venv/bin/python scripts/bootstrap_demo.py
./scripts/run_all.sh
```
**Check:** `bootstrap_demo.py` ends with `Demo ready. 4 dataset(s) queryable.` and lists
`synthea-small: 300 subjects`. Then `run_all.sh` prints three URLs and stays running.
**Check:** `curl -s -o /dev/null -w '%{http_code}' http://localhost:5173/` returns 200.
Tell the user to open <http://localhost:5173>, click the **"deidentified data access"**
login button, and then **Ask**. **You are done.**
Requirements, which you should confirm first: Python 3.12+, Node 20+, Docker running, and
the Claude Code CLI installed and logged in (`claude --version`). The first `run_all.sh`
spends a few minutes on `npm install`.
---
### Only if the user already runs their own Chiron
Skip this unless the user has said so themselves. The bundled demo above is the right
answer for everyone else, and 300 patients is plenty to work with.
Same repo, but skip `bootstrap_demo.py` and aim the two database variables at their
deployment. Do **not** run `bootstrap_demo.py` in this case: it builds a separate demo
database and never touches theirs, but running it wastes several minutes.
#### 1. Gather four things from the user
1. The **metadata database**: usually a SQLite file holding datasets, users and access
grants. In a Docker deployment it is often a bind mount; look in the compose file for a
path mapped to `db.sqlite3`.
2. The **warehouse connection string**: `postgresql://user:pass@host:port/dbname`. Also in
the compose file, as `CHIRON_SQL_ALCHEMY_CONNECTION_STRING`. If Chiron runs in Docker it
may say `host.docker.internal`; from outside the container that is `localhost`.
3. The **Django username** the server should act as.
4. The **URL of their Chiron UI**, for hand-off links.
If you cannot get 1 and 2, stop and ask. Do not guess a path or a password.
#### 2. Verify before configuring anything
```bash
CHIRON_MCP_USERNAME=<user> \
CHIRON_MCP_METADATA_DB=/path/to/their_metadata.sqlite3 \
CHIRON_MCP_WAREHOUSE_URL=postgresql://user:pass@localhost:5432/chiron \
.venv/bin/python scripts/harness.py
```
**Check:** it ends with `N dataset(s) reachable by this identity`, N at least 1.
Lines marked `[refused]` are not failures; they mean that user has no access record for
that dataset, which is correct behaviour. If **every** dataset is refused, see step 3.
If this fails, nothing else will work. Fix it here.
#### 3. Grant access only if asked
```bash
.venv/bin/python scripts/grant_access.py --list
.venv/bin/python scripts/grant_access.py --user <user> --datasets <dataset> --level deid
```
This writes to **their** metadata database. Tell the user which account and which dataset
before doing it, and get their agreement. Never pick a superuser: the server refuses to
start as one in analyst mode, and that refusal is deliberate.
#### 4. Run against their deployment
```bash
export CHIRON_MCP_METADATA_DB=/path/to/their_metadata.sqlite3
export CHIRON_MCP_WAREHOUSE_URL=postgresql://user:pass@localhost:5432/chiron
export CHIRON_MCP_USERNAME=<user>
export CHIRON_MCP_UI_URL=http://their-chiron-ui # for hand-off links
export CHIRON_MCP_ALLOW_SAVE=1 # enables "open this in Chiron"
.venv/bin/python -m chiron_mcp.webapp # Ask chat at :8900
```
`run_all.sh` honours the same variables, but here do not start `serve_chiron.py`:
they already have a Chiron running, and a second one on `:8001` would confuse everyone.
Either run only the Ask server as above, or add the **Ask** tab to their own UI following
[docs/chiron-ui-integration.md](docs/chiron-ui-integration.md).
**Check:** ask the chat "how many patients are in <their dataset>?" and confirm with the
user that the number matches what Chiron shows.
#### 5. Also register with Claude Desktop, if they want it there too
See [Register with Claude](#register-with-claude). Use the same variables.
---
### If something goes wrong
| Symptom | What it means | Fix |
|---|---|---|
| `Could not find the Chiron source tree` | Discovery failed | Set `CHIRON_MCP_CHIRON_SRC` to the directory containing `chiron/` |
| `No Django project settings at ...` | Wrong host project | Set `CHIRON_MCP_PROJECT_DIR` to the directory with `manage.py`, and `CHIRON_MCP_BASE_SETTINGS` if its settings module is not `project.settings` |
| `has no ChironUser on dataset` | Working as designed | Grant it with `scripts/grant_access.py`, with the user's agreement |
| `is a superuser. Refusing to start` | Working as designed | Use a dedicated service account |
| `cannot see subject-level data` | The account is `agg` | Correct behaviour. Use `chiron_crosstab` or `chiron_concept_values` |
| `npm install` fails on peer deps | The UI pins eslint 9, a plugin wants 8 | `run_all.sh` already passes `--legacy-peer-deps`; use that flag if installing by hand |
| `You must set settings.ALLOWED_HOSTS` | Running Django with `DEBUG=False` | Already handled in `chiron_mcp/django_settings.py`; make sure you launch through `scripts/serve_chiron.py` |
| Tools missing after restart | Config not loaded | Check the JSON parses, paths are absolute, and you edited the right file |
| `Server disconnected` in the client | The client did not honour `cwd` | Set `command` to `.venv/bin/chiron-mcp` and drop `args` and `cwd` entirely |
| `pip install chiron` "worked" but nothing imports | Wrong package | Chiron is vendored here and is not on PyPI |
### What to tell the user when you are done
Which Django user the server is bound to, its access ceiling, and exactly which datasets
are reachable. If you granted any access during setup, say so
explicitly including the level: that is a change to their deployment, not just to this tool.
## Configuration
Every setting is an environment variable. Defaults are the conservative end of each choice.
| Variable | Default | Meaning |
|---|---|---|
| `CHIRON_MCP_USERNAME` | **required** | Django user the server acts as |
| `CHIRON_MCP_MAX_ACCESS_LEVEL` | `deid` | Ceiling. Clamps whatever the `ChironUser` actually has, so PHI is opt-in |
| `CHIRON_MCP_DATASETS` | *(all with a grant)* | Comma-separated allowlist |
| `CHIRON_MCP_OPERATOR` | off | Enables the operations tools (also requires `is_staff`) |
| `CHIRON_MCP_ALLOW_SAVE` | off | Lets `chiron_open_in_ui` save reports and load cohorts into Chiron, which powers the Query and Report buttons |
| `CHIRON_MCP_MAX_EXPORT_ROWS` | `50000` | Cap on `chiron_export_table` |
| `CHIRON_MCP_CHIRON_SRC` | auto-discovered | The `is4r-chiron` checkout |
| `CHIRON_MCP_PROJECT_DIR` | `<src>/test_project` | The Django host project |
| `CHIRON_MCP_BASE_SETTINGS` | `project.settings` | Host project's settings module |
| `CHIRON_MCP_METADATA_DB` | *(host project's)* | Metadata database: a SQLite file path, or a `postgresql://` URL |
| `CHIRON_MCP_WAREHOUSE_URL` | *(host project's)* | SQLAlchemy warehouse URL |
| `CHIRON_MCP_UI_URL` | `http://localhost:3000` | Where Query and Report links point |
| `CHIRON_MCP_TRANSPORT` | `stdio` | `stdio`, or `http` for a streamable-HTTP endpoint at `/mcp`. See [HTTP endpoint](#http-endpoint) |
| `CHIRON_MCP_HTTP_PORT` | `8765` | Port for the HTTP endpoint. The address is always `127.0.0.1` |
| `CHIRON_MCP_HTTP_AUTH` | `none` | `oauth` makes callers of the HTTP endpoint sign in as a Chiron user; each call then acts as that user. `CHIRON_MCP_USERNAME` is not needed in that mode |
| `CHIRON_MCP_OAUTH_SECRET` | *(generated)* | Key that signs OAuth tokens. Unset, a random one is kept in `CHIRON_MCP_STATE_DIR` (default `~/.cache/chiron-mcp/oauth.key`) |
### HTTP endpoint
By default an MCP client launches `chiron-mcp` itself and talks to it over stdio. Set
`CHIRON_MCP_TRANSPORT=http` to run it as a long-lived server instead, with the same tools
at `http://127.0.0.1:8765/mcp` (streamable HTTP):
```bash
CHIRON_MCP_TRANSPORT=http CHIRON_MCP_USERNAME=<user> .venv/bin/chiron-mcp
```
`scripts/run_all.sh` starts it alongside the other servers. Point a client at it by URL:
```bash
claude mcp add --transport http chiron http://localhost:8765/mcp
```
```json
{ "mcpServers": { "chiron": { "type": "http", "url": "http://localhost:8765/mcp" } } }
```
It is bound to `127.0.0.1` and there is no setting to change that. Requests whose
`Host` header is not local are refused, which stops a web page from reaching it through
DNS rebinding.
**By default it has no authentication**: every caller acts as `CHIRON_MCP_USERNAME`, with
whatever that user can see and, if `CHIRON_MCP_ALLOW_SAVE` is on, change. Check it with:
```bash
CHIRON_MCP_USERNAME=<user> .venv/bin/python -m tests.protocol_http
```
#### Signing in as a Chiron user (OAuth)
Set `CHIRON_MCP_HTTP_AUTH=oauth` and the endpoint stops being one shared identity. A
client must sign in, and every tool call then runs as the person who did, with their own
access level and permission groups on each dataset. `scripts/run_all.sh` turns this on.
```bash
CHIRON_MCP_TRANSPORT=http CHIRON_MCP_HTTP_AUTH=oauth .venv/bin/chiron-mcp
```
The server is its own small OAuth 2.1 authorization server (dynamic client registration,
authorization code with PKCE, refresh tokens), which MCP clients already speak. Nothing
changes in how you add it; the client notices the `401` and opens a browser. In Claude
Code that is `/mcp`, then the server, then **Authenticate**.
The browser lands on a sign-in page served by this server, which turns an identity into a
Chiron user in one of two ways:
- **Continue as *name***, if that browser is already logged in to Chiron. The page reads
Chiron's own session cookie, so whatever Chiron uses to log people in (including SSO)
is what authenticated them, and no password is typed here.
- **A Chiron username and password**, checked by Django's `authenticate()`, so the
deployment's own authentication backends decide.
Either way the result is an existing, active Django user, and that username is the
token's subject. From there the rules are the ones in
[Access model and safety](#access-model-and-safety), per person:
- The user's own `ChironUser` on each dataset decides what they see. Nobody is
provisioned: someone with no access record on a dataset is refused, as on stdio.
- `CHIRON_MCP_MAX_ACCESS_LEVEL` still clamps everyone, so PHI stays opt-in.
- An aggregate-only user is refused row-level tools; the operations tools need
`CHIRON_MCP_OPERATOR` and a staff account; saves land in the signed-in user's own
workspace and reports.
- A superuser may sign in as themselves, as in the Ask tab: the superuser refusal is
about a *service* being configured as one.
What it keeps, and for how long:
- **One signing key and nothing else.** Tokens and client registrations are signed
blobs the server verifies rather than stores, so they survive a restart. The key is
not Django's `SECRET_KEY` (the bundled demo project ships a public one). Delete the key
file to sign everyone out.
- Access tokens last an hour and refresh tokens thirty days. A token stops working at
once if its user is deactivated or changes password.
- Signed tokens cannot be revoked one at a time, so there is no revocation endpoint.
- Clients may register loopback redirect URIs only, matching the loopback-only endpoint.
Check the whole flow, as two users with different access:
```bash
.venv/bin/python -m tests.protocol_oauth
```
The Ask server (`python -m chiron_mcp.webapp`) adds:
| Variable | Default | Meaning |
|---|---|---|
| `CHIRON_MCP_WEB_PORT` | `8900` | Port for the chat page |
| `CHIRON_MCP_ALLOW_SUPERUSER` | off | Lets a *logged-in* superuser use Ask as themselves. The fallback identity can never be a superuser |
| `CHIRON_MCP_THREAD_TTL_HOURS` | `12` | How long a conversation can be continued. After that its transcript is deleted |
| `CHIRON_MCP_TURN_SECONDS` | `240` | How long one answer may take before it is stopped and the page says to ask again |
| `CHIRON_MCP_STATE_DIR` | `~/.cache/chiron-ask` | Conversation index and the neutral working directory Claude runs in |
See `.env.example`. A superuser is refused in analyst mode; point it at a dedicated service user.
## The tools
### Orient
| Tool | Purpose |
|---|---|
| `chiron_datasets` | Datasets this identity can reach, its access level on each, and why anything is refused |
| `chiron_find_concepts` | Search a dataset's variables by name or description |
| `chiron_describe_concept` | One variable in full, **including the exact field names required to filter on it** |
| `chiron_concept_values` | Distinct values with subject counts |
### Build a cohort
| Tool | Purpose |
|---|---|
| `chiron_edit_cohort` | Apply one transformation; returns the new `cohort_def` plus a plain-English description |
| `chiron_event_rule_options` | Which date/age restrictions apply to a criteria set |
| `chiron_count_cohort` | How many subjects match |
### Hand off to the Chiron UI
| Tool | Purpose |
|---|---|
| `chiron_open_in_ui` | Turn a cohort built in conversation into real Chiron filters and return a link. Requires `CHIRON_MCP_ALLOW_SAVE=1` |
### Get results
| Tool | Purpose |
|---|---|
| `chiron_run_table` | One page of rows; columns are a plain list, no `table_def` authoring |
| `chiron_export_table` | Complete result set, capped, with an acknowledged-count interlock |
| `chiron_breakdown` | Distinct patients per group of one or two Category variables, variants merged, as structured rows and cells. Small counts masked for `agg` |
| `chiron_crosstab` | Chiron's own analysis-view pivot (preformatted text); `chiron_breakdown` is usually better |
| `chiron_saved_reports` | Browse saved reports (`scope="mine"` for your own), or open one's stored definitions |
| `chiron_run_saved_report` | Run a saved report by id |
| `chiron_column_options` | How one variable can be shown as a column: its default and every aggregation, with the settings each takes |
### The Results tab
Both write tools require `CHIRON_MCP_ALLOW_SAVE=1`.
| Tool | Purpose |
|---|---|
| `chiron_results` | What the identity's Results tab holds now: filters, columns with their aggregation, sort, and a preview |
| `chiron_update_results` | Put a cohort into Results and add, remove, reorder, sort or re-aggregate columns. Previews before saving, and saves a new snapshot so Chiron's undo still works |
### Change saved reports
Both require `CHIRON_MCP_ALLOW_SAVE=1`, and both act only on reports the identity created.
| Tool | Purpose |
|---|---|
| `chiron_update_report` | Rename, re-describe, change filters, or make public/private; add, remove, reorder, sort or re-aggregate columns. Validates and previews before saving |
| `chiron_delete_report` | Permanently delete. `confirm_name` must equal the report's exact current name |
### Operations
Gated behind `CHIRON_MCP_OPERATOR` **and** a staff account.
| Tool | Purpose |
|---|---|
| `chiron_validate_dictionary` | Run Chiron's own validator. Non-empty means the next ETL will refuse to run |
| `chiron_schema_diagram` | The dataset's shape as a Mermaid ER diagram |
| `chiron_etl_history` | The ETL audit trail, which has no HTTP API |
| `chiron_explain_access` | Why this identity can or cannot use a variable, Chiron's verdicts verbatim |
## How a query actually gets built
The non-obvious part, and the reason `chiron_describe_concept` exists:
**Chiron publishes no machine-readable schema for cohort filter inputs.** A filter is submitted
as HTML *form field names*, and which names apply depends on the variable's processor. The only
source of truth is each processor's `validate_form()` body in `chiron/processors/cohort_def/`.
Those were read directly and transcribed into `chiron_mcp/filters.py`:
| Processor | Required input | Also accepts |
|---|---|---|
| `CohortDefCategory` | `selected_categories` (list) | `exclude_selected` |
| `CohortDefBoolean` | `selected_categories` (list) | |
| `CohortDefNumber` | `cd_numeric_min` / `cd_numeric_max` | `exclude_selected`, `include_null_and_missing` |
| `CohortDefNumberWithCategories` | `cd_numeric_min` / `cd_numeric_max` | `selected_categories`, and the above |
| `CohortDefDate` / `CohortDefDateDeid` | `query_type` | `cd_numeric_min`, `cd_numeric_max`, `days_ago` |
| `CohortDefDetailedAge` | `cd_age_min_year` / `cd_age_min_day` / `cd_age_max_year` / `cd_age_max_day` | |
| `CohortDefText` | `chiron_text_field_selection` (newline-separated) | `ignore_warnings` |
| `CohortDefOntology` | `chiron_ontology_field_selection` | `include_ontology_unknown`, `ignore_warnings` |
So the working sequence is always: **describe the concept, then filter it.**
```python
chiron_describe_concept(ds, "condition__description") # -> CohortDefCategory
chiron_edit_cohort(ds, [], {
"type": "add_entry",
"concept_id": "condition__description",
"selected_categories": ["Asthma"],
}) # -> cohort_def
chiron_count_cohort(ds, cohort_def) # -> 838
```
Date and age restrictions ("diagnosed between 2015 and 2020") are a **second** step on a
criteria set, and take two transformations:
```python
chiron_event_rule_options(ds, cohort_def, entry_id) # -> which options apply
chiron_edit_cohort(ds, cohort_def, {"type": "create_event_rule", "entry_id": eid})
chiron_edit_cohort(ds, cohort_def, {
"type": "modify_event_rule", "entry_id": eid,
"option_type": "date_range", "date_min": "2015", "date_max": "2020",
})
```
Note the key is `option_type`, not `option_id`. `chiron_event_rule_options` asks Chiron directly
rather than relying on a transcription, so it cannot drift from the code.
## Handing a cohort to the Chiron UI
The point of building a cohort in conversation is usually to keep working on it somewhere else.
`chiron_open_in_ui` writes the cohort into Chiron proper and returns a link.
It has two modes, because there are two genuinely different things a user means by "open it in
Chiron".
**`mode="report"` (default, non-destructive).** Saves the cohort and columns as a private Chiron
report and returns `/<dataset>/reports/<id>`. Nothing the researcher currently has open is
touched. They can then use Chiron's own "load as active" to pull it into the builder when ready.
**`mode="workspace"` (destructive).** Writes the cohort and table straight into the user's live
query workspace and returns `/<dataset>/query`, so the link opens the filter builder with the
filters already applied. This is the mode that makes a conversation become a UI page.
Be deliberate about the second one. It mirrors what Chiron's own `load_as_active` does
(`chiron/api_v2/viewsets/report_tools.py:108-133`): it calls `clear_history` on both snapshot
models, so the researcher's current cohort, current table **and their entire undo history on
that dataset** are replaced. Ask before using it.
### Why a link is enough
The React UI's query route fetches `GET /api/v2/<dataset>/cohort_def/`
(`src/store/cohortSlice.ts:319`), and that endpoint returns
`CohortDefSnapshot.get_active_cohort_def(request.chironuser)`. So writing an active snapshot for
that ChironUser *is* the hand-off; no URL parameters or deep-link encoding are involved, and the
UI needs no changes.
One consequence worth understanding: the hand-off lands in the workspace of the Django user the
server is bound to (`CHIRON_MCP_USERNAME`). If several people share one service account, they
share one workspace and will overwrite each other. Give each person their own account, or use
`mode="report"`, which has no such problem.
Set `CHIRON_MCP_UI_URL` to point the links at your deployment (default `http://localhost:3000`).
### Columns, aggregation and the Results tab
Chiron groups a table's rows by its **stacked** columns and combines every other column
within each group. With only gender stacked there is one row per gender; stack patient-level
columns such as birthdate and city to get about one row per patient. Each column is
`{"concept_id", "aggregation", "settings", "alias"}`, where `aggregation` is `"stack"` or one
of the variable's methods from `chiron_column_options` (Chiron's own default is stack on the
patient record and `list_distinct` elsewhere). Settings are checked against the method's
inputs, so `has_value` takes `{"values": [...], "return_value": "boolean" | "count"}`.
`chiron_update_results` and `chiron_update_report` take the same edits, applied through
Chiron's own table transformations in this order: `remove_columns`, `add_columns`,
`set_aggregation` (`[{column, aggregation, settings}]`), `order` (these columns first, the
rest after them), `sort` (`[{column, direction: "asc" | "desc"}]`, replacing the sort;
`[]` clears it). A column can be named by its entry id,
its concept id, or the label `chiron_results` gives ("medication description"); an ambiguous
or unknown name is refused with the columns that exist. Both preview the result before
saving, so a change Chiron cannot run is never stored.
Unlike `chiron_open_in_ui(mode="workspace")`, `chiron_update_results` saves new active
snapshots instead of clearing history, so Chiron's undo goes back to the previous table.
## Access model and safety
Access in Chiron is not a login check. It is a `ChironUser` record per (Django user, dataset),
carrying one of three levels plus `PermissionGroup` memberships.
| Level | Subject rows | Counts / crosstabs |
|---|---|---|
| `phi` | yes, identifiable | yes |
| `deid` | yes, with PHI variables automatically swapped for de-identified forms | yes |
| `agg` | **refused** | yes |
### Gates re-implemented here
Calling Chiron's Python directly skips three permission classes that exist **only** in the HTTP
layer. They are re-implemented in `chiron_mcp/identity.py`, and every tool calls them first:
| Gate | Mirrors |
|---|---|
| `require_subject_level` | `SubjectLevelAccess` (`chiron/api/permissions.py:64-79`) |
| `require_workspace` | `CanViewWorkspacePermission` (`chiron/api_v2/permissions.py:37-45`) |
| `require_analysis_view` | `AnalysisViewOn` (`chiron/api/permissions.py:55-62`) |
`Concept.user_can_view_concept_stats` is **not** a substitute for the first: it explicitly
*allows* agg users for non-PHI concepts (`data_definition_models.py:1114-1120`). The agg block is
the DRF class, and only the DRF class.
### Four hazards handled explicitly
1. **No silent provisioning.** `get_request_chironuser` (`chiron/authorization.py:61-72`) writes a
`ChironUser` as a side effect of a *read*, granting at `Dataset.auto_access_level`, which is
`phi` on some datasets. This server refuses instead and says what to create deliberately.
2. **Never a `None` chironuser.** `CohortDefProcessor.__init__:42` substitutes `SystemChironUser`
(hardcoded PHI, every permission group, all datasets) when passed `None`.
`identity.checked_chironuser()` makes that a hard error rather than a silent escalation.
3. **An errored cohort is never executed.** `clean_cohort_def` reduces an errored definition to an
empty list, and an empty `cohort_def` matches **every subject**. Running one would silently
return the whole dataset, so the server refuses.
4. **A failed filter returns no reusable cohort.** On failure `chiron_edit_cohort` omits the
`cohort_def` key entirely and returns `unchanged_cohort_def` instead, so a failed transformation
cannot be chained into an accidental whole-dataset query.
### Verifying
```bash
CHIRON_MCP_USERNAME=<deid-user> .venv/bin/python tests/safety.py # invariants
CHIRON_MCP_USERNAME=<agg-user> .venv/bin/python tests/safety.py # agg refusals
.venv/bin/python -m tests.protocol_http # HTTP endpoint, loopback only
.venv/bin/python -m tests.protocol_oauth # HTTP endpoint, OAuth login per user
.venv/bin/python tests/ask_e2e.py # the Ask tab, end to end
.venv/bin/python -m tests.protocol # real MCP stdio handshake
```
`tests/ask_e2e.py` asks real questions through the Ask server exactly as the page does, and
checks everything a user would click against ground truth computed independently in raw SQL:
the stated figure, that the Query button loaded into Chiron makes Chiron's own count agree, that
a report opens with columns and the same subjects, that refusals leak nothing, and that no answer
contains an email address or file path. It is written against the 10,000-patient Synthea dataset.
The page's starter questions come from a pool of about 130 built from the dataset's own values
and tagged by feature; `tests/ask_e2e.py --suggestions` asks every one of them and fails any that
errors, is refused, shows no figure, or lacks the button its feature promises, so a starter cannot
ship broken.
It also runs whole conversations: three follow-ups that each narrow the last cohort (841, 445,
370, each checked in Chiron); "now show their medications", which must give the asthma
patients' figures (albuterol 587) and not the whole dataset's (696); a report saved, renamed,
refiltered and deleted, with Chiron's API checked after every step; a refused attempt to delete
someone else's report; and another user presenting someone's conversation id, who must get a
fresh conversation. Breakdowns are checked cell by cell, and a chart that shows one row of a
two-way table as if it were the whole fails. Reports the run creates are deleted at the end,
and so are its conversations and their transcripts.
Point it at a Chiron that shares file locking with the Ask server (`CHIRON_API`, default
`http://localhost:8000`). With the bundled demo that is `http://localhost:8001`.
## Known limits
- **Crosstab needs a configured root collection.** `run_analysis` reads
`dataset.root_collection.event_id_field` and dereferences it without a null check. Datasets that
leave it unset get a clear refusal instead of an `AttributeError`.
- **Most collections are not event collections.** Where `event_date_field` is unset, event rules do
not apply at all.
- **Regex text filters are broken upstream.** Chiron's own tests document `/slash-wrapped/` terms
as returning wrong answers.
- **Terms absent from the data are rejected** with `Entry X not found`. Pass `ignore_warnings` to
accept the filter anyway.
- **Most variables are multi-value**, living in their own lookup table joined one-to-many, so a
filter matches a subject if *any* of their values match. That makes `exclude_selected` mean
"has at least one record that is something else", not "has none": on the demo data it turns
"patients without hypertension" into 4,758 instead of the right 7,861. The Ask server's prompt
directs the model to Chiron's criteria-set count rule ("exactly 0") instead.
- **`chiron_crosstab` returns preformatted text**, not structured rows. That is what Chiron's
analysis engine hands back, and it needs a root-collection setting the Synthea data lacks.
`chiron_breakdown` does the same job with Chiron's count engine and returns structured rows.
- **Breakdowns group Category and Text variables only.** Numbers and dates have too many values
to group; the model filters on ranges and counts each one instead. At most two variables, and
the largest groups first (10 for one variable, 6 per axis for two, or `top`).
- **A report lists distinct rows, not patients, unless the dataset has a patient-ID
variable.** Reports default to the variables the cohort filters on; with no ID column,
Chiron collapses identical rows, so an asthma-in-women report on synthea-10k shows two rows
for 445 subjects. The subject count is right; add an ID variable to the data dictionary to
get one row per patient.
- **For `agg` accounts, masked cells can sometimes be inferred** by subtracting from a total.
That is equally true of Chiron's own analysis view, whose rules the breakdown follows.
- **A SQLite metadata database must not be shared across the Docker boundary.** If Chiron runs
in a container with its metadata SQLite bind-mounted from the host, and this server runs on
the host against the same file, the two sides cannot see each other's file locks (Docker
Desktop does not carry them across its VM). Concurrent writes then produce "database disk
image is malformed" errors, and at worst real corruption. Run this server where Chiron runs:
both on the host, as the bundled demo does, or both in containers on the same mount. Keep a
backup (`sqlite3 db ".backup copy"`) either way.
- **Ask conversations are stored as Claude Code transcripts**, which contain patient data.
They are deleted when you delete the conversation or after `CHIRON_MCP_THREAD_TTL_HOURS`; see
`docs/chiron-ui-integration.md` for the details and a cleanup command for older ones.
- **Remote deployment is not supported.** Execution is in-process, so the server must run
where it can reach both databases, and the [HTTP endpoint](#http-endpoint) listens on
the loopback interface only. Its OAuth login identifies each caller as a Chiron user,
which is the authentication a remote endpoint would need, but serving it beyond this
machine also needs TLS and non-loopback redirect URIs, and neither is built.
## A Chiron bug this project found
`CohortDefProcessor._generate_cd_entry_template` (`chiron/processors/abstract/cohort_def_processor.py`)
has a mutable default argument, `additional_args={}`, and returns it after writing into it. Every
filter entry built without arguments in the same process is therefore **the same dict**, so
building a second filter silently rewrites the first: its values, its exclude flag and its
`entry_id`. In Chiron's web app each request serialises its entries quickly, which mostly hides
it. Anything that builds several filters in one process does not get that protection.
The bundled copy under `vendor/` is patched (`additional_args=None`, a fresh dict per call), and
`chiron_edit_cohort` also deep-copies its result, so this server is correct against an unpatched
Chiron too. The one-line fix belongs upstream.
## Troubleshooting
| Symptom | Cause |
|---|---|
| `Could not find the Chiron source tree` | Set `CHIRON_MCP_CHIRON_SRC` to the checkout containing `chiron/` |
| `No Django project settings at ...` | Set `CHIRON_MCP_PROJECT_DIR` (the directory with `manage.py`) and `CHIRON_MCP_BASE_SETTINGS` |
| `has no ChironUser on dataset` | Expected. Grant it with `scripts/grant_access.py` |
| `is a superuser. Refusing to start` | Use a dedicated service user, or set `CHIRON_MCP_OPERATOR=1` deliberately |
| `cannot see subject-level data` | The identity is `agg`. Use `chiron_crosstab` or `chiron_concept_values` |
| `Please select at least one value` | Wrong field name; call `chiron_describe_concept` and use its `filter_input` |
| Tools missing in the client | Restart it; check the paths in the config are absolute |
| `Server disconnected` / `No module named 'chiron_mcp'` | The client ignored `cwd`. Use the `.venv/bin/chiron-mcp` console script as `command`, with no `args` and no `cwd` |
## Project layout
```
chiron_mcp/
config.py environment configuration and path discovery
bootstrap.py starts Django in-process, keeping stdout clean for the protocol
django_settings.py inherits the host project's settings, overrides only the databases
identity.py identity resolution and the re-implemented permission gates
oauth.py OAuth login for the HTTP endpoint: sign in as a Chiron user
filters.py cohort filter input schemas, transcribed from validate_form()
server.py the 23 tools
webapp.py the Ask chat server embedded in the Chiron UI
webui/index.html the chat page (markdown, tables, charts, buttons)
scripts/
harness.py deployment check with no MCP involved
grant_access.py grant, update, revoke or list ChironUser rows
export_dataset.py pack one dataset (dictionary + warehouse) into a file
deploy/ the whole stack in Docker: compose.yml, setup.sh, check.py
tests/
safety.py permission and whole-dataset invariants
smoke.py end-to-end tool exercise
protocol.py real MCP stdio handshake
protocol_http.py the same over the HTTP endpoint, plus its loopback-only checks
protocol_oauth.py the HTTP endpoint's OAuth login, as two users with different access
ask_e2e.py the Ask tab end to end, checked against raw SQL and Chiron's API
```
Built against Chiron 6.5.4.
## License
MIT
This server cannot be deployed
Maintenance
ActivityMaintained
ResponsivenessNo issues