chiron-mcp
Click on "Deploy Server".
Wait a few minutes for the server to deploy. Once ready, it will show a "Started" state.
In the chat, type
@followed by the MCP server name and your instructions, e.g., "@chiron-mcpBuild a cohort of asthma patients and show their diagnosis dates."
That's it! The server will respond to your query, and you can continue using it as needed.
Here is a step-by-step guide with screenshots.
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 patients in this dataset?" -> 300
"What conditions are most common?" -> obesity 64 · hypertension 57
"Build a cohort of asthma patients" -> cohort_def
"How many is that?" -> 20
"Show me their diagnoses and dates" -> 20 records with diagnosis datesQuickstart
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/rohzzn/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 |
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? Show the top 10 medications as a chart Build an asthma cohort and open it in Chiron
Questions take roughly 25 to 60 seconds, because the model makes several Chiron tool calls. Each one shows as a chip while it works.
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: cogstack-mcp-wrapper
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 16 MCP tools, 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. No tool writes a snapshot, so using this
server never disturbs a researcher's saved workspace in the Chiron UI.
It is read-only. Nothing here runs an ETL, alters a schema, or saves a report.
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/rohzzn/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. Sixteen chiron_* tools appear.
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 | Reserved; the write tool is not implemented |
|
| Cap on |
| auto-discovered | The |
|
| The Django host project |
|
| Host project's settings module |
| (host project's) | Metadata SQLite |
| (host project's) | SQLAlchemy warehouse URL |
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 |
| Break a cohort down by one or two variables |
| Browse saved reports, or open one's stored definitions |
| Run a saved report by id |
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).
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.
Four 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.
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 # real MCP stdio handshakeKnown limits
chiron_save_cohort_as_reportis not implemented.CHIRON_MCP_ALLOW_SAVEis reserved but currently does nothing; there is no path from the model back into the Chiron UI.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. "No white race value" and "a non-white race value" are different questions.
chiron_crosstabreturns preformatted text, not structured rows. That is what Chiron's analysis engine hands back.Remote deployment is not supported. The transport is stdio and execution is in-process, so the server must run where it can reach both databases. Serving it remotely would need HTTP transport plus an authentication story Chiron does not currently have.
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
filters.py cohort filter input schemas, transcribed from validate_form()
server.py the 16 tools
scripts/
harness.py deployment check with no MCP involved
grant_access.py grant, update, revoke or list ChironUser rows
tests/
safety.py permission and whole-dataset invariants
smoke.py end-to-end tool exercise
protocol.py real MCP stdio handshakeBuilt against Chiron 6.5.4.
License
MIT
This server cannot be deployed
Maintenance
Related MCP Connectors
Query your org's data in natural language — read-only MCP access to SQL, NoSQL, files & warehouses.
Knowledge graph ingestion, entity search, ontology analysis, and CoSync scoring.
Semantic search across 5 US government healthcare databases.
Read patient-authorized EHR records: medications, labs, conditions, allergies. Consent-bounded.
Related MCP Servers
- AlicenseBqualityAmaintenanceQuery clinical datasets like MIMIC-IV and eICU with natural language, supporting both tabular EHR data and clinical notes through a unified interface.1143MIT
- AlicenseNot gradedqualityDmaintenanceEnables querying and analyzing medical cohorts from CogStack via the Model Context Protocol, supporting SNOMED-CT search, cohort creation, and demographic analysis.MIT
- 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 gradedqualityBmaintenanceEnables natural-language querying of a mock legacy healthcare database and returns validated FHIR resources (Patient, Observation, Condition).MIT