The Best FHIR MCP Server Gives the Model No FHIR Tools
Why a production FHIR MCP server gives the model typed tasks, not FHIR: SMART scopes, Bundle bloat, minimum PHI and audit logs from MedScrub and eSpiral.
Mostly about building products, healthcare tech, and lessons learned along the way.
Why a production FHIR MCP server gives the model typed tasks, not FHIR: SMART scopes, Bundle bloat, minimum PHI and audit logs from MedScrub and eSpiral.
We stopped giving the model FHIR at all. If you're building a FHIR MCP server, I think you should too.
Don't expose FHIR search to the agent. Expose typed tasks. Your own code does the FHIR fetch under SMART scopes you control. That code bounds the data, filters it, de-identifies it and logs it before anything reaches the model.
The published open-source FHIR MCP servers mostly go the other way: they put FHIR operations in the model's hands. Momentum's fhir-mcp-server offers full CRUD on FHIR resources from Claude Desktop. That makes a great demo. It's the wrong shape for production.
eSpiral gives the model zero FHIR tools. At SMART launch the server pulls a narrow slice with read-only scopes: Patient, Condition, Observation, Encounter and ServiceRequest. It projects each Condition down to ICD-10 code, date and description. It runs that through MedScrub's de-identification and puts the result in the prompt. The model's only tools are UI actions: highlight conditions, focus a year, show or hide problems.
MedHook follows the same idea. The model designs and inspects integration workflows. Deterministic adapters make the FHIR calls under fixed backend scopes.
MedScrub's MCP server is the closest to what people mean by a FHIR MCP server, and the model still never touches FHIR. It passes an opaque patient ID to a typed tool like "generate SOAP note." A fixed pipeline then runs fetch, de-identify, LLM, re-identify. The tool surface has roughly this shape (an illustrative sketch, not the exact definition):
{
"name": "generate_soap_note",
"inputSchema": {
"type": "object",
"properties": {
"patient_ref": { "type": "string", "description": "Opaque ID only." }
},
"required": ["patient_ref"]
}
}
Once the fetch is our code, we can bound it, filter it and test it. A model holding a search tool can't be bounded. I made the general case for agent-shaped interfaces in Every Service Is Going to Need an MCP Layer. In healthcare, the cost of an unbounded tool is a PHI disclosure.
A tempting design maps one SMART scope to one MCP tool. It looks clean, but it mixes up two jobs.
Scopes are an authorization boundary. They say what you may fetch. They say nothing about what the model should see. In SMART v2, patient/*.rs means read and search any resource for the current patient. That is the whole chart. If you wrap that grant in a search_resources tool, the model decides how much PHI to pull on every call.
Granular scopes help a little. SMART v2 lets you narrow a scope by search parameter, as in patient/Observation.rs?category=laboratory. That still only sets a ceiling on access. It doesn't tell you what each tool call needs.
There are also two "minimum necessary" rules:
A tight scope request does not make every tool call minimum necessary. Whether your workflow falls under the treatment exemption is a question for your compliance person. Your MCP layer doesn't get to decide it. I covered more of the HIPAA side in HIPAA Consent: What Engineers Actually Need to Know.
Scopes also break things operationally. eCW rejects the whole token request if even one scope isn't provisioned on their side. That once took down our sync for 28 days. Vendor syntax doesn't match the spec either. The spec wants suffixes in .cruds order, but mock.health documents Oracle Health's .rsu and .rsuc forms. Plan for a per-vendor translation layer, and request as few scopes as you can. Each extra scope is one more thing the EHR can reject.
Some FHIR MCP server READMEs advertise "SMART-on-FHIR authentication." The PyPI fhir-mcp-server package is one. Find out what that phrase means before you adopt one. The MCP spec says a server MUST NOT pass through the token it got from the client. It must also reject tokens whose audience isn't the server itself. When it calls an upstream API, it uses a separate token from the upstream authorization server.
For FHIR, that means two token boundaries. The agent authenticates to your MCP server. Your MCP server holds its own EHR credentials, either from SMART launch or under fixed backend scopes as in MedHook. Then your server decides what to fetch. A server that forwards the agent's bearer token to the EHR is the token passthrough anti-pattern the spec names explicitly. Pin the spec version you build against. Client registration alone shifted between releases: in 2025-11-25, Dynamic Client Registration dropped to a MAY.
Thin demo patients won't surface this one.
In MedScrub, one complex patient's chart produced this:
prompt is too long: 1,302,962 tokens > 1,000,000 maximum
The skill returned nothing. We had been sending the whole de-identified Bundle as pretty-printed JSON. Every resource carried its text.div narrative HTML, its meta and its extensions. We fixed it in three steps the same week.
meta, extensions and narrative, and minify. Cap the prompt at about 1.5M characters, roughly 400–450k tokens. If it still doesn't fit, keep the newest resources.Fitting under the limit isn't the only goal. A smaller, focused context gives better answers, for the reasons in Context Rot.
eSpiral avoids the problem at the source with projection. The model never sees a Condition resource, only something like this:
// Sketch: the only Condition shape the model ever sees.
function project(c: Condition) {
const icd = c.code?.coding?.find(x => x.system?.includes("icd-10"));
if (!icd?.code) return null;
return { icd10: icd.code, date: c.onsetDateTime ?? c.recordedDate, description: icd.display };
}
The opposite bug is quieter. We found a _count=100 fetch that silently truncated patients with more than 100 conditions or labs. It returned no error; the remaining pages were dropped. Now we follow next links:
let url: string | undefined = `${base}/Condition?patient=${id}&_count=100`;
while (url) {
const bundle = await fhirGet(url);
rows.push(...(bundle.entry ?? []));
url = bundle.link?.find(l => l.relation === "next")?.url;
}
Every PHI leak I've found was on a path we thought was covered.
deidentified: true. Separately, Sentry breadcrumbs carried "Briefing failed for <patient name>" out with error reports.Test the claim itself. MedHook now has a test that feeds in a synthetic name, MRN, DOB, phone number and SSN and asserts they don't get through. If your product says "de-identified" anywhere, write a test that tries to prove it false. That covers the UI, a flag in the code and the sales deck.
Sentry, log lines and debug endpoints can carry PHI out too. We hadn't put them in our threat model. Put them in yours.
The audit log can be a PHI leak too.
eSpiral had logs writing patient names and problem lists to CloudWatch. It also had a debug endpoint that returned them as JSON. We deleted both.
MedScrub's audit ledger works the other way. It's append-only and allow-listed. It stores an opaque patient ID and never a name, even if a caller passes one in. The allow-list matters more than the append-only part. Without it, any developer who adds a field to the log call widens what you store, and nobody reviews that change.
HIPAA's audit control standard, 164.312(b), requires you to record and examine activity in systems that touch ePHI. It doesn't prescribe a format. So when a FHIR MCP server README promises a "HIPAA-compliant audit trail," treat it as marketing. HIPAA has no certification for log formats. If you want a standard shape, FHIR's AuditEvent has slots for the actor (including software agents), the action, the outcome and the patient involved.
For each tool call, log the tool name, opaque patient reference, calling identity, outcome and timestamp. Never log the payload.
Use them against synthetic data. They're a fast way to see what an agent does with a chart and whether it reaches for broad queries. Keep them away from real charts until the fetch, projection, de-identification and audit layers are yours.
Wrapping a FHIR search endpoint in an MCP tool is less work. Typed tasks over a fetch layer you own is the only version I've been able to bound, test and defend.