An MCP server that does not log the patient ID, on purpose
Most public MCP demos that read patient data would fail a HIPAA audit on a single grep. A walk-through of a healthcare-grade MCP server design where the patient ID never enters logs by construction.
Also on Medium.
Most public MCP demos that read patient data would fail a HIPAA audit on a single grep. They look fine in a screen recording. The moment they are pointed at real data, they leak identifiers into stdout, debug traces, and cloud logs. The technical controls under §164.312 are specific. Most public examples skip them entirely. Here is what a structural fix looks like.
The prototype, in three tools
The repo is hipaa-fhir-mcp on GitHub. TypeScript, Node 25, MCP over stdio, MIT licensed. It exposes three tools to Claude Desktop:
get_patienttakes apatient_idand returns the FHIR R4 Patient resource.search_observationstakes apatient_idand a LOINCcodeand returns the matching Observation list.get_medication_listtakes apatient_idand returns the active MedicationRequest list.
The data source is the public SMART sandbox at r4.smarthealthit.org. Everything in it is synthetic Synthea output. No real PHI ever touches the repo.
The audit invariant
Every tool call, success or failure, emits exactly one structured JSON record. Same shape every time:
{
"timestamp": "2026-04-21T10:15:03.123Z",
"tool": "get_patient",
"patient_id_hmac": "9b74c9897bac770ffc029102a200c5de...",
"caller_identity": "local-dev",
"request_id": "a3e1...",
"status": "success"
}
The plaintext patient_id is never written to any persistent sink: not logs, not traces, not error reports. The audit log only ever sees an HMAC-SHA-256 of the ID, computed with a server-side secret held in a secrets manager (KMS-backed in production). Plain SHA-256 would be reversible by enumeration if the ID space is small or predictable. The keyed HMAC closes that gap. The caller_identity field is the workload identity stamped on each record: an env-var stub locally, a SPIFFE SVID in production. This is the §164.312(b) audit control implemented in code, not in a Confluence page.
One trade-off is worth naming. The HMAC is deterministic by design: same patient ID plus same key produces the same output. That is what makes audit and KMS logs cross-referenceable for the same patient (see the production path below). It also means anyone with read access to the audit log can count requests per patient. The mitigation is access control on the log stream, not the HMAC.
Hash-only is a contract, not a convenience. If a developer later adds a log statement that accidentally includes the raw ID, the invariant breaks silently and nobody notices for months. The fix is structural: hash at the boundary, pass the HMAC everywhere downstream, fail loudly if anything else shows up. Most HIPAA log-related incidents are not malicious. They are PHI leaking into stdout because the rule was set at the wrong layer.
The invariant covers one layer: the MCP server’s own audit log. The tool’s return payload contains the full FHIR resource and travels to the host process over stdio. What the host and the model do with that payload, including whether they persist or echo it, is a separate problem that lives above this layer. The point of naming the invariant precisely is so the layers above can be reasoned about separately.
Hashing the identifying field at the boundary, never below it, is a small structural decision that removes a large class of incidents.
What the live test proved
I expected the tools to be the interesting part of this build. They were not. The interesting part was running the end-to-end flow and watching what the audit log did and did not contain.
I pulled a real-shape synthetic patient from the public Synthea sandbox: ID ae8a896e-bbd9-4e1a-a732-1568df9d7527, name “Raúl Fernández”, SSN 999-12-3185, phone 555-399-7091, DOB 1971-12-23, address in Brockton MA. All values are synthetic but production-shaped. The sandbox cycles its data periodically, so a reader running the same flow today will see a different but similarly-shaped patient. I called all three tools in sequence and inspected the artifacts.
| Check | Result |
|---|---|
| Audit log lines added | 3 |
| grep for plaintext patient_id in audit log | 0 matches |
| grep for SSN 999-12-3185 in audit log | 0 matches |
| grep for phone 555-399-7091 in audit log | 0 matches |
| Repo-wide regex sweep for PHI (pnpm check:phi) | 0 findings |
Three tool calls, three audit records, zero leaks of any identifier the patient could be re-identified by. That is what the invariant looks like when it actually holds.
What is NOT in scope
The repo is honest about what it is not. The point of a prototype is to make the gap visible, not to fake compliance. Five pieces are deliberately stubs:
- SMART-on-FHIR OAuth 2.1 + PKCE for user identity. Stub.
- SPIFFE workload identity for service-to-service trust. Stub.
- KMS envelope encryption for cached PHI at rest. Stub.
- LLM and host PHI handling. Tool return payloads carry full FHIR resources, including any PHI fields. The host process and the model can persist, echo, or surface that data in ways the MCP server cannot see or prevent. That belongs at the host layer. Out of scope here.
- Business Associate Agreement with AWS and with any LLM provider. Not present.
Administrative and Physical safeguards are out of scope for a different reason. They are organizational controls: workforce clearance, facility access, training. Not the job of an MCP server.
The repo will not pass a HIPAA audit on its own. It is not supposed to. It is supposed to show what the technical scaffolding looks like before the policy work is done.
The production path
Each stub maps to a specific production replacement. Nothing exotic. All standard AWS healthcare patterns:
| Prototype piece | Production replacement |
|---|---|
| SMART sandbox URL | AWS HealthLake over a VPC interface endpoint |
| CALLER_IDENTITY env var | SPIFFE SVID read from the Workload API socket |
| Audit log file | stdout to CloudWatch Logs, 2-year retention |
| OAuth stub (security/oauth) | Real SMART OAuth 2.1 + PKCE |
| KMS stub (security/kms) | CMK with kms:EncryptionContext = patient_id_hmac |
The last row is the one worth pausing on. Binding the KMS encryption context to the same HMAC already used in the audit trail makes audit logs and KMS logs cross-checkable for the same patient, without ever co-locating the plaintext ID anywhere. Decrypts that do not match the expected context fail closed.
Closing
The repo is public, MIT-licensed, and runs end to end against the public sandbox in a few minutes (docs/demo.md). The HIPAA mapping (docs/hipaa-compliance-mapping.md) lays out each control against §164.312 with the prototype state and the production state side by side. The audit invariant is the part most worth copying into other contexts.
Stack: TypeScript, Node 25, Model Context Protocol (stdio), HL7 FHIR R4, HMAC-SHA-256