Turning your data into FHIR — how the authoring flow works¶
Status: describes the designed FHIR-IG authoring toolset (the
fhir_*tools). For the engineering spec and design decisions, seefhir-ig-mcp-toolset-assessment.md. This page is the user-facing walkthrough.
This MCP server lets a language model (LLM) turn your source data — a structured hospital record or a free-text clinical note — into a valid FHIR resource that conforms to an Implementation Guide (IG) such as TW Core, US Core, or IPS. This page walks through exactly how that happens, step by step, with a concrete example.
The one idea to hold onto: who does what¶
The conversion is a partnership. The boundary never moves:
| Responsibility | |
|---|---|
| The LLM | Meaning & semantics — reads/understands the source, decides which resource to build, picks the right code for "influenza", fills the value of each field. |
| The MCP server | Structure, terminology, mechanics & conformance — tells the LLM which fields exist and what's allowed, supplies the legal code lists, pins the mechanical fields the LLM must not invent (fixed values, profile URLs, system URLs, references), and validates the result. |
The LLM never invents canonical URLs, code-system URLs, a SNOMED display, or a meta.profile. Those come from the server. The LLM fills the semantic blanks; the server pins the mechanical ones.
Before you start: which IG?¶
A FHIR resource is only meaningful against a target IG. The same clinical facts (a patient, an influenza diagnosis) can be mapped to TW Core, US Core, or IPS depending on where the data is going — the data itself doesn't tell you. So the IG is chosen by context, not guessed from the content, in this order:
- Deployment default — most installations are bound to one IG (e.g. a Taiwan hospital → TW Core). One package is marked the default; the LLM doesn't choose.
- Explicit instruction — the calling app or the user says "produce TW Core FHIR". Every tool takes an
ig = {packageId, version}argument, so the choice is always explicit. - Informed choice — if several IGs are installed and none is specified, the LLM calls
fhir_list_igs(which returns each package'stitle,jurisdiction, and default flag) and matches it to the user's intent and content hints (Chinese text, ROC national ID, NHI billing →jurisdiction = TW). - If still ambiguous → ask. The system asks the user rather than guessing.
Because every IG-scoped tool takes ig, you can target any imported IG explicitly: fhir_get_profile(ig=US-Core, "Patient") and fhir_get_profile(ig=TW-Core, "Patient") return different profiles. Adding a new country's IG is just importing its package — no new tools.
Worked example¶
Input — an unstructured clinical note (free text):
「病人王小明,男性,生日 1985年3月12日,身分證 A123456789。因急性發燒至門診就醫,經醫師確診為流行性感冒,2026/6/1 發病,目前持續追蹤中。」
Goal: produce TW-Core-conformant FHIR — a Patient and a Condition, packaged in a Bundle.
Legend: [LLM] = the model reasons on its own (no tool call) · [MCP] = a tool call.
Stage 0 — Understand the text (no tool calls)¶
[LLM] reads the note and extracts the clinical facts:
Patient: name=王小明, sex=male, birth=1985-03-12, national-id=A123456789
Diagnosis: influenza, clinical status=active, verification=confirmed, onset=2026-06-01
It also decides it needs a Patient + a Condition. It does not yet know which FHIR fields or codes to use — that's what the server is for.
Stage 1 — Resolve the IG¶
Per the rules above. In a TW-bound deployment this is just "TW Core 1.0.0". If unsure: [MCP] fhir_list_igs → pick by jurisdiction/intent → ask if ambiguous.
Stage 2 — Decide the profiles¶
- [MCP]
fhir_list_resource_profiles(ig)→ returns the IG's profiles:Patient-twcore,Condition-twcore,Encounter-twcore, … - [MCP]
fhir_rank_resource_profiles(ig, facts)(optional) → feeds the diagnosis facts in; the server ranks candidates (Condition-twcoretop, with matching-field evidence). It only suggests — [LLM] makes the final pick.
Stage 3 — Build each resource (Patient first, because Condition references it)¶
3A · Patient¶
- [MCP]
fhir_get_resource_skeleton(ig, Patient-twcore)→ a blank, annotated fill-form derived live from the profile:identifier (1..*, required; sliced — national ID uses slice "NN"; system is auto-pinned, you only supply value) name (1..*, required: family / given) gender (0..1; allowed: male | female | other | unknown) birthDate (0..1; date) [meta.profile → auto-filled by the server; do not touch] - [LLM] fills the semantic blanks from Stage 0:
identifier[0].value = "A123456789" name = { family: "王", given: ["小明"] } gender = "male" ← taken from the allowed list in the skeleton birthDate = "1985-03-12"For coded fields it may double-check with [MCP]fhir_validate_code. - [MCP]
fhir_finalize_resource(ig, Patient-twcore, draft, refCtx)→ the server pins the mechanical fields (identifier.systemfor the national-ID slice,identifier.type,meta.profile), runs the validator, and returns{ resource, validation: pass }. The Patient is registered in the reference context (refCtx) so other resources can point to it.
3B · Condition¶
- [MCP]
fhir_get_resource_skeleton(ig, Condition-twcore)→clinicalStatus (allowed: active | recurrence | ...) verificationStatus (allowed: confirmed | provisional | ...) category (allowed: encounter-diagnosis | problem-list-item) code (1..1, required; bound to a SNOMED diagnosis ValueSet) subject (1..1, required; Reference → Patient-twcore) onset[x] (choice; use onsetDateTime for a date) - [LLM] fills the easy semantic blanks:
clinicalStatus=active,verificationStatus=confirmed,onsetDateTime=2026-06-01. - The key step — turn free text "流行性感冒" into a standard code:
- [MCP]
fhir_normalize_code(input="流行性感冒", target = Condition.code's ValueSet)→ returns candidate codes by semantic match, e.g. SNOMED6142004 | Influenza. - [MCP]
fhir_validate_code→ confirms that code really is a member of the bound ValueSet → the LLM writes it intocode. - (The LLM must not invent a code here — always normalize, then validate.)
- Wire the reference: [MCP]
fhir_resolve_reference(refCtx, target=Patient, source=王小明)→ returnsurn:uuid:…→ written intosubject.reference. - [MCP]
fhir_finalize_resource(ig, Condition-twcore, draft, refCtx)→ suppose the validator findscategoryis required but missing. The server does not fix it for you — it returns{ resource, issues: [category missing] }. - [LLM] reads the issue, checks the allowed values with [MCP]
fhir_get_profile_elements(ig, Condition-twcore, view="binding", path="Condition.category"), setscategory="encounter-diagnosis", and callsfhir_finalize_resourceagain → this time it passes.
Stage 4 — Assemble & validate the whole thing¶
- [MCP]
fhir_build_bundle([Patient, Condition], type="transaction", refCtx)→ wraps both into a Bundle, rewrites references tourn:uuid:, returns the bundle + a reference map. - [MCP]
fhir_validate_bundle(bundle)→ checks every entry and internal reference integrity (Condition.subject really points to the Patient in the bundle) → passes.
The validated Bundle is your output file.
The flow at a glance¶
free-text / source data
│
├─[LLM] understand it, extract facts, decide which resources are needed
│
├─ resolve IG (deployment default ▸ explicit ▸ informed choice ▸ ask)
│
├─[MCP] list / rank profiles ──▶ [LLM] pick the profile
│
├─ for each resource:
│ [MCP] get_resource_skeleton ← blanks + allowed values + candidate codes
│ └─[LLM] fill the semantic blanks
│ ├─[MCP] normalize_code → validate_code (free text → standard code)
│ └─[MCP] resolve_reference (link to other resources)
│ [MCP] finalize_resource ← pin mechanical fields + validate
│ └─ failed? returns issues → [LLM] fixes → finalize again
│
└─[MCP] build_bundle → validate_bundle ──▶ final Bundle file
Why it's built this way¶
- No mapping templates to maintain. Older systems needed a hand-written "field A → field B" rule file because software couldn't understand meaning. With an LLM, the model does the semantic mapping live against the skeleton the server hands it — nothing to author or version.
- The LLM can't drift into invalid FHIR. It only fills semantic values; the server pins the mechanical parts and the validator gates the result. Anything the server can't verify locally comes back as a warning, never a silent "valid".
- A pre-flight check, not the final authority. When you submit the Bundle to a real FHIR server, that server does the authoritative validation. This flow's job is to catch the great majority of mistakes early, with clear, fixable feedback.
- One toolset, many IGs. Every tool takes an
igselector, so the same flow works for TW Core today and any IG you import tomorrow — no new tools.
Quick tool reference¶
| Stage | Tool | Does |
|---|---|---|
| IG | fhir_list_igs / fhir_get_ig |
list installed IGs (title, jurisdiction, default) / one IG's details |
| Discover | fhir_list_artifacts / fhir_search_artifacts |
browse / search an IG's profiles, ValueSets, etc. |
| Profile | fhir_list_resource_profiles / fhir_rank_resource_profiles / fhir_get_profile |
list selectable profiles / rank candidates for your data / profile summary |
| Structure | fhir_get_profile_elements(view=…) |
one tool, many views: elements, element, slices, choices, binding, examples |
| Terminology | fhir_get_valueset / fhir_expand_valueset / fhir_lookup_code / fhir_validate_code / fhir_normalize_code |
inspect/expand a ValueSet, look up or validate a code, map free text → candidate codes |
| Author | fhir_get_resource_skeleton / fhir_finalize_resource |
get the annotated fill-form / pin mechanical fields + validate |
| Assemble | fhir_resolve_reference / fhir_build_bundle |
link resources / package into a Bundle |
| Validate | fhir_validate_resource / fhir_validate_bundle |
conformance check a resource / a whole bundle |