← back to blog
MCPHealthcarePrivacy

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_patient takes a patient_id and returns the FHIR R4 Patient resource.
  • search_observations takes a patient_id and a LOINC code and returns the matching Observation list.
  • get_medication_list takes a patient_id and 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.

CheckResult
Audit log lines added3
grep for plaintext patient_id in audit log0 matches
grep for SSN 999-12-3185 in audit log0 matches
grep for phone 555-399-7091 in audit log0 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 pieceProduction replacement
SMART sandbox URLAWS HealthLake over a VPC interface endpoint
CALLER_IDENTITY env varSPIFFE SVID read from the Workload API socket
Audit log filestdout 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