HL7 v2.5.1 → FHIR
Mapping MCP Server
A Model Context Protocol server for the crossing between the two standards that actually carry an order end to end: it answers what does this v2 field become in FHIR R4 at field, datatype and code-value level, and it converts whole ORM, ADT and ORU messages into a FHIR transaction Bundle — with a warning for every value it could not map faithfully rather than a plausible guess.
server.js
below and run it locally; the endpoint is http://localhost:3001/mcp by
default, chosen so it can run alongside the
HL7 v2.5.1 reference server on 3000.
urn:id:
placeholders, and every one of them is reported in warnings. Resolve
those against your own registry before anything reaches a shared store.
Available Tools
Nine tools: six that explain the mapping, three that perform it.
What Each Message Becomes
Conversion is a transaction Bundle. Resources with a usable business identifier
get a conditional PUT so a replayed message updates rather than duplicates;
resources without one fall back to POST, and the missing identifier is
reported as a warning.
| Message | Resources emitted | The part that goes wrong |
|---|---|---|
| ORM^O01 | ServiceRequest, Patient, Encounter, Practitioner, Location, Observation, ImagingStudy | One ORM can carry several ORC/OBR pairs — that is N ServiceRequests, not one with repeated codes. And OBX inside an ORM is an order-entry answer, not a result: it is emitted with category=exam and basedOn the request, never laboratory. |
| ADT^A01 / A04 | Patient, Encounter, Location chain, Practitioner, Condition, AllergyIntolerance | Encounter.class is 1..1 in R4. A PV1-2 of U makes a conformant Encounter impossible, so the server emits UNK and warns rather than quietly defaulting to ambulatory. |
| ADT^A08 | Patient, Encounter, Condition, AllergyIntolerance | A08 is a snapshot, but most pipelines treat it as a patch, so a field the sender cleared never clears downstream. A changed MRN in an A08 is an unannounced merge, not an update. |
| ADT^A31 | Patient only | Person-level, so there is no Encounter — even when the sender includes a PV1. Emitting one anyway, because the A08 converter always does, is the classic A31 bug; the server warns instead. |
| ADT^A40 | Patient (survivor) + Patient (retired, active=false) |
PID survives, MRG-1 is retired with link.type=replaced-by. The direction is reversible and reversing it is silent — the wrong chart is retired and the bundle still validates. |
| ORU^R01 | DiagnosticReport, Observation, Patient, Encounter, Specimen, Practitioner, ImagingStudy | A radiology narrative arrives as dozens of repeating TX rows. They are one report: the server joins consecutive narrative OBX rows sharing a code into a single Observation, in line order, and repeats the text in DiagnosticReport.conclusion. |
Where the two status fields go
The most common conversion defect in an ORU is copying one status onto the other. They come from different fields, use different tables, and legitimately differ — a final report can contain a corrected line.
| Source | Table | FHIR element |
|---|---|---|
| OBR-25 | 0123 | DiagnosticReport.status |
| OBX-11 | 0085 | Observation.status |
| ORC-5 | 0038 | ServiceRequest.status |
| ORC-1 | 0119 | (create / update / cancel — a verb, not a state) |
Quick Start
-
Download the server
shellcurl -O https://coffeemilktea.github.io/mcp/fhir/server.js -
Install dependencies
shellnpm install @modelcontextprotocol/sdk express cors zod -
Run the server
shellnode server.js # HL7 v2.5.1 → FHIR MCP server listening on http://localhost:3001/mcp
-
Add to Claude Desktop
Edit
~/Library/Application Support/Claude/claude_desktop_config.json(Mac) or%APPDATA%\Claude\claude_desktop_config.json(Windows). The two servers complement each other — run both.claude_desktop_config.json{ "mcpServers": { "hl7-reference": { "type": "http", "url": "http://localhost:3000/mcp" }, "hl7-to-fhir": { "type": "http", "url": "http://localhost:3001/mcp" } } }Restart Claude Desktop. The nine tools appear in the tool panel.
Environment variables
| Variable | Default | Description |
|---|---|---|
| PORT | 3001 | HTTP port the server listens on |
Rules the Converter Holds To
Every one of these is a place where a conversion can look successful and be wrong. The server's position on each is fixed, and departures are reported rather than absorbed.
| Rule | Why |
|---|---|
| No invented systems | An assigning authority with no universal ID becomes urn:id:<namespace> and a warning — never a fabricated http:// URI. Two hospitals' local codes sharing one system URI silently merges two code systems. |
| Precision is preserved | 19800115 is a date; 202501 stays 2025-01. Padding a partial timestamp to midnight invents a time the sender never asserted. |
| Timezones are never assumed | A TS with a time and no offset is converted without one and flagged. Applying the receiver's locale is what shifts admissions across midnight between sites. |
| Out-of-table codes fail loudly | A PID-8 that is not in table 0001 leaves Patient.gender absent rather than mapping to the nearest plausible neighbour. |
| Idempotent output | fullUrls are derived from MSH-10 and the resource's logical key, so converting the same message twice produces byte-identical bundles — diffable in a test suite. |
| Escapes are reversed | \F\, \S\, \T\, \R\, \E\, \X..\ and \.br\ are decoded before the text reaches FHIR. A FHIR string containing \.br\ is a conversion that stopped halfway. |
| OBX-2 decides value[x] | NM becomes valueQuantity, SN becomes Quantity, Range or Ratio depending on its separator, CE becomes valueCodeableConcept. An NM that is not numeric falls back to valueString with a warning instead of being dropped. |
| ED and RP are not values | Encapsulated data and reference pointers belong in presentedForm or an ImagingStudy. The raw value is kept as a string for review and flagged, not filed as a result. |
Example Prompts
Once connected, you can ask Claude questions like:
# Field-level mapping "Where does OBR-25 go in FHIR?" → get_field_mapping(segment="OBR", field=25) # Reverse lookup — the question you have at 2am "DiagnosticReport.effectiveDateTime is empty. Which v2 field feeds it?" → which_v2_fields_feed(resourceType="DiagnosticReport") # Code translation "What does patient class 'B' become in Encounter.class?" → lookup_concept_map(table="0004", code="B") # Whole-message conversion "Convert this ORU to FHIR and tell me what didn't map cleanly." → convert_message(message="MSH|^~\&|RIS|HOSP|...", include="both") # Just the diagnosis, not the payload "Will this ADT^A08 convert cleanly?" → convert_message(message="...", include="summary") # Merge safety "Which patient does this A40 retire?" → get_message_mapping(messageType="ADT^A40") convert_message(...) → survivor keeps PID-3, MRG-1 goes active=false with replaced-by # Datatype detail "How do I get an email address out of an XTN?" → get_datatype_mapping(datatype="XTN") # Timestamps "Is 20250901143000 safe to send as a FHIR instant?" → convert_datetime(value="20250901143000")