chiron-mcp
This server does not currently have a deployable release on Glama. Follow the repository's setup instructions to install and configure it for your MCP client, including any required credentials and local dependencies.
chiron-mcp
An MCP 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" -> goneFigures 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 logged in.
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 serversThen 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 |
Chiron UI with the Ask tab, start here | |
Chiron itself (server-rendered pages, admin) | |
The Ask chat page on its own | |
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.
A trace under every answer, for finding out why one is wrong. Trace opens it. It shows a timeline of each step (start-up, every model call, every Chiron tool call, the server's checks), plus time, tokens and cost. Below that, at three levels of detail (Summary, Detailed, Debug), it shows each step: the model's summarised reasoning, the exact input and output of each tool call, where each figure in the answer came from, and the raw stream. Checks point at the step where something went wrong:
a figure that appears in no tool result, with the nearest count Chiron did return;
a cohort the model wrote by hand;
a field Chiron silently ignored;
a count rule Chiron does not read;
a spelling variant left out.
A call log in the server's output: one line per model call and per Chiron call, with the user, dataset, model, tokens, cost, tool, status and time, so a conversation can be followed end to end with grep. See The call log.
Who is asking: opened on its own, the Ask page's top bar shows the person's name and their access level on this dataset (full access, de-identified, aggregate only), resolved the way every tool resolves them, so the bar states what the answers obey. Inside the Chiron UI the bar stays empty: Chiron's own header and dataset page already show the name, the dataset and the level.
/whoamireturns the same as JSON and, for a staff account, the names of the request headers that reach the server and the values of those that may carry a sign-on identity, so a deployment behind a sign-on gateway can see what it is sent before trusting any of it.A usage page at
/usage: how many answers, what they cost, how long they took and how many failed, for a period, per day, per person, per model and per dataset, with a CSV download. Everyone sees their own total at the foot of the Ask sidebar; a superuser gets the link, sees everyone, and gives and takes access on each dataset there. Fed by a ledger with one row per answer and no question text, kept for good. See The usage page.
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, which also has a prompt to paste into Claude to
do the whole setup, and how to bring the 10,000-patient dataset along.
Ask for a Chiron you already run
askpatch/ adds Ask to an existing Chiron deployment without changing it: one container
built from that deployment's own Chiron, one route in its web server, one switch in its
UI, removable the same way. See askpatch/README.md.
The demo data
Four datasets, the useful one being 300 synthetic patients (Synthea) with conditions, encounters, observations, medications and procedures:
Dataset | Subjects | Content |
| 300 | Synthetic clinical records. The one to demo with |
| 6 | Chiron's own test fixture |
| 2 | Test fixture, the only one with event-date rules configured |
| 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.
Related MCP server: Legacy-to-FHIR Mapping MCP Server
Table of contents
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 ( | 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 qualifytable_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 |
|
|
|
|
|
|
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.
git clone https://github.com/ri-ds/chiron-mcp.git
cd chiron-mcp
./install.shinstall.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:
CHIRON_MCP_USERNAME=<user> .venv/bin/python scripts/harness.pyIt 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:
{
"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:
.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> --revokeSetup 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-chironandvendor/is4r-chiron-ui. Do not clone orpip installthem. Thechironpackage 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
./install.sh
docker compose up -d
.venv/bin/python scripts/bootstrap_demo.py
./scripts/run_all.shCheck: 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
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.The warehouse connection string:
postgresql://user:pass@host:port/dbname. Also in the compose file, asCHIRON_SQL_ALCHEMY_CONNECTION_STRING. If Chiron runs in Docker it may sayhost.docker.internal; from outside the container that islocalhost.The Django username the server should act as.
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
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.pyCheck: 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
.venv/bin/python scripts/grant_access.py --list
.venv/bin/python scripts/grant_access.py --user <user> --datasets <dataset> --level deidThis 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
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 :8900run_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.
Check: ask the chat "how many patients are in ?" 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. Use the same variables.
If something goes wrong
Symptom | What it means | Fix |
| Discovery failed | Set |
| Wrong host project | Set |
| Working as designed | Grant it with |
| Working as designed | Use a dedicated service account |
| The account is | Correct behaviour. Use |
| The UI pins eslint 9, a plugin wants 8 |
|
| Running Django with | Already handled in |
Tools missing after restart | Config not loaded | Check the JSON parses, paths are absolute, and you edited the right file |
| The client did not honour | Set |
| 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 |
| required | Django user the server acts as |
|
| Ceiling. Clamps whatever the |
| (all with a grant) | Comma-separated allowlist |
| off | Enables the operations tools (also requires |
| off | Lets |
|
| Cap on |
| auto-discovered | The |
|
| The Django host project |
|
| Host project's settings module |
| (host project's) | Metadata database: a SQLite file path, or a |
| (host project's) | SQLAlchemy warehouse URL |
|
| Where Query and Report links point |
|
|
|
|
| Port for the HTTP endpoint. The address is always |
|
|
|
| (generated) | Key that signs OAuth tokens. Unset, a random one is kept in |
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):
CHIRON_MCP_TRANSPORT=http CHIRON_MCP_USERNAME=<user> .venv/bin/chiron-mcpscripts/run_all.sh starts it alongside the other servers. Point a client at it by URL:
claude mcp add --transport http chiron http://localhost:8765/mcp{ "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:
CHIRON_MCP_USERNAME=<user> .venv/bin/python -m tests.protocol_httpSigning 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.
CHIRON_MCP_TRANSPORT=http CHIRON_MCP_HTTP_AUTH=oauth .venv/bin/chiron-mcpThe 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, per person:
The user's own
ChironUseron 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_LEVELstill clamps everyone, so PHI stays opt-in.An aggregate-only user is refused row-level tools; the operations tools need
CHIRON_MCP_OPERATORand 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:
.venv/bin/python -m tests.protocol_oauthThe Ask server (python -m chiron_mcp.webapp) adds:
Variable | Default | Meaning |
|
| Port for the chat page |
| off | Lets a logged-in superuser use Ask as themselves. The fallback identity can never be a superuser |
|
| How long a conversation can be continued. After that its transcript is deleted |
|
| How long one answer may take before it is stopped and the page says to ask again |
|
| Conversation index, answer traces ( |
| on |
|
|
| Where the call log goes (one line per model call and per Chiron call, see The call log): the server's own output, a file path to append to, or |
|
| The usage ledger, one row per answer, behind the usage page: a file path, or |
| (kept) | Ledger rows older than this many days are deleted when the server starts |
| (none) | A Django group whose members see everyone's usage and who has what access, as superusers do |
|
| Passed to Claude Code as |
See .env.example. A superuser is refused in analyst mode; point it at a dedicated service user.
The tools
Orient
Tool | Purpose |
| Datasets this identity can reach, its access level on each, and why anything is refused |
| Search a dataset's variables by name or description |
| One variable in full, including the exact field names required to filter on it |
| Distinct values with subject counts |
Build a cohort
Tool | Purpose |
| Apply one transformation; returns the new |
| Which date/age restrictions apply to a criteria set |
| How many subjects match |
Hand off to the Chiron UI
Tool | Purpose |
| Turn a cohort built in conversation into real Chiron filters and return a link. Requires |
Get results
Tool | Purpose |
| One page of rows; columns are a plain list, no |
| Complete result set, capped, with an acknowledged-count interlock |
| Distinct patients per group of one or two Category variables, variants merged, as structured rows and cells. Small counts masked for |
| Chiron's own analysis-view pivot (preformatted text); |
| Browse saved reports ( |
| Run a saved report by id |
| 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 |
| What the identity's Results tab holds now: filters, columns with their aggregation, sort, and a preview |
| 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 |
| Rename, re-describe, change filters, or make public/private; add, remove, reorder, sort or re-aggregate columns. Validates and previews before saving |
| Permanently delete. |
Operations
Gated behind CHIRON_MCP_OPERATOR and a staff account.
Tool | Purpose |
| Run Chiron's own validator. Non-empty means the next ETL will refuse to run |
| The dataset's shape as a Mermaid ER diagram |
| The ETL audit trail, which has no HTTP API |
| 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 |
|
|
|
|
| |
|
|
|
|
|
|
|
|
|
|
| |
|
|
|
|
|
|
So the working sequence is always: describe the concept, then filter it.
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) # -> 838Date and age restrictions ("diagnosed between 2015 and 2020") are a second step on a criteria set, and take two transformations:
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 |
| yes, identifiable | yes |
| yes, with PHI variables automatically swapped for de-identified forms | yes |
| 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 |
|
|
|
|
|
|
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.
Five hazards handled explicitly
No silent provisioning.
get_request_chironuser(chiron/authorization.py:61-72) writes aChironUseras a side effect of a read, granting atDataset.auto_access_level, which isphion some datasets. This server refuses instead and says what to create deliberately.Never a
Nonechironuser.CohortDefProcessor.__init__:42substitutesSystemChironUser(hardcoded PHI, every permission group, all datasets) when passedNone.identity.checked_chironuser()makes that a hard error rather than a silent escalation.An errored cohort is never executed.
clean_cohort_defreduces an errored definition to an empty list, and an emptycohort_defmatches every subject. Running one would silently return the whole dataset, so the server refuses.A failed filter returns no reusable cohort. On failure
chiron_edit_cohortomits thecohort_defkey entirely and returnsunchanged_cohort_definstead, so a failed transformation cannot be chained into an accidental whole-dataset query.A name is withheld on every path, not only as a column. A synthetic dataset's dictionary marks no
has_phi, so Chiron's own pages let a de-identified account without subject details add a first or last name column or filter on one. Here such a concept is refused wherever it could show or single out a person: as a column (listed, exported, in Results, in a report, added later), as the values of a field, in a description with statistics, as a breakdown or crosstab dimension, and as a filter (how many people named X have asthma is a question about a person), whether the filter arrives throughchiron_edit_cohort, in a hand-writtencohort_defor inside a saved report. A report or Results tab that already holds such a column runs with it withheld and says so.tests/permissions_adversarial.pytries each of these as that account.
Verifying
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 tests/permissions.py # what each kind of account cannot get
.venv/bin/python tests/permissions_adversarial.py # every way past one's level: tools, questions, the server
.venv/bin/python -m tests.protocol # real MCP stdio handshake
.venv/bin/python tests/usage.py; .venv/bin/python tests/header_login.py # offline: the ledger, the header sign-ontests/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.
tests/permissions.py is the permission suite: one question set (a count, a list with names,
a breakdown, the Results tab, a report with names, another dataset) asked through /ask as six
identities it makes for the run, a de-identified account with and without subject details, an
aggregate-only one, a full-access row under the ceiling, a login with no row on the dataset,
and admin. From each answer, its events and its trace (every tool result) it asserts what that
account must not get: no button, no patient-level tool and no unmasked small count for
aggregate; no identifying column in any tool result, the Results tab or a saved report for
de-identified without details; nothing at all for the login without a row; and for everyone
that another person's conversation cannot be continued, their trace cannot be read, request
headers show to staff only and the usage page shows one's own rows. It warns when a dataset's
auto-create rule would hand a newcomer more than aggregate access, and fails on it once header
sign-on is on.
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.
tests/permissions_adversarial.py attacks instead of asking. As a de-identified account without
subject details, an aggregate-only one and a login with no row (plus a control that may see
names), it tries every way past the level: every tool called in-process with the arguments a
model could be talked into (the values of a name field, a breakdown or crosstab by last name, the
statistics of a name, a filter on a name, a hand-written cohort_def with one, a name column
added to Results or a report afterwards, a shared report that holds names, an export, another
dataset); adversarial questions through /ask (claimed authority, injected tool calls,
obfuscation in uppercase or base64, membership questions with real names, other datasets); and
the web server itself (no session, a forged one, forged identity headers, cross-site origins on
reads and on writes, another person's trace, conversation and usage rows, the CSV scope, path
tricks, the MCP route). It does not trust the answer: every real first and last name in the
dataset is read from the warehouse and searched for in the answer and in every tool result the
trace holds, and the control account proves the detector sees names where they are allowed. A
policy layer reports what the rules permit by design (birthdate, zip and gender per patient at
the de-identified level; what a masked breakdown still lets one derive; what Chiron's own pages
would show the same account) as observations. --no-model runs the tool and server layers
alone, in under a minute; --layer picks layers.
Known limits
Crosstab needs a configured root collection.
run_analysisreadsdataset.root_collection.event_id_fieldand dereferences it without a null check. Datasets that leave it unset get a clear refusal instead of anAttributeError.Most collections are not event collections. Where
event_date_fieldis 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. Passignore_warningsto 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_selectedmean "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_crosstabreturns 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_breakdowndoes 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
aggaccounts, 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; seedocs/chiron-ui-integration.mdfor 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 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.
A second patch, also for upstream: Chiron's relative-date rule between two event collections
("within 90 days after") read each event date straight off the collection table
(LongitudinalCombo.generate_sql_alchemy_clauses_for_event), which is where a single-value
concept lives. A dataset loaded with every concept multivalue, as the Synthea bundles are,
keeps its dates in lookup tables, so the rule failed with AttributeError: <date concept>
for Chiron's own query builder as much as for chiron_query's within. The where builder
now resolves an event date through the subquery's lookup alias (joined like the filters'
lookups) or, for a table without a subquery of its own, a correlated lookup
(WhereBuilder.get_event_date_column).
Against a Chiron that has neither fix (any deployment's own checkout, through askpatch/
or CHIRON_MCP_CHIRON_SRC), chiron_mcp/chiron_patches.py applies both in this server's
process right after Django starts, and says so on stderr. Chiron's files are not touched,
and Chiron's own web app, in its own process, keeps its behaviour until upstream merges
them. askpatch/chiron-fixes.patch is the same change as a patch for a Chiron checkout.
The call log
The Ask server writes one line to its own output (stderr, which Docker collects) for every call an answer makes, so a conversation, a user or a day can be followed through the log with grep: the trace under an answer holds the detail for one answer, and this is the operational copy for all of them.
2026-11-03T15:40:12.345Z ask request answer=2f1a… thread=6c3e… user=demouser dataset=synthea-10k turn=1 followup=0
2026-11-03T15:40:14.010Z ask model answer=2f1a… thread=6c3e… user=demouser dataset=synthea-10k call=1 model=us.anthropic.claude-haiku-5-5 in=4210 cache_read=3900 cache_write=0 out=88 stop=tool_use tools=query ms=1830
2026-11-03T15:40:14.420Z ask chiron answer=2f1a… thread=6c3e… user=demouser dataset=synthea-10k call=1 tool=query status=ok count=179 group=g1 rows=0 size=612 ms=410
2026-11-03T15:40:17.902Z ask model answer=2f1a… thread=6c3e… user=demouser dataset=synthea-10k call=2 model=us.anthropic.claude-haiku-5-5 in=8390 cache_read=7800 cache_write=0 out=152 stop=end_turn ms=3480
2026-11-03T15:40:20.114Z ask answer answer=2f1a… thread=6c3e… user=demouser dataset=synthea-10k turn=1 followup=0 status=ok model=us.anthropic.claude-haiku-5-5 model_calls=2 chiron_calls=1 chiron_errors=0 retries=0 in=12600 cache_read=11700 cache_write=0 out=240 cost_usd=0.00421 cost_total=0.00421 ms=7770 model_ms=5310 chiron_ms=410 rc=0Every line is a UTC timestamp, the word ask, the kind, then key=value pairs. The kinds
are request (the question arrived), model (one model call: the tokens it used, why it
stopped, the tools it chose), chiron (one Chiron tool call: how it ended, the count or
the number of patient rows it returned, the size of the result), retry (the API was
busy), reroute (Bedrock refused the model and another answered, as a new conversation),
restart (the conversation had expired and the question was asked as a new one) and
answer (the whole answer: status, calls, tokens, this answer's cost and the
conversation's running total, time split between the model and Chiron). Each line of an
answer carries the same answer, thread, user and dataset, so:
docker compose logs --no-log-prefix ask | grep ' ask ' # everything
docker compose logs --no-log-prefix ask | grep 'thread=6c3e' # one conversation, end to end
docker compose logs --no-log-prefix ask | grep ' ask answer ' | grep 'user=demouser' # one person's answers
docker compose logs --no-log-prefix ask | grep ' ask chiron ' | grep -c 'status=error' # failed Chiron calls
docker compose logs --no-log-prefix ask | grep ' ask answer ' | grep -o 'cost_usd=[0-9.]*' | cut -d= -f2 | paste -sd+ | bc # spendNo question text, tool argument, filter value or result is written: a filter value can
be a patient's name, and the server log is read by people a trace is not shown to. An
error line carries a status, not the message; the trace has the message. The lines come
from the trace, so CHIRON_MCP_TRACE=0 silences them too; CHIRON_MCP_CALL_LOG names a
file to append to instead of stderr, or off. In the deploy bundle and the add-on the
lines are in docker compose logs ask.
The usage page
/usage on the Ask server (in the deploy bundle and the add-on, <chiron address>/ask/usage)
shows what Ask is being used for and what it costs: answers, spend, average answer time,
errors and tokens for a period (today, 7, 30 or 90 days, all, or a date range), spend per
day, and the same per model and per dataset, with the most recent answers listed one per
row and a CSV download of the period. Signed in as anyone, the page shows that person's
own answers. Signed in as a Django superuser, or as a member of the group
CHIRON_MCP_USAGE_GROUP names, it shows everyone's, a table per person (click a name to
see only theirs), and Who has what: every account with its access level on every
dataset as Ask applies it under the ceiling, the row's own level where that differs, the
two flags (identifying columns, workspace), the last sign-in, and a warning for any
dataset whose auto-create rule would give a newcomer access on their first visit to
Chiron's own pages, in red when that level is full access. A superuser changes access
right there: a level per dataset (no access, aggregate only, de-identified, full
access), whether a row may see identifying columns, and admin (superuser) for anyone
but themselves, so nobody can lock everyone out; full access and admin take a second
click. Every change goes into a change log under the table, with who made it, and
into the server's output.
The Ask page itself tells everyone their own total at the foot of its sidebar: questions
asked through Ask and what they cost, all time, refreshed as each answer finishes. The
link to the usage page sits beside it only for those who may see everyone's; the page's
back link leads to Chiron's dataset list (CHIRON_MCP_UI_URL).
Behind it is a ledger, usage.sqlite in the state directory (CHIRON_MCP_USAGE_DB
moves it or switches it off), with one row per answer: who, which dataset, at what
access level, when, which model, how many model and Chiron calls, the tokens, the cost
(this answer's share, and the conversation's running total), the time split between the
model and Chiron, and how it ended. No question text, no tool argument, no filter value,
no result, so it is kept for good (CHIRON_MCP_USAGE_RETENTION_DAYS prunes it) and is
safe to show an administrator; the trace under an answer has the details, for the person
who asked, while the conversation lives. The row is written when the answer finishes, at
the same moment as the call log's answer line, with the same figures.
Answers given before the ledger existed can be added from the call log, which carries
the same fields: scripts/usage_backfill.py reads call-log lines from stdin or files and
writes the answers the ledger does not have yet (rows it has are kept), so it is safe to
run again. In the deploy bundle:
docker compose logs --no-log-prefix ask | docker compose exec -T ask python /app/scripts/usage_backfill.py/usage.json?period=30d is what the page reads, /usage.csv?period=... the download,
and /usage/access.json the who-has-what table with the change log; all three follow
the same rule about whose rows a person may see. POST /usage/access with
{"username", "dataset", "level"}, {"username", "dataset", "subject_details"} or
{"username", "superuser"} is the change, superusers only, same-site only.
Troubleshooting
Symptom | Cause |
| Set |
| Set |
| Expected. Grant it with |
| Use a dedicated service user, or set |
| The identity is |
| Wrong field name; call |
Tools missing in the client | Restart it; check the paths in the config are absolute |
| The client ignored |
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
trace.py the trace under each answer, and the hooks that feed the call log and the ledger
calllog.py the call log: one line per model call and per Chiron call
usage.py the usage ledger: one row per answer, kept for good, summarised for /usage
header_login.py sign-on by request header, for a Chiron behind a gateway (deploy bundle)
chiron_patches.py the two Chiron fixes, applied at run time when the host lacks them
webui/index.html the chat page (markdown, tables, charts, buttons)
webui/usage.html the usage page: spend, time, errors per person, model, dataset and day
scripts/
harness.py deployment check with no MCP involved
grant_access.py grant, update, revoke or list ChironUser rows
usage_backfill.py fill the usage ledger from call-log lines
export_dataset.py pack one dataset (dictionary + warehouse) into a file
deploy/ the whole stack in Docker: compose.yml, setup.sh, check.py
askpatch/ Ask as an add-on for an existing Chiron: compose.yml, proxy snippets, UI patch, 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
permissions.py what each kind of account cannot get out of Ask, as six identities
permissions_adversarial.py every way past one's access level: tools in-process, questions, the web server
usage.py the usage ledger, its summaries and the backfill from the call log (offline)
header_login.py sign-on by request header: created on first sight, kept in step (offline)
chiron_patches.py the run-time fixes land on an unfixed Chiron and leave a fixed one aloneBuilt against Chiron 6.5.4.
License
MIT
This server cannot be deployed
Maintenance
Related MCP Connectors
Query Health Gorilla FHIR patients, conditions, medications and lab results.
Query Clinical.ly clinical-research data via read-only API tools using your OAuth permissions.
Search ClinicalTrials.gov: find studies, retrieve results, match patients to candidate trials.
Ask your Quickbase apps in plain language. Read-only by default; writes need human approval.
Related MCP Servers
- AlicenseNot gradedqualityDmaintenanceEnables natural language exploration of OMOP CDM databases for concept discovery, patient count queries, and cohort SQL generation with support for multiple database backends.1MIT
- AlicenseNot gradedqualityDmaintenanceEnables natural-language querying of a mock legacy healthcare database and returns validated FHIR resources (Patient, Observation, Condition).MIT
- FlicenseNot gradedqualityAmaintenanceEnables LLM-based agents and MCP-compatible clients to query and manipulate clinical data through natural-language prompts by providing full CRUD operations on FHIR resources, document ingestion, semantic search, and LOINC terminology lookup.-
- FlicenseNot gradedqualityAmaintenanceEnables LLM-based agents and MCP-compatible clients to query and manipulate FHIR-compliant clinical data through natural-language prompts, with CRUD operations, document ingestion, semantic search, and terminology lookup.-