Model Context Protocol

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.

Protocol MCP Streamable HTTP Source HL7 v2.5.1 Target FHIR R4 (4.0.1) Runtime Node.js 18+ License MIT
Note — static hosting: GitHub Pages serves only static files. This page is the documentation and download hub. Download 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.
What this is not: a certified conversion engine. The output is a syntactically valid R4 transaction Bundle built from one message in isolation — it has no patient index, no identifier registry and no memory of earlier messages. Assigning authorities that carry no universal ID come out as 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.

list_message_mappings
Every message type the server converts, the FHIR resources each produces, and how many documented pitfalls it carries.
no parameters
get_message_mapping
The full segment-to-resource plan for one message type, including the pitfalls that make that message different from its neighbours.
messageType
get_segment_mapping
Field-by-field FHIR targets for one segment — PID, PV1, ORC, OBR, OBX, MRG, SPM, NTE, AL1, DG1, NK1, PD1, EVN, MSH, ZDS.
segment
get_field_mapping
One field, e.g. OBR-25 or PID-5, with the FHIR element it feeds and the caveat attached to it.
segmentfield
get_datatype_mapping
Component-level mapping for a v2 datatype — XPN, XAD, CX, XTN, CE, TS, XCN, HD, PL, EI, CQ, SN and the primitives.
datatype
lookup_concept_map
Translate a v2 table value to its FHIR code, or list a whole table. Tables 0001, 0002, 0004, 0007, 0038, 0078, 0085, 0119, 0123, 0125, 0136, 0203, 0396.
tablecode?
which_v2_fields_feed
Reverse lookup. Name a FHIR resource and get every v2 field that lands in it — the question you actually have when a FHIR element is empty and you need to know which segment failed you.
resourceType
convert_datetime
HL7 TS to FHIR date/dateTime, preserving the source precision instead of padding it, and flagging a time sent with no timezone offset.
value
convert_message
A whole ORM, ADT or ORU message to a FHIR R4 transaction Bundle, plus the warnings and notes the conversion generated. Ask for the bundle, the summary, or both.
messageinclude?

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.

MessageResources emittedThe 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.

SourceTableFHIR element
OBR-250123DiagnosticReport.status
OBX-110085Observation.status
ORC-50038ServiceRequest.status
ORC-10119(create / update / cancel — a verb, not a state)

Quick Start

  1. Download the server

    shell
    curl -O https://coffeemilktea.github.io/mcp/fhir/server.js
  2. Install dependencies

    shell
    npm install @modelcontextprotocol/sdk express cors zod
  3. Run the server

    shell
    node server.js
    # HL7 v2.5.1 → FHIR MCP server listening on http://localhost:3001/mcp
  4. 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

VariableDefaultDescription
PORT3001HTTP 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.

RuleWhy
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:

natural language → tool call
# 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")