Legacy-to-FHIR Mapping MCP Server
Click on "Install 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., "@Legacy-to-FHIR Mapping MCP ServerFind patient Jane Smith and her recent vitals"
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.
Legacy-to-FHIR Mapping MCP Server

Note: For this demo, a simplified local REPL (scripts/demo.py) that calls the
tool functions directly, with a keyword-based stand-in for tool selection —
the real natural-language routing is AI-driven via an MCP client, documented in Usage below.
A Model Context Protocol (MCP) server that maps fragmented, inconsistently-formatted legacy healthcare records into valid, schema-conformant FHIR resources that are queryable by an AI agent using natural language.
Problem
Legacy healthcare systems store patient data in fragmented, non-standardized formats that predate modern interoperability standards like FHIR. Hospitals and payers still run production systems on these decades-old schemas, and migrating them wholesale is slow, expensive, and high-risk.
With the increasing adoption of AI agents, plugging them in directly to legacy systems risks two failure modes: the LLM either can't reach the data at all, or has to write raw SQL against an undocumented, inconsistent schema, with no guarantee the result is even valid healthcare data. Therefore, in order to use legacy healthcare data safely, we need a translation layer in front of it. Someone has to do the hard work of defining what "correct" means for that data and enforcing it. This is a product problem as much as an engineering one: deciding when the system is allowed to guess, and if it should be guessing at all.
This project builds a working version of that translation layer end-to-end: a synthetic legacy database with realistic messiness (inconsistent date formats, demographic data split across unlinked tables, units embedded in free-text values, un-coded diagnosis notes), and an MCP server that maps it cleanly into FHIR. The system never guesses when a mapping is ambiguous, and never returns a resource that hasn't independently passed schema validation.
Related MCP server: FHIR MCP Server
Architecture
The system is a three-stage pipeline, not a single black box — each stage is a separate, independently testable unit with one job:
flowchart TD
Q["Natural-language query"] --> S["MCP server\nmcp_server/server.py"]
S -->|"patient / condition query"| T1["query_legacy_patient_records\n(Patient + Condition)"]
S -->|"vitals / labs query"| T2["translate_vitals_log\n(Observation)"]
T1 --> R["legacy_repo.py\nresolves query against mock legacy\nSQLite database (data/legacy.db)"]
T2 --> R
R --> B["fhir_build.py\nmaps raw legacy fields to FHIR elements\nvia codes.py (LOINC/SNOMED-CT) + datetimes.py"]
B --> V{"validation.py\nindependent FHIR R4B\nschema check"}
V -->|"pass"| OK["Validated FHIR Bundle\nreturned to caller"]
V -->|"fail"| BAD["Excluded from bundle,\nreported in validation_failures"]1. Retrieve — the MCP server exposes two narrowly-scoped tools
(query_legacy_patient_records, translate_vitals_log) rather than one generic
"query anything" tool. legacy_repo.py resolves the natural-language query (a
name, an MRN, or both) against the mock legacy SQLite database.
2. Translate — fhir_build.py maps raw legacy fields to FHIR elements,
resolving clinical codes via codes.py (LOINC for vitals, SNOMED-CT for
conditions) and normalizing four different legacy date formats via
datetimes.py.
3. Validate — validation.py independently re-checks every resource against
the FHIR R4B schema before it's allowed to leave the system, as a step that does
not trust the mapping code that produced the resource. Anything that fails is
excluded and reported, never silently returned as if it were valid.
The mock legacy data itself comes from Synthea
(MITRE's open-source synthetic patient generator), then deliberately degraded
by legacy_data/mangle.py into a fragmented, legacy-system-shaped schema — see
Legacy data design in the technical reference below.
Why this approach
The interesting decisions in this project weren't really about FHIR — they were about how much a system should be allowed to assume on its own, and where to draw tool boundaries for an AI agent. Five decisions worth walking through:
Decision | Alternative considered | Why this one won |
Two narrowly-scoped tools ( | A single flexible | Tool descriptions are the interface contract an LLM uses to route a request. A generic tool produces ambiguous routing and untestable behavior; narrow tools with precise docstrings make routing reliable — verified directly by testing natural-language queries against the live MCP connection and confirming correct tool selection every time |
Deterministic, rule-based mapping (lookup tables + parsers) instead of having an LLM interpret/map the legacy data | Prompting an LLM to extract and code each record on the fly | Reliability and auditability. LLM-based extraction of clinical codes is non-deterministic and expensive to verify at scale; a healthcare data pipeline that can't guarantee the same input always produces the same output isn't one you can trust in a regulated domain — deterministic logic plus an independent validation gate can |
"Flag, never guess" as the core product principle for every ambiguous case | Best-effort inference (e.g. assume a missing unit from the vital's name) | A wrong guess in healthcare data is worse than a visible gap — a flagged gap can be caught and reviewed downstream; a silently wrong value usually can't. This is a risk decision as much as an engineering one |
Validation as a fully independent second pass, not trusted to the mapping code that built the resource | Trusting | Defense in depth — the mapping layer is treated as an untrusted component, the same posture you'd want for any pipeline writing into a compliance-sensitive format |
Scoped to exactly 3 FHIR resource types ( | Attempt broader (shallower) resource coverage | Proving the pattern deeply on a representative slice is more convincing than covering more resource types shallowly — a scope cut made deliberately, not by running out of time |
Tradeoffs
Being upfront about what this project deliberately does not solve:
Not a general legacy-schema translator. The mapping logic is built against the specific mess patterns this dataset contains. A new legacy field format outside those patterns wouldn't be handled — a production system ingesting arbitrary unknown schemas would need much broader lookup coverage, or a human-in-the-loop review step for genuinely novel free text.
Not complete interoperability coverage. Three resource types proves the pattern; a commercially viable version would need to cover far more of the FHIR resource surface (
MedicationRequest,Encounter,AllergyIntolerance, and more) before it replaces anything a hospital actually depends on.Not tested at real-world scale or messiness. 42 synthetic patients from one generator's mangling logic is not representative of the volume or edge cases in real legacy data — the fuzzy-matching join logic in particular would need real stress-testing before this pattern could be trusted further.
No performance engineering. SQLite plus in-process Python is adequate for a demo; it says nothing about throughput at hospital-system scale.
No auth/access-control layer. Deliberately out of scope for a read-only portfolio project against mock data — but a real product handling any patient data, de-identified or not, would need this before anything else. Worth stating plainly rather than leaving implicit.
Demo
The GIF at the top is a real recording of two live natural-language queries
answered by the system end-to-end, including one query with a name typo
(ben thorp instead of Ben Torp) that the fuzzy patient-matching still
resolved correctly.
Try it yourself without any setup beyond Installation:
python3 scripts/demo.pyThat's a simplified local REPL — real usage is through an actual MCP client (Claude Code or otherwise), where an AI model does the natural-language tool routing itself rather than the REPL's keyword stand-in. See Usage in the technical reference for both paths, plus the direct Python-call option with no AI involved at all.
What I would improve
Roughly in priority order, if this moved past portfolio scope:
Expand resource coverage to
MedicationRequestandEncounter. (High impact, moderate effort.) Medication and visit history are the next-most-requested data types after diagnoses and vitals in real clinical workflows — the natural next slice, not a random expansion.Add a review queue for unresolved free-text conditions, instead of only flagging and excluding them. (High impact, higher effort.) Right now an unmapped condition note is surfaced as a gap; closing that loop with a human-in-the-loop coding step would make the system actually usable in a real clinical workflow, not just honest about its limits.
Add authentication, authorization, and audit logging. (Medium impact, required before touching anything beyond mock data.) Not a "nice to have" — a hard prerequisite for handling any real patient data at all.
Stress-test against a larger, noisier synthetic dataset. (Medium impact.) 42 patients from one generator run is enough to prove the pattern, not enough to trust the fuzzy-matching logic at scale.
Technical reference
The sections below are the full engineering detail — installation, usage, configuration, testing, schema validation internals, and the legacy data design. Collapsed by default to keep the page narrative-forward; expand whichever section you need.
Python 3.10+
A JDK (11+) only if you intend to regenerate the mock database from scratch — not needed to run the server against the checked-in
data/legacy.db
Three steps — clone, install, verify. Dependency versions are pinned
(pyproject.toml) to a combination that's actually tested working; an
unpinned fhir.resources install can pull in an incompatible
annotated-types release and fail with an ImportError, so match these
versions rather than installing latest:
# 1. Clone
git clone https://github.com/gdanse/legacy-to-fhir-mcp-server.git
cd legacy-to-fhir-mcp-server
# 2. Install (pinned versions -- see pyproject.toml)
python3 -m venv .venv && source .venv/bin/activate
pip install "mcp==1.28.1" "fhir.resources==8.3.0" "annotated-types<0.8.0"
# 3. Verify
python3 scripts/test_query_legacy_patient_records.pyAll three steps together run in well under two minutes on a normal
connection — the mock database (data/legacy.db) is already checked in,
so there's no seeding step.
Start the server (stdio transport):
python3 mcp_server/server.pyClaude Code isn't required — the server speaks the standard Model Context Protocol over stdio, so anything that can act as an MCP client can use it. A few ways to actually make queries:
Via Claude Code (or any MCP client)
Copy .mcp.json.example to .mcp.json and replace <path-to-this-repo>
with your local checkout path (.mcp.json is gitignored since that path is
machine-specific):
cp .mcp.json.example .mcp.jsonThen ask natural-language questions — the model picks the right tool automatically:
Example query | Tool selected | Returns |
|
|
|
|
|
|
|
|
|
Both tools accept a name, a legacy MRN, or both, and return:
{
"bundle": "<FHIR searchset Bundle>",
"warnings": ["<flags for anything left deliberately unresolved>"],
"validation_failures": ["<resources excluded for failing schema validation>"]
}Any other MCP client works the same way — e.g. the standalone MCP Inspector, a browser-based dev tool for exercising an MCP server without wiring it into an AI assistant at all:
npx @modelcontextprotocol/inspector python3 mcp_server/server.pyWithout any MCP client — direct Python calls
Natural-language tool selection is what an MCP client provides. The data retrieval, FHIR mapping, and validation underneath don't depend on it — the tool functions are plain Python and can be called directly with a query string, no AI involved:
from mcp_server.server import query_legacy_patient_records, translate_vitals_log
query_legacy_patient_records("Ben Torp")
translate_vitals_log("Adelle Raynor's weight")This is exactly what the smoke tests in scripts/ do — see
Testing below.
None needed. This project requires no API keys or secrets — all data is
local, synthetic, and self-contained (data/legacy.db), and no code path
calls any external service at runtime.
Smoke tests bypass the MCP transport, call the tool functions directly, and
validate every returned resource against fhir.resources' FHIR R4B models:
python3 scripts/test_query_legacy_patient_records.py
python3 scripts/test_translate_vitals_log.py
python3 scripts/test_validation_layer.pyscripts/verify_legacy_mess.py prints direct SQL evidence of each legacy
mess pattern described below, straight from data/legacy.db.
scripts/demo.py is an interactive REPL for trying queries yourself
without any MCP client (this is what the demo GIF at the top was recorded
from) — run it and type a natural-language query at the query> prompt.
mcp_server/ MCP server + FHIR mapping/validation logic
server.py tool entry points
legacy_repo.py resolves natural-language queries against the DB
fhir_build.py maps legacy rows -> FHIR resource dicts
codes.py LOINC / SNOMED-CT lookup tables
datetimes.py legacy date-format parsing
validation.py independent FHIR R4B schema validation gate
legacy_data/ builds the mock legacy database from Synthea output
extract.py reads clean FHIR bundles
mangle.py deliberately degrades them into legacy-shaped rows
schema.py SQLite schema for the four legacy tables
mapping/
fhir-mapping-schema.md field-by-field mapping spec + verified code tables
scripts/ smoke tests + database build/verification scripts
demo.py interactive REPL for trying queries (used for the demo GIF)
docs/demo.gif the README demo recording
data/legacy.db the mock legacy database (checked in, regeneratable)data/legacy.db is generated from Synthea's clean, FHIR-conformant output,
then deliberately degraded into four legacy-system-shaped SQLite tables:
Table | Legacy mess pattern |
| DOB as |
| Same patient's other half of the demographic record, in a separate table with no shared key — joins to |
| Date as bare |
| Date as |
To regenerate from scratch:
# 1. Requires a JDK (11+) on PATH.
git clone --depth 1 https://github.com/synthetichealth/synthea.git
cd synthea
./run_synthea -p 40 # generates ~40 synthetic patients into output/fhir/
# 2. Copy the bundles into this project
cp -R output/fhir "<this-repo>/synthea_output/fhir"
# 3. Build the mangled legacy SQLite DB
cd "<this-repo>"
python3 scripts/build_legacy_db.pyEvery resource returned by either tool passes through mcp_server/validation.py
— an independent FHIR R4B schema check that doesn't trust the mapping logic
in fhir_build.py. Anything that fails is excluded from the bundle and
reported in a sibling validation_failures list instead of being returned
as if it were valid.
The same never-guess principle governs every ambiguous mapping case:
Flag | Resource | Trigger |
| Patient | Zero or multiple name+DOB matches across the split identity/contact tables |
| Observation |
|
| Observation |
|
| Condition |
|
See mapping/fhir-mapping-schema.md for the full field-by-field mapping
spec, including the verified LOINC/SNOMED-CT lookup tables and the
corrections made when initial code guesses turned out to be wrong.
Every FHIR resource this server returns is a pydantic model, not a plain
dict — fhir.resources (the FHIR model library used here) is built on
fhir_core, which is itself built on
pydantic v2. Concretely:
>>> from fhir.resources.R4B.patient import Patient
>>> import pydantic
>>> issubclass(Patient, pydantic.BaseModel)
Truemcp_server/validation.py instantiates the matching pydantic model
(Patient, Observation, or Condition) for every resource fhir_build.py
produces, as an independent step that doesn't trust the mapping code that
built the resource. Pydantic's own validation raises on missing required
fields, wrong types, or malformed structure — the exact same enforcement
mechanism a hand-written Zod schema would provide in a TypeScript service,
applied here to the FHIR R4B spec. A resource that fails is excluded from
the bundle and reported in validation_failures; there is no code path
that returns a resource without going through this check.
Boundary | How it's enforced |
Read-only data access | The SQLite connection is opened in |
No secrets / API keys | Nothing in the codebase reads |
No real PHI | All patient data is synthetic, generated by Synthea — every SSN carries Synthea's |
Local-only execution | The MCP server communicates over stdio only; it does not open a network port or accept remote connections |
Untrusted-input handling | Every ambiguous or unparseable legacy value is flagged and excluded rather than guessed — see Validation & fallback philosophy above |
License
MIT — see LICENSE.
This server cannot be installed
Resources
Unclaimed servers have limited discoverability.
Looking for Admin?
If you are the server author, to access and configure the admin panel.
Related MCP Servers
- AlicenseAqualityDmaintenanceEnables LLM-based agents to interact with FHIR healthcare data through natural language prompts, providing full CRUD operations on FHIR resources, document processing, and semantic search capabilities.1397MIT
- FlicenseAqualityDmaintenanceProvides read/write access to any FHIR-compliant healthcare API with built-in validation, supporting resource management, search operations, and granular permissions through natural language.51
- Alicense-qualityCmaintenanceEnables querying MIMIC-IV medical data using natural language through MCP clients, with support for local DuckDB and cloud BigQuery backends.76MIT
Related MCP Connectors
Hosted MCP server exposing US hospital procedure cost data to AI assistants
GibsonAI MCP server: manage your databases with natural language
Query PostgreSQL databases in plain English — LLM-generated, safety-validated SQL.
Latest Blog Posts
- Who's Calling? MCP Hosts Are an Identity Blind Spot (And the Spec Knows It)By Om-Shree-0709 on .mcpAgent IdentityOAuth 2.1
- Your AI Chatbot Just Exposed Your CEO's Salary to an InternBy Om-Shree-0709 on .Agent IdentityMCP SecurityOAuth Delegation
- Why MCP Servers Need Execution Sandboxing (And Why Your Current Stack Isn't Enough)By Om-Shree-0709 on .Agentic AiPrompt InjectionWebAssembly
MCP directory API
We provide all the information about MCP servers via our MCP API.
curl -X GET 'https://glama.ai/api/mcp/v1/servers/gdanse/legacy-to-fhir-mcp-server'
If you have feedback or need assistance with the MCP directory API, please join our Discord server