fhir-mcp:基于 .NET 10 的 MCP 服务器,让 Claude 查询 FHIR R4 患者记录
marcsalot/fhir-mcp
开发者 marcsalot 发布 fhir-mcp,一个用 C# 在 .NET 10 上编写的 MCP 服务器,让 Claude 等 MCP 客户端通过 FHIR R4 查询患者记录,仅连接合成数据的公开测试服务器且只读。
An MCP server, written in C# on .NET 10, that lets Claude and other MCP clients answer questions about patient records over FHIR R4. Ask "What is Reyna Ziemann being treated for, and do her medications line up with it?" and the server runs the FHIR searches, trims the verbose responses down to what matters, and hands back a compact answer.
Synthetic data only. This project points at public FHIR test servers seeded with synthetic patients. It is read-only and is not connected to any real patient data.
Why this exists
Connecting an AI model to patient records over FHIR is a natural idea. Making it work well is harder than it looks, for two reasons.
FHIR search is more literal than it looks. The standard name= parameter matches only the beginning of one stored name value, so searching for "Reyna Ziemann" returns nothing: the phrase has to match one value from the start. "Sayed" misses "Al-Sayed" because "Al-" comes first. "a1c" misses "Hemoglobin A1c/Hemoglobin.total in Blood" because the test name does not start with "a1c". These are not bugs; they are how the standard works. But they matter a lot when a model generates search terms from natural language.
Raw FHIR responses are large. A patient summary takes six parallel searches and returns about 117 KB of JSON for one synthetic patient, much of it metadata and coding structure. Sending that to a model wastes context and produces worse answers.
This project addresses both: name and lab search are done on our side, with transparent notes about how results were found, and each tool returns a compact summary shaped for a model rather than for a client that needs the full resource. The MCP protocol connects it to Claude and other AI clients.
Status
| Piece | State |
|---|---|
| FHIR client and patient search | Done |
| Name matching (full names, hyphens, accents, maiden names, fallback) | Done |
| MCP server with 7 read-only tools | Done |
| Patient summary, medications, conditions, encounters, observations, allergies | Done |
| Privacy and audit: tamper-evident audit log, de-identified mode, tool allowlist | Done |
| SMART on FHIR authentication | Stretch goal |
How it works
Claude --> MCP server (this project) --> FHIR R4 server
|
+-- matches names the way a person types them
+-- flattens Bundles into small summaries
+-- caps results and says when more exist
+-- turns FHIR errors (OperationOutcome) into plain messages
FHIR responses are large, which wastes the model's context and makes answers worse. Most of the work in this project is in shaping requests and responses, not in calling the API. Measured against the SMART Health IT sandbox for one synthetic patient:
| Question | Raw FHIR from the server | What the tool returns |
|---|---|---|
| Patient summary (six searches) | 117 KB | 2.2 KB |
| Latest result of each lab test | 212 KB | 3.5 KB |
Name search
FHIR's default name search surprised me in testing against public servers:
| Typed | FHIR server alone | This server |
|---|---|---|
Reyna Ziemann |
0 results (the phrase must match one name value) | Reyna Ziemann |
Sayed (for Al-Sayed) |
0 results (only the start of "Al-Sayed" matches) | Fatima Al-Sayed, flagged as a looser match |
fatima al sayed |
0 results | Fatima Al-Sayed |
Hammes |
Melony Williamson, with no hint why | Melony Williamson, showing "Melony Hammes (maiden)" |
How it works:
- The server is asked about the most specific word typed (the longest), as a prefix, with hyphens kept.
- Every word typed is then checked against all of each candidate's names: official, maiden, nickname. Case, accents, hyphens and apostrophes are ignored, so
arissofinds Arissó andobrienfinds O'Brien. - If nothing matches, the search is retried with FHIR's
:containsmodifier, and the result says so. - Each result lists the patient's other names, so it is clear why they matched.
Known limit: a name typed without its hyphen or apostrophe (AlSayed, okeefe) is not found, because the server stores "Al-Sayed" and "O'Keefe" and offers no way to match across those characters. The tool's description and its no-match note tell the model to use the stored spelling.
Tools
| Tool | What it does |
|---|---|
search_patients |
Finds patients by any part of the name and/or birth date. Returns the id the other tools take, plus name, other names, gender, birth date, age, city, state, and a deceased flag. |
get_patient_summary |
One patient at a glance: demographics, active conditions and medications, allergies, the latest reading of each vital sign, and the last visit. Runs six searches in parallel. |
get_medications |
Medication orders, newest first, with status, dosage in plain words ("500 mg, twice a day"), reason, and RxNorm code. Active or all. |
get_conditions |
Problems and diagnoses with status, onset and resolution dates, and SNOMED CT code. Active (including recurrences and relapses) or all. |
get_encounters |
Visits and stays, newest first, with setting (ambulatory, emergency, inpatient), type, reasons, and discharge disposition. |
get_observations |
Labs, vitals, and other observations by category, test name, LOINC code, or date. latestOnly keeps the newest result of each test. |
get_allergies |
Allergies and intolerances with criticality and reactions. |
Tools are read-only and annotated as such. Errors (a bad date, an unknown patient, an unknown category) come back as messages the model can act on.
Details that make the answers better
- Readable values. Synthetic data stores values like
77.92600836244159; tools return77.93 mg/dL. Blood pressure readsSystolic Blood Pressure: 124.7 mm[Hg]; Diastolic Blood Pressure: 81.68 mm[Hg], and coded answers read as text (Never smoker). - Test names found the way people say them. Like names, FHIR's
code:textsearch matches only the start of a test's full name, soa1cmisses "Hemoglobin A1c/Hemoglobin.total in Blood" andsystolicmisses "Blood Pressure". When the server finds nothing, the tool scans the patient's recent results and matches words anywhere in the test name or its parts, and says it did. - References resolved in one request. A medication order may point at a separate Medication resource instead of naming the drug. The tool asks the server to include those (
_include) and returns the drug's name. - Honest notes. Each list says when more items exist than were returned, and an empty allergy list says "none on file", not "no allergies".
Privacy and audit
Three controls answer the first questions a security or compliance team asks about connecting a model to patient data. They are enforced once in the MCP request pipeline, so every tool is covered. The reasoning and the limits are in decision 0006.
| Setting | Values | What it does |
|---|---|---|
FHIR_MCP_AUDIT_LOG |
a file path, or off (default ~/.fhir-mcp/audit.jsonl) |
Writes one JSON line per tool call: time, tool, outcome, duration, arguments, patient ids accessed, item count. Names and birth dates typed into a search are recorded as [redacted]. Each line holds the SHA-256 hash of the line before it, so edits are detectable. |
FHIR_MCP_PRIVACY |
full (default) or deidentified |
De-identified mode follows the HIPAA Safe Harbor approach before anything reaches the model: patient names and cities removed, dates reduced to the year, ages over 89 shown as "90 or older". |
FHIR_MCP_TOOLS |
comma-separated tool names (default: all) | Tools not listed are hidden from the model and refused if called; refusals are audited. |
A real audit record from a test run (the client name is the test script):
{"time":"2026-10-11T18:39:22.7563254+00:00","tool":"get_conditions","outcome":"ok","durationMs":190,"arguments":{"patientId":"76d2fa2d-66a6-4823-8dab-52cd70822844","status":"active"},"patientIds":["76d2fa2d-66a6-4823-8dab-52cd70822844"],"itemCount":7,"privacy":"full","client":"policy-check","previous":"1adb3cbb47e4537c60d0217c6ea57b1ef9f07376cd2940767f1620fe66c76cc5"}Check that a log has not been edited:
dotnet run --project src/FhirMcp.Server -- audit-verify # default log
dotnet run --project src/FhirMcp.Server -- audit-verify path/to/audit.jsonlIn de-identified mode, a search for Reyna Ziemann returns "name": "[redacted]", no city, "birthDate": "1974", and visit dates such as "start": "2020". Invalid settings (an unknown mode or tool name) stop the server at startup instead of falling back to a looser policy.
Getting started
Requires the .NET 10 SDK.
dotnet build
dotnet testTry a search from the command line (defaults to the SMART Health IT R4 sandbox):
dotnet run --project src/FhirMcp.Server -- search --name "reyna ziemann"
dotnet run --project src/FhirMcp.Server -- search --name ham --containsUse a different server by setting FHIR_BASE_URL, for example https://hapi.fhir.org/baseR4/.
Test servers
Both servers below speak FHIR R4 (the version of the standard). They differ in who runs them and what data they hold.
| SMART Health IT sandbox (default) | HAPI public test server | |
|---|---|---|
| URL | https://r4.smarthealthit.org/ |
https://hapi.fhir.org/baseR4/ |
| Run by | The SMART Health IT project | The HAPI FHIR open-source project, as a public demo |
| Data | Curated synthetic patients generated by Synthea, with realistic histories | Whatever anyone uploads; records vary widely and are often sparse |
| Stability | Stable; patient ids stay valid | Data is added and cleared regularly; ids can disappear |
| Use it for | Demos and realistic questions (Reyna Ziemann is here) | Edge cases: hyphenated names, medications given by reference |
Both hold test data only. Never send real patient information to either one; the HAPI server is publicly writable and anything stored there is visible to anyone.
Connect it to Claude Desktop
Build once, then in Claude Desktop open Settings > Developer > Edit Config and add the server, using the full path to the built program (run pwd in the project folder to get it):
{
"mcpServers": {
"fhir": {
"command": "/Users/you/Projects/fhir-mcp/src/FhirMcp.Server/bin/Debug/net10.0/FhirMcp.Server",
"env": {
"FHIR_BASE_URL": "https://r4.smarthealthit.org/",
"FHIR_MCP_PRIVACY": "full"
}
}
}
}Fully quit and reopen Claude Desktop. A full path is more reliable than dotnet run, because desktop apps on macOS do not always share the terminal's PATH.
Connect it to Claude Code
Build once, then register the server. Use -e to set the FHIR server URL:
# SMART Health IT sandbox (default, curated synthetic patients) claude mcp add fhir -e FHIR_BASE_URL=https://r4.smarthealthit.org/ -- /path/to/fhir-mcp/src/FhirMcp.Server/bin/Debug/net10.0/FhirMcp.Server # HAPI public test server (edge cases: hyphenated names, medication references) claude mcp add fhir -e FHIR_BASE_URL=https://hapi.fhir.org/baseR4/ -- /path/to/fhir-mcp/src/FhirMcp.Server/bin/Debug/net10.0/FhirMcp.Server
Replace /path/to/fhir-mcp with the absolute path to this repo. To check what is registered:
claude mcp list
To remove and re-add with a different server:
claude mcp remove fhir
Once registered, ask Claude Code questions like: "Find Reyna Ziemann and summarize her health." or "Is her blood sugar trend improving?"
Test it by hand
MCP Inspector gives you a web page to call each tool:
npx @modelcontextprotocol/inspector ./src/FhirMcp.Server/bin/Debug/net10.0/FhirMcp.Server
| Tool | Input | Expected |
|---|---|---|
The main example patient is Reyna Ziemann (76d2fa2d-66a6-4823-8dab-52cd70822844): 7 active conditions including hypertension and prediabetes, 5 active medications, 10 allergies, 25 lab tests, and an A1c trend from 2014 to 2020. |
| Tool | Input | Expected |
|---|---|---|
| search_patients | name: reyna ziemann |
1 patient, Reyna Ziemann |
| search_patients | name: Melony Hammes |
Melony Williamson, maiden name Hammes |
| search_patients | name: o'keefe |
Mathilda and Clarine O'Keefe |
| get_patient_summary | Reyna's id | 7 conditions, 5 medications, 10 allergies, latest vitals, last visit 2020-05-27 |
| get_medications | Reyna's id, status: active |
Hydrochlorothiazide, inhalers, ferrous sulfate, diphenhydramine |
| get_observations | Reyna's id, text: a1c |
5 results, 2014 to 2020, with a note that words were matched |
| get_observations | Reyna's id, category: laboratory, latestOnly: on |
One result per test (25), including the lipid panel |
| get_observations | Reyna's id, category: labs |
Error listing valid categories |
| search_patients | birthDate: 1/10/1953 |
Error asking for yyyy-MM-dd |
| get_conditions | patientId: ../Patient |
Error saying the id is invalid |
Questions to try in Claude: "Find Reyna Ziemann and summarize her health." "Do her medications line up with her conditions?" "Is her blood sugar getting better or worse?" "Anything in her allergies to watch for?"
Project layout
src/FhirMcp.Core FHIR client, name matching, mapping, privacy and audit (no dependencies)
src/FhirMcp.Server MCP server over stdio, policy filters, plus "search" and "audit-verify" commands
tests/FhirMcp.Tests xUnit tests with synthetic FHIR fixtures
docs/decisions Architecture decision records: why the code is shaped this way
Design choices
The main decisions, with the options considered and their costs, are written up as architecture decision records. In short:
- No dependencies in the core library. The FHIR client uses
HttpClientandSystem.Text.Jsonand reads only the fields it needs, which keeps the output shaping explicit and easy to test. - Read-only, bounded queries. Every search requires real criteria and caps its results (50 at most), so a tool call can never page through a whole server.
- Honest data. Partial birth dates (
1955) are not turned into exact ages, deceased patients get no current age, and looser matches are labeled as such. - Testable without a network. Tests run against synthetic FHIR responses through a fake HTTP handler, and the clock is injected through
TimeProvider. 117 tests cover mapping, name and test matching, request shape, limits, errors, the JSON each tool returns, de-identification, and the audit log's hash chain.
Security notes
- Read-only: the client only issues
GETrequests. - Patient ids are checked against the FHIR id format before they go into a URL, so a malformed or injected value (
../Patient?_count=1000) is rejected without a request. - Paging only follows
nextlinks that point back at the configured server, so a response cannot steer the client to another host. - Every tool call is audited, including refusals and failures, in a tamper-evident log (see Privacy and audit).
- Synthetic data only. The privacy controls here are a starting point, not a compliance program: do not point this at a server holding real patient data without authentication (SMART on FHIR), protected storage for the audit log, and a review of free-text fields before relying on de-identified mode.
- Error messages returned to the model contain the server's diagnostic text, never stack traces or raw payloads. Logs go to stderr so they never mix with the MCP protocol on stdout.
Roadmap
- SMART on FHIR (OAuth 2.0) authentication
- Remote HTTP deployment on Azure Container Apps
- An MCP prompt that assembles a visit summary from several tools
来源:GitHub · github.com