Turning your data into FHIR — how the authoring flow works¶
Status: describes the shipped FHIR-IG authoring toolset (22
fhir_*tools; checked against the code 2026-10). For each tool's parameters see FHIR IG Tools; the original design-decision record isfhir-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 in the admin console (IG packages); the LLM doesn't choose.
- Explicit instruction — the calling app or the user says "produce TW Core FHIR". Every IG-scoped tool takes a
package_id(+ optionalversion) argument, so the choice can always be explicit. - Informed choice — if several IGs are installed and none is specified, the LLM calls
fhir_list_igs(which returns each package'spackageId,version,title,canonical,isDefault,installOriginand dependencies) and matches it to the user's intent and content hints (Chinese text, ROC national ID, NHI billing → atw.gov.mohw.*package). - If still ambiguous → ask. The system asks the user rather than guessing.
When package_id is omitted the server resolves the target package in a fixed order: a canonical argument or meta.profile pinned with |version → the package holding that version; otherwise → the package that owns the canonical / meta.profile (manually installed IGs first); otherwise → the default IG; otherwise → the only manually installed IG. Every response's provenance.resolved_by says which rule applied. With no default IG and no owner, the call fails with NO_DEFAULT_IG instead of picking an arbitrary package.
Because every IG-scoped tool takes package_id, you can target any imported IG explicitly: fhir_get_profile(identifier="Patient", package_id="hl7.fhir.us.core") and fhir_get_profile(identifier="Patient-twcore", package_id="tw.gov.mohw.twcore") return different profiles; a canonical may also be written url|version to pin a version (an uninstalled version returns ARTIFACT_NOT_FOUND — there is no silent fallback to another version). 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 packageId / title and intent → ask if ambiguous.
Stage 2 — Decide the profiles¶
- [MCP]
fhir_list_resource_profiles()→ returns the IG's profiles:Patient-twcore,Condition-twcore,Encounter-twcore, … (passall_packages=trueto look across every installed package; zero rows come with adata.hintnaming other packages that match). - [MCP]
fhir_rank_resource_profiles(keys=[…])(optional) → feeds the source field names (keys) in; the server ranks candidates by how many keys match each profile's element paths (Condition-twcoretop). It returnsselectionRequired:true— it only suggests, [LLM] makes the final pick. - [MCP]
fhir_get_profile_required_elements(profile)(recommended) → once a profile is chosen, get the complete list of what it effectively requires (min≥1with required ancestors, required slices, choice types, invariant-implied value-or-dataAbsentReason), each row classified by where its value comes from:auto(pinned by the server),coding(look up a code),source_fact(from the source) orworkflow_default.
Stage 3 — Build each resource (Patient first, because Condition references it)¶
3A · Patient¶
- [MCP]
fhir_get_resource_skeleton(profile="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(profile="Patient-twcore", draft, context_id, key="patient-1")→ the server pins the mechanical fields (identifier.systemfor the national-ID slice,identifier.type,meta.profile), runs the validator, and returns{ resource, validation }(validation.valid: true). The Patient is registered under itskeyin the reference context (context_id) so other resources can point to it.
3B · Condition¶
- [MCP]
fhir_get_resource_skeleton(profile="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(text="流行性感冒", value_set = 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(key="patient-1", resource_type="Patient", context_id)→ returnsurn:uuid:…; the draft sayssubject = "Patient/patient-1"and finalize rewrites it to that urn. - [MCP]
fhir_finalize_resource(profile="Condition-twcore", draft, context_id, key="condition-1")→ suppose the validator findscategoryis required but missing. The server does not fix it for you (no auto-loop) — it returnsvalidation.valid: falsewithissues: [category missing]. - [LLM] reads the issue, checks the allowed values with [MCP]
fhir_get_profile_elements(profile="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(entries=[{resource, key}, …], bundle_type="transaction", context_id)→ wraps both into a Bundle, rewrites references tourn:uuid:, returns the bundle + the list of unresolved references (unresolvedshould be empty). - [MCP]
fhir_validate_bundle(bundle)→ checks every entry and internal reference integrity (Condition.subject really points to the Patient in the bundle) → passes. Also readchecked_fully:validkeeps HL7 validator semantics (an unresolvable profile is only a warning), so whenchecked_fully: false,profiles_unchecked/valuesets_uncheckedlist the canonicals that could not be checked anddata.deps_missinglists missing dependency packages.
Optional — check against the source record (grounding): when the source is free text, pass source_text (plus optional site_config, evidence_map, grounding) to the same fhir_validate_bundle / fhir_validate_resource call and the server additionally checks, with deterministic rules (no LLM), that every populated value is actually stated in the record; the response gains conformanceValid, groundingValid and grounding issues. Without source_text the output is unchanged. fhir_check_grounding(bundle, source_text) runs the same check on its own, and fhir_check_completeness(targets, source_text) compares — before you write anything — the facts the conversion targets require with what the record states, returning missing items, the record lines that state absence, a ready clarification question (or an insufficient_information object).
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
├─[MCP] get_profile_required_elements ← what the profile really requires
│
├─ 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 (+ optional source_text grounding) ──▶ 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 IG-scoped tool takes
package_id(auto-resolved when omitted), 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, canonical, default, installOrigin) / one IG's details (incl. data.deps_missing) |
| 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 |
| Required | fhir_get_profile_required_elements |
the profile's complete effective required list, each row tagged with its value source (auto / coding / source_fact / workflow_default) |
| 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 (plus grounding when source_text is given) |
| Faithfulness | fhir_check_grounding / fhir_check_completeness |
is every value supported by the record / does the record state the facts the targets require (both deterministic, no LLM) |