FHIRBridge
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.
export FHIRBRIDGE_VAULT_PASSPHRASE='use-a-long-passphrase'
# De-identified by default. --free-text omit drops all free text: the recommended mode for real data.
npx fhirbridge pack patient-export.json --vault patient.vault.json --free-text omit > context.mdFHIRBridge is a zero-config CLI, SDK and MCP server that sits between messy EHR FHIR R4 exports and an LLM:
Normalize: resolves references (
Type/id,urn:uuid:,#contained), decodes Base64 notes, strips XHTML narrative, repairs over-long ids and missing timestamps, and lints RxNorm codes.De-identify reversibly: removes or tokenizes the 18 HIPAA identifier categories, reduces the date of birth to a year, and shifts every other date by a secret per-patient offset that keeps intervals intact. Token mappings live in an AES-256-GCM vault. This is not strict Safe Harbor; see Privacy model and limits.
Compile: dense clinical Markdown (or SQL-on-FHIR-style tables) where every fact ends with a
[ResourceType/id]anchor.Verify: checks the model's answer against the record, catching invented ids, stopped drugs described as current, and wrong lab values. It then restores the real names and dates locally.
It runs fully offline, with no Docker, no JVM and no cloud API. The runtime dependencies are commander, zod and @modelcontextprotocol/sdk.
Benchmark
Every number below comes from a script in scripts/ run on the synthetic fixture in
tests/fixtures. To reproduce: npm run build && npm run bench, and
node --expose-gc scripts/scale-benchmark.mjs 10 100 1000.
One patient (scripts/benchmark.mjs):
Metric | Value |
Input | 195.9 KB, 95 resources (1 patient, 50 refills, 30 HbA1c results, Base64 notes, PDF) |
Tokens before → after | 50,145 → 793 (−98.4%) |
Pack time (normalize + de-identify + compile, median of 15) | 25 ms |
CLI cold start ( | 144 ms |
npm tarball | 268 KB |
Identifiers tokenized / dates shifted | 171 / 190 |
Planted PHI strings leaked into the context | 0 / 13 |
Date-shift offset leaked into the context | 0 |
Clinical terms preserved ( | 6 / 6 |
| 1 / 1 |
Bulk export (scripts/scale-benchmark.mjs, one NDJSON file per resource type):
Patients | Resources | Input | Load | Pack | µs / resource | Peak heap |
10 | 950 | 1.4 MB | 27 ms | 0.26 s | 301 | 19 MB |
100 | 9,500 | 14.1 MB | 168 ms | 1.8 s | 210 | 135 MB |
1,000 | 95,000 | 141.4 MB | 1.6 s | 25.5 s | 285 | 705 MB |
Cost per resource stays roughly flat as the export grows. Token counts are estimates at 4 bytes per token, applied the same way to input and output. Timings are from Node 24 on Windows x64 with an otherwise idle CPU. Real exports vary, so run the scripts on your own data.
Related MCP server: clinical-mcp
What you get
# CLINICAL SUMMARY CONTEXT (FHIRBridge v0.1.0 | De-identified: YES)
## 1. Patient Demographics [Patient/pat-1]
- Name: [PATIENT_1] | Gender: female | Age: 64 | Birth year: 1962 | ZIP: 021XX | MRN: [MRN_1] [Patient/pat-1]
## 4. Active Medications (Deduplicated by RxNorm)
- Metformin 500 MG Oral Tablet [RxNorm: 861007] — 1 tablet twice daily with meals (Authored: 2024-08-16, Status: active, Refills consolidated: 50) [MedicationRequest/medreq-104]
## 5. Discontinued / Stopped Medications (DO NOT TREAT AS ACTIVE)
- Lisinopril 10 MG Oral Tablet [RxNorm: 314076] — Status: stopped (2023-10-06, Reason: Dry cough) [MedicationRequest/medreq-088]
## 6. Longitudinal Labs & Vitals (Grouped by LOINC)
| Metric (LOINC) | Latest Value | Date | Reference Range | Historical Trend (Chronological) | Source Anchor |
| HbA1c (4548-4) | 7.8 % (HIGH) | 2024-08-14 | 4 - 5.6 % | 7.9% (2023-02-08) -> … -> 7.8% (2024-08-14) | [Observation/obs-301] |
## 7. Clinical Notes & Decoded DocumentReferences
- [2024-08-14] Progress Note by [PROVIDER_1]: Pt [PATIENT_1] ([MRN_1]) … Breast core biopsy: 2/12 cores positive. Foley catheter removed. Family history of Parkinson's disease. … [DocumentReference/doc-12]
## 8. Clinical Data Integrity Warnings (Auto-Detected by FHIRBridge)
- EXCLUDED: 1 resource(s) with status='entered-in-error' ([MedicationRequest/medreq-err-99]: Aspirin 81 MG Oral Tablet)
- CODE WARNING: [MedicationRequest/medreq-bad-1] display "Tiotropium 18 MCG Inhalation Capsule" conflicts with RxCUI 1116634 ("ticagrelor").Ask the model to cite the anchors. Then check its answer:
$ fhirbridge verify answer.txt --bundle patient-export.json --vault patient.vault.json
Citations checked: 3; issues: 2
[CRITICAL] CRITICAL_STATUS_CONTRADICTION MedicationRequest/medreq-088: … historical in the source record but the sentence presents it as current.
[CRITICAL] HALLUCINATED_RESOURCE_ID Observation/obs-k-1: … does not exist in the source record.
✖ Critical issues found. # exit code 1, so it can gate a pipeline
$ fhirbridge rehydrate answer.txt --vault patient.vault.json
Maria Elena Gonzalez's HbA1c improved to 7.8% on 2025-08-10 [Observation/obs-301]. …Why
Feeding raw FHIR to an LLM goes wrong in predictable ways:
Bloat. Most of an export is structural JSON, repeated XHTML narrative and Base64 attachments. Long records overflow context windows or get silently truncated by local runtimes.
Broken graphs. Medication names hide behind
medicationReference, labs hang off panels, and bulk exports carry ids over FHIR's 64-character limit or nometa.lastUpdated. Some datasets carry RxNorm codes that do not match their display text.De-identification that breaks the timeline. Shifting each resource's dates independently destroys the intervals clinicians reason about, such as days since admission or time between labs. FHIRBridge derives one secret offset per patient (HMAC of the patient id) and applies it to every date in that patient's graph, including
contained, extensions and free text. Time of day is preserved, so a 41 h 45 min gap stays 41 h 45 min. The offset itself is never written to any output.Naive scrubbers damage the medicine. Eponyms (
Foley,Swan-Ganz,Parkinson's,Hodgkin) get redacted as names, and clinical fractions (2/12 cores,4/5strength,PEEP 10/5) get rewritten as dates. FHIRBridge masks those patterns before it detects identifiers.Status hallucination. Summaries merge
stoppedandentered-in-errororders into the active medication list. FHIRBridge gates status at compile time and audits it again in the answer.
Install
npx fhirbridge --help # once published to npm
# from source:
git clone https://github.com/Digital-Healthcare-OpenSource/fhirbridge-context && cd fhirbridge-context
npm ci && npm run build && node dist/cli.js --helpRequires Node.js ≥ 22.12.
CLI
Command | What it does |
| Layers 1→3. De-identifies by default. Context goes to stdout and statistics to stderr. Without |
| Layer 4 citation audit. With |
| Restores tokens and reverses the dates the tool shifted. A vault with several patients requires |
| Resource counts, token savings, PHI found, |
| Pulls |
| Starts the MCP server on stdio. |
| Checks the Node version, configuration and vault key setup (prints no secret values). |
--no-deid (on pack and pull) switches de-identification off, prints a warning on stderr before anything is written, and produces output with real names, identifiers and dates. Never send that output to an external LLM.
--free-text omit is structured-only mode: every free-text field and every decoded document is dropped, so no narrative can carry an identifier past the scrubber. It is the recommended mode for real patient data (see Privacy model and limits). The default, scrub, keeps notes and redacts them on a best-effort basis.
pull refuses an --iss whose host is an IP literal in a private, loopback, link-local or cloud-metadata range (127.0.0.0/8, 10.0.0.0/8, 172.16.0.0/12, 192.168.0.0/16, 169.254.0.0/16, ::1, fc00::/7, fe80::/10, and IPv4-mapped forms), so a crafted URL cannot probe your internal network. localhost, 127.0.0.1 and [::1] stay allowed (plain http: is accepted for them, for local development). Pass --allow-private-network when your FHIR server really lives on an internal address. Only literals are checked: a host name is not resolved, so use egress controls if the name can point inside your network.
<input> can be a Bundle, a JSON array of resources, a single resource, an .ndjson file, or a Bulk $export directory of .ndjson files.
pull falls back to a compartment search when $everything is unsupported. In that search only HTTP 400/404/405 for a resource type are skipped, and each skipped type is reported as a warning; 401/403/408/429/5xx abort the pull instead of returning a partial record. Requests time out after 60 s, a single page is capped at 50 MB, and --max-pages applies per resource type.
The input limits apply to a pull too: total bytes and resources are counted across all pages (raw pages are not kept in memory) and the pull stops with exit 65 when a limit is exceeded. --max-pages must be an integer from 1 to 10000. Error messages never contain the server URL.
Configuration reference
Secrets come from the environment only, never from argv. Invalid values fail fast with exit 78 and name the variable (never its value).
Variable | Default | Purpose |
|
| Maximum total input size in MB (1-65536), also applied across all pages of a |
|
| Maximum number of FHIR resources accepted from one input (1-50000000). |
|
| Diagnostics format on stderr: |
|
|
|
| none | Derives the vault key (scrypt); at least 12 characters. |
| none | 64 hex chars; used instead of a passphrase. |
| none | Bearer token for |
A vault key is required: set FHIRBRIDGE_VAULT_PASSPHRASE or FHIRBRIDGE_VAULT_KEY. FHIRBridge does not silently create a key file. If you accept the weaker setup where the key sits beside the data, pass --allow-key-file explicitly.
Exit codes
Exit | Meaning | Codes |
0 | Success | |
1 | Verification found critical clinical issues (an expected outcome, not a crash) |
|
64 | Usage error: unknown command, invalid option, vault with several patients and no |
|
65 | Input is not usable: invalid JSON, too deeply nested, over a size limit, unknown |
|
66 | Input path does not exist |
|
69 | Pull failed: server error, refused address, network failure |
|
70 | Internal error (re-run with |
|
74 | Vault file I/O error |
|
75 | Temporary failure: vault changed by another process, or the pull timed out |
|
77 | Permission: wrong vault key, or the FHIR server refused the token (401/403) |
|
78 | Configuration: invalid |
|
MCP: Claude Desktop, Cursor, OpenCode
{
"mcpServers": {
"fhirbridge": {
"command": "npx",
"args": ["-y", "fhirbridge", "mcp", "--bundle", "/path/to/patient-export.json", "--vault", "/path/to/patient.vault.json"],
"env": { "FHIRBRIDGE_VAULT_PASSPHRASE": "use-a-long-passphrase" }
}
}
}Tool | Returns |
| The de-identified |
| De-identified resources filtered by type, code, shifted date range and status. |
| A citation audit of the agent's draft |
| EHR data-quality warnings (code, resource key, sanitized message), de-identification counts and token statistics |
Tool results are model input, so the MCP server never returns real PHI unless started with --allow-rehydrate. Every tool reads the de-identified graph only. Rehydrate on your side with fhirbridge rehydrate; use --allow-rehydrate only when the model runs locally. Even then, only tokens the server itself has shown to the model are restored.
SDK
import { packFile, verifyResponse, SafeHarborVault, StreamRehydrator } from 'fhirbridge';
const vault = await SafeHarborVault.open('patient.vault.json', { passphrase: process.env.VAULT_PASS });
const { context, graph } = await packFile('patient-export.json', { deid: true, vault });
await vault.save('patient.vault.json');
const answer = await llm(context.content); // your model call
// The vault maps pseudonymized ids back to `graph`; pass { patientKey } if it holds several patients.
const { report, rehydrated } = verifyResponse(answer, graph, vault);
if (!report.ok) throw new Error(report.issues.map((i) => i.code).join(', '));
// Streaming: tokens and dates split across SSE chunks are held back until complete.
const stream = new StreamRehydrator(vault);
for await (const chunk of llmStream) process.stdout.write(stream.push(chunk));
process.stdout.write(stream.end());Each layer is also published on its own: fhirbridge/normalizer, fhirbridge/deid, fhirbridge/compiler, fhirbridge/verifier.
Architecture
flowchart LR
subgraph Input
A1[Bundle / array / resource .json]
A2[NDJSON file]
A3[Bulk $export directory]
A4[SMART pull: $everything or compartment search]
end
A1 & A2 & A3 --> L[input-loader<br/>streaming, byte + resource budget]
A4 --> L2[smart-client<br/>same-origin paging, timeouts, size caps]
L --> N
L2 --> N
N[1 · Normalizer<br/>reference graph, decode, status gate, RxNorm lint] --> D
D[2 · De-identification<br/>tokens, per-patient date shift, DOB→year, id pseudonyms] --> C
D <-->|AES-256-GCM| V[(Vault file)]
C[3 · Compiler<br/>clinical-md, views, CSV, fhir-slim] --> LLM{{LLM / MCP agent}}
LLM --> R[4 · Verifier<br/>citation audit]
N -. original graph .-> R
V -. tokens, emitted dates, id map .-> R
R --> OUT[Audit report + local rehydration]ARCHITECTURE.md covers the invariants, the vault format and the resource limits.
Privacy model and limits
Read this before using it with real patient data.
This is not strict HIPAA Safe Harbor. Safe Harbor requires removing all elements of dates except the year. FHIRBridge removes or tokenizes the 18 identifier categories and reduces the date of birth to the year (ages over 89 become
90+), but by default it shifts all other dates by a secret per-patient offset instead of removing them. Date shifting is a technique used under Expert Determination, not under Safe Harbor, and it keeps day-level information that Safe Harbor would remove. Treat the output as de-identified for LLM use under your own risk assessment, not as a Safe Harbor determination, and have your privacy officer or an expert validate it before data leaves your control.De-identification is the default and can be turned off.
packandpullde-identify unless you pass--no-deid, which prints a warning and outputs real identifiers and dates.For real patient data, use
--free-text omit("structured-only"). It removes all free text and decoded documents, so the output consists of coded and structured fields only. Free-text scrubbing (--free-text scrub, the default) is best-effort and not Safe Harbor-complete. Known gaps: names with no cue word that are not registered from a structured field, identifiers that carry no label (a bare number in a sentence), and unusual date formats (for example5th of March 1930,03.05.30, dates spelled in another language or split across lines). It also cannot recognise facts that identify a person without being an identifier. Usescrubonly on data you have validated on a sample.Free-text detection is heuristic. Structured fields are handled for all 18 identifier categories, and biometrics and photos are handled by removing attachment payloads. In free text, names are found through dictionaries, names registered from structured fields, and cue words (
Pt,Dr.,signed by, relationship words such asmotherorhusband, and CJK, Vietnamese and Korean honorific cues). A name with no cue that is not registered from a structured field can be missed. Validate the output on a sample of your own notes before relying on it.The vault contains PHI (encrypted). Anyone holding the vault and its key can reverse the tokens. The key must come from the environment (
FHIRBRIDGE_VAULT_PASSPHRASEorFHIRBRIDGE_VAULT_KEY); a key file next to the vault is used only with--allow-key-file. Keep the key off the machine that talks to the LLM.Rehydration reverses only dates the tool emitted. The vault records the exact shifted dates it produced for each patient. Any other date, such as a guideline year or a date the model computed, is left exactly as written. A vault that holds several patients requires you to name one (
--patient Patient/id).The MCP server never returns real PHI unless started with
--allow-rehydrate, and then only for tokens it has already shown to the model. Use that flag only with models that run locally.Resource ids that embed identifiers are pseudonymized. Ids longer than 64 characters are shortened. Pass the vault to
verify/verifyResponseso the ids a model cites map back to your bundle.The RxNorm dictionary is small. It covers common outpatient drugs and known bad codes. It is not a terminology server.
This is not a medical device. The verifier reduces some failure modes; it does not make model output clinically safe.
Production runbook
Docker with read-only mounts
The image is distroless, runs as nonroot, and has no shell. Mount the input read-only and give the vault its own writable volume; nothing else on the container needs to be writable.
docker run --rm --read-only --cap-drop ALL --security-opt no-new-privileges \
-e FHIRBRIDGE_VAULT_PASSPHRASE \
-v "$PWD/exports:/data/in:ro" \
-v fhirbridge-vaults:/data/vault \
ghcr.io/digital-healthcare-opensource/fhirbridge \
pack /data/in/patient.json --vault /data/vault/patient.vault.json --free-text omit > context.mdPass the key with -e NAME (inherits from your shell) rather than -e NAME=value, so it does not appear in docker inspect or shell history. Keep the vault volume on a different host or account than the one that sends context to the LLM.
Key management and rotation
Generate a long random passphrase (or a 64-hex
FHIRBRIDGE_VAULT_KEY) and keep it in a secret manager; inject it as an environment variable at run time. Never put it in argv, an image, or a repository.A vault is encrypted with one key and the key cannot be changed in place. To rotate, re-pack into a new vault: export the data again, run
packwith the new key and a new--vaultpath, switch consumers to the new vault, then securely delete the old vault and retire the old key. Tokens and date offsets in the new vault differ from the old one, so answers produced from the old context can be rehydrated only with the old vault. Keep it until those answers are no longer needed.Treat a lost key as a lost vault: there is no recovery, and rehydration becomes impossible (the de-identified context stays valid).
If a key may have been exposed, rotate it and treat the old vault as compromised.
Pre-flight and monitoring
fhirbridge doctorchecks the Node version, theFHIRBRIDGE_*configuration and the vault key setup without printing any secret. Run it as a deployment smoke test; it exits 78 on a problem.Use
--log-format json(orFHIRBRIDGE_LOG_FORMAT=json) to get one JSON event per line on stderr, ready for a log shipper such as Fluent Bit, Vector or the Datadog agent. Events carry counts, codes and durations only, never patient values. SetFHIRBRIDGE_LOG_LEVEL=warnin steady state. stdout carries only the context, so redirect or pipe it separately from stderr.
Exit codes in pipelines
fhirbridge pack export.json --vault v.vault.json --free-text omit -o context.md
case $? in
0) ;; # success
65) echo "bad or oversized input"; exit 1 ;; # fix the export, do not retry
69|75) sleep 30; retry ;; # transient: pull failed, timeout, vault busy
77|78) echo "credentials or config"; exit 1 ;; # page the operator, do not retry
*) echo "unexpected"; exit 1 ;;
esacfhirbridge verify exits 1 when the model contradicts the record: treat that as a gate (block the answer), not as an error.
Troubleshooting by exit code
Exit | Likely cause | What to do |
1 | The answer cites ids that do not exist, or calls a stopped drug current | Read the issue list on stderr; regenerate the answer. |
64 | Misspelled option, invalid | Check |
65 | Truncated or non-FHIR JSON, nesting over 256 levels, a single NDJSON line over 64 MB, input over the limits, or | Validate the export; raise |
66 | Wrong path, or the container mount is missing | Check the |
69 | HTTP error from the server, unreachable host, or an | Check the URL; add |
70 | Bug | Re-run with |
74 | Vault path not writable, disk full | Fix permissions or space on the vault volume. |
75 | Another process changed the vault, or the server timed out | Re-run; serialize runs that share a vault. |
77 | Wrong vault passphrase, or the server rejected the token | Check the secret and the token scopes. |
78 | Invalid | Run |
Development
npm ci
npm test # 200+ tests: one suite per layer, end-to-end, MCP (in-memory client), SMART client
npm run lint # tsc --noEmit (strict)
npm run build
npm run fixtures # regenerate the synthetic fixture
npm run benchThe six acceptance tests in tests/fhirbridge.test.ts cover token compression and reference resolution, patient-scoped date shift and reversal, eponym and fraction protection, the status gate and code linter, citation hallucination and contradiction detection, and streaming rehydration across split chunks.
License
MIT. All patient data in this repository is synthetic.
This server cannot be deployed
Maintenance
Related MCP Connectors
Privacy-preserving synthetic health data generation. FHIR R4/R5 compliant.
HealthGuard - 12-tool health/medical AI safety MCP: PII redaction, HIPAA, GDPR Art.9.
Hosted MCP server for the Healthie EHR & telehealth API: patients, appointments, charting, tasks.
Auditable MCP server for PubMed, Europe PMC, ClinicalTrials.gov, and bioRxiv/medRxiv queries
Related MCP Servers
- FlicenseNot gradedqualityDmaintenanceProvides FHIR resource validation, synthetic test fixture generation, and HIPAA-safe logging review as MCP tools for AI agents.-
- AlicenseNot gradedqualityBmaintenanceEnables clinical workflows including search and summarization of synthetic FHIR patient records, PubMed literature search, and HIPAA Safe Harbor de-identification of text, via any MCP client.MIT
- AlicenseNot gradedqualityDmaintenanceEnables healthcare systems to parse HL7 v2 messages, convert them to FHIR R4 Bundles, validate FHIR resources, and detect/govern PHI with HIPAA, EU MDR, and GDPR compliance evidence for AI governance.MIT
- FlicenseNot gradedqualityAmaintenanceEnables LLM-based agents and MCP-compatible clients to query and manipulate FHIR resources through full CRUD tools, with document ingestion, semantic search, LOINC terminology lookup, and OAuth2-authenticated FHIR API integration.-