FhirMCP
Click on "Deploy Server".
Wait a few minutes for the server to deploy. Once ready, it will show a "Started" state.
In the chat, type
@followed by the MCP server name and your instructions, e.g., "@FhirMCPsearch for patients with diabetes born after 1990"
That's it! The server will respond to your query, and you can continue using it as needed.
Here is a step-by-step guide with screenshots.
FHIR-MCP - FHIR Model Context Protocol Server
FHIR-MCP is an open-source MCP (Model Context Protocol) server that lets LLMs interact with FHIR servers and HL7 terminology services behind a PHI de-identification layer. It provides FHIR read/search/create/update, terminology operations, PHI classification and masking, and audit logging.
Status: pre-production. This branch carries nine lanes of PHI remediation (A-I) plus fixes for four issues found by a live audit against a real FHIR server. Several of those leaks were open in every release before this branch. Three of the four audit findings are fixed; one remains an open design question - see Security issues found by the live audit. Read that section before deploying against real patient data.
Features
PHI protection: rule-driven classification and masking, pseudonym tokens, fail-closed authorization
FHIR operations: read, search, create, update
HL7 terminology: ValueSet expansion, CodeSystem lookup, concept translation
Audit logging: structured JSON records with trace IDs, emitted on stderr by default
Security hardening: OWASP security headers, multi-tier rate limiting, Joi input validation
Token efficient: field selection (
elements), paginationInteroperable: works with HAPI FHIR, Firely and other R4/R4B servers
HTTP bridge: REST API example with Docker packaging
Modern architecture: ES modules, TypeScript
Related MCP server: FHIR MCP Server
Breaking change on this branch: identity is required for PHI
Before lane H, the tool layer built its SecurityContext without a userId. The measured effect against a live FHIR server was that every IDENTIFIABLE resource was suppressed before masking, so the masking engine never ran in production at all.
Lane H added src/security/identity.ts and wired a caller identity through. The consequence you will hit immediately:
Without a configured principal, every read of an IDENTIFIABLE or RESTRICTED resource is denied with HEALTHCARE_COMPLIANCE_VIOLATION and returns no resource body.
This is deliberate and it is fail-closed: performComplianceChecks (src/security/security-middleware.ts) blocks the request when context.userId is absent at those PHI levels. An unconfigured deployment behaves exactly as the old one did — denied — rather than quietly returning unmasked data.
To get resource bodies back, configure a service principal:
# PowerShell
$env:MCP_SERVICE_PRINCIPAL_ID = "svc-analytics"
$env:MCP_SERVICE_PRINCIPAL_SCOPES = "system/*.read"
# bash
export MCP_SERVICE_PRINCIPAL_ID="svc-analytics"
export MCP_SERVICE_PRINCIPAL_SCOPES="system/*.read"See Authentication & authorization for the full scope table and the transport-specific rules.
Quick Start
Install dependencies (from the repository root — this is an npm workspace):
npm installBuild.
dist/is gitignored but load-bearing at runtime — see The build trap. This step is not optional.npm run buildConfigure.
# bash export FHIR_BASE_URL="https://hapi.fhir.org/baseR4" export TERMINOLOGY_BASE_URL="https://tx.fhir.org/r4" export PHI_MODE="safe" export MCP_SERVICE_PRINCIPAL_ID="svc-analytics" export MCP_SERVICE_PRINCIPAL_SCOPES="system/*.read"# PowerShell $env:FHIR_BASE_URL = "https://hapi.fhir.org/baseR4" $env:TERMINOLOGY_BASE_URL = "https://tx.fhir.org/r4" $env:PHI_MODE = "safe" $env:MCP_SERVICE_PRINCIPAL_ID = "svc-analytics" $env:MCP_SERVICE_PRINCIPAL_SCOPES = "system/*.read"Start the server:
cd packages/mcp-fhir-server npm startSmoke test:
node test-basic-functionality.js
The build trap
dist/ is in .gitignore (line 8), and it is what actually runs:
Consumer | Path |
|
|
|
|
|
|
|
|
| copies |
MCP client config (below) |
|
Jest, meanwhile, runs against src/ via ts-jest. A green test suite therefore says nothing about the deployed path. npm run build is mandatory before deploying, before testing runtime behaviour, and after every pull that touches src/. A stale dist/ will happily serve the pre-remediation masking code while the tests pass.
Windows note: npm run start:http is broken on PowerShell
packages/mcp-fhir-server/package.json defines:
"start:http": "MCP_TRANSPORT=http PORT=8080 node dist/http.js"That VAR=value command prefix is POSIX-shell syntax. On PowerShell it fails — PowerShell parses MCP_TRANSPORT=http as a command name. Every VAR=... npm ... line in this repository's docs has the same problem on Windows. Until the script is made portable (as scripts/run-jest.mjs already was for the test runner), use:
$env:MCP_TRANSPORT = "http"
$env:PORT = "8080"
node dist/http.jsor cmd /c "set MCP_TRANSPORT=http && set PORT=8080 && node dist/http.js".
Available Tools
FHIR Operations
fhir.capabilities— get server capability statementfhir.search— search resources with filtering and paginationfhir.read— read a resource by IDfhir.create— create a resourcefhir.update— update a resource
Terminology Services
terminology.lookup— look up code properties and display namesterminology.expand— expand ValueSetsterminology.translate— translate codes between systems
Project Structure
packages/
├── mcp-fhir-server/ # Main MCP server
│ ├── src/
│ │ ├── providers/ # FHIR and terminology HTTP clients
│ │ ├── security/ # identity, PHI classifier/masking/authz, audit, validation
│ │ ├── tools/ # MCP tool handlers and schemas
│ │ ├── types/ # PHI matrix, masking rules, config types
│ │ └── tests/ # jest suites + canary fixtures (run against src/)
│ ├── scripts/ # portable jest launcher, baseline gate, e2e probes
│ └── dist/ # Compiled JS — gitignored, required at runtime
├── examples/
│ ├── http-bridge/ # HTTP REST API bridge for web clients
│ ├── claude-client/
│ └── copilot-integration/
docs/
├── QUICKSTART.md
├── PROMPTS.md
├── SECURITY.md
├── AI_INTEGRATION.md
└── QA-REPORT.md # test status against test-baseline.json
tests/e2e/ # standalone e2e scriptPHI protection
What masking covers
Masking runs only after authorization allows the read. Rules come from three places: the PHI level defaults (DEFAULT_MASKING_RULES), a set of global rules applied to every resource type regardless of the per-type switch, and per-type rules in PHIClassifier.getResourceSpecificMaskingRules().
Applied to an IDENTIFIABLE resource:
Element | Treatment |
| replaced with a pseudonym token ( |
| replaced with a pseudonym token |
| rewritten to |
| replaced with |
| removed |
| removed |
| removed by default ( |
| removed |
| removed ( |
Attachment | removed at ten known paths; |
| recursed into and masked as resources in their own right |
| rebuilt from a whitelist — |
Uniform tokenization
resource.id and Reference.reference are tokenized consistently across Condition, Observation, Encounter, MedicationRequest, Procedure and DiagnosticReport. The same patient yields the same token everywhere within one process, so a masked bundle stays internally joinable.
Pseudonym tokens are per-process — read this before caching them
Tokens are PT_ + 12 base64url characters, derived as HMAC-SHA256(sessionKey, value). The session key is crypto.randomBytes(32), generated in the PHIMaskingEngine constructor. Production constructs the engine with no key argument (phi-authorization-engine.ts:250), and nothing persists the key.
Consequences:
Restarting the server changes every token. The same patient is
PT_aBc…before a restart and something else after.Cross-session longitudinal linkage is impossible by design. This is a real unlinkability property, not an accident.
Any consumer that caches or stores
PT_tokens as stable keys will break at the next restart, silently, by splitting one patient into two. Treat a token as valid only within the response set it arrived in.
rotateSessionKey() exists and drops every derived pseudonym when called. There is currently no supported way to supply a stable key from configuration.
Authentication & Authorization
This section describes what is wired on this branch. There is no OAuth2 or SMART authorization server in this repository; see What does not exist below for the full list.
What exists
Caller identity (src/security/identity.ts, lane H). A single service principal is built from environment variables:
Variable | Meaning |
| Principal id. Absent or empty → no principal → PHI denied. |
| Space-separated scopes from the closed set below. |
Scope spelling follows SMART on FHIR, but only as a naming convention so that a future token issuer's scope claim maps across without translation. The complete grant table is:
Scope | Grants |
|
|
|
|
|
|
x-restricted/*.read is non-standard on purpose: the RESTRICTED tier (Coverage, Claim, ExplanationOfBenefit, Bundle, Binary) has no SMART equivalent, so reaching it requires a scope that cannot be confused with a standard one and never rides along with an ordinary read scope.
Parsing is strict: an unrecognised scope, a malformed id, or an id declared with no scopes is a startup error, not a downgrade. A configured principal always has roles: [] and never sets phiAccessLevel or isEmergencyAccess — those are independent grant routes inside the authorization engine, and configuration may not use them. The emergency grant is the only route that returns PHI unmasked, and no environment variable can open it.
Transport binding:
stdio —
StaticIdentityProviderholds the configured principal for the process lifetime.HTTP/SSE —
RequestScopedIdentityProvidercarries the principal in anAsyncLocalStorageentered only after a bearer token has been verified. A tool call arriving by any route that did not go throughrunWithPrincipalgets no identity at all.Default —
ANONYMOUS_IDENTITY_PROVIDER, which returnsundefined. Omitting a provider denies rather than grants.
HTTP bearer authentication is a single shared secret: AUTH_TOKEN, compared with a timing-safe comparison in http.ts. If AUTH_TOKEN is unset the HTTP bridge is open — it returns allowed: true — but binds no principal, so PHI stays denied. Setting a principal does not authenticate anyone; it only says who a verified caller is.
What does not exist
No OAuth2 authorization server, no Authorization Code flow, no PKCE, no client credentials grant, no token introspection, no JWKS verification, no per-user tokens, no token expiry.
AuthConfig(src/types/config.ts) andAuthConfigSchema(src/tools/schemas.ts) are orphan type declarations with no implementation behind them.No FHIR
AuditEventresources are emitted. Audit records are this project's own JSON shape, written withconsole.error(orconsole.logwhenAUDIT_SINK=stdout).No machine learning is used anywhere in classification or anomaly detection. Classification is a static resource-type matrix plus regular-expression field-name patterns.
Real token-issuer integration remains Phase 2 work. identity.ts defines the seam it plugs into: anything that can verify a caller produces a User and hands it to an IdentityProvider.
Audit logging
Records are structured JSON on stderr (stdout is the MCP protocol channel; AUDIT_SINK=stdout restores the old behaviour for deployments already scraping it).
Changes on this branch:
originalInputis no longer written. It previously carried a sanitized copy of the whole request.Metadata passes through a structural allowlist, not a keyword denylist, and it is recursive. A key nobody anticipated is dropped rather than published.
The authorization catch block logs the error class only (
AuditLogger.errorClass), nevererror.message— a thrown message routinely quotes the resource that broke it.resourceIdis replaced byresourceIdHash, a keyed HMAC-SHA256 (AH_prefix). It was previously an unsaltedsha256truncated to 16 hex and was reversible; see issue 1 below.Denied reads now produce an audit record. They previously produced none:
fhir-toolsreturned as soon asallowedwas false and never reachedlogFhirOperation, so the single most reviewable event this control produces left no trace.
Security issues found by the live audit
A live audit against a real FHIR server (HAPI R4) found four issues beyond the nine remediation lanes. Three are fixed on this branch; the fourth is an open design question. They are recorded here rather than only in an issue tracker because overclaiming is the specific failure mode that produced most of the defects lanes A-I were created to fix.
1. resourceIdHash was reversible - FIXED
AuditLogger.hashIdentifier was sha256(value) truncated to 16 hex characters, unsalted and unkeyed, under a docstring claiming it was "not a reversible identifier on its own." That was false for the low-entropy identifier spaces this server handles: a live patient id was recovered from a log line by direct comparison against sha256(id).
The repository already condemned the identical construction - src/tests/fixtures/canary.ts defines it as LEGACY_UNSALTED_SHA256 and describes it as "equivalent to publishing the ID." The audit logger and the canary module disagreed, and the canary module was right.
Now a keyed HMAC-SHA256, matching what PHIMaskingEngine already did for pseudonyms, emitting an AH_-prefixed token. The false docstring is gone. A regression test asserts the emitted digest is not equal to sha256(id).substring(0,16).
2. Free text unmasked on several clinical resource types - FIXED
getResourceSpecificMaskingRules() had case arms for Patient, RelatedPerson, Observation, Encounter, Coverage, Organization, Practitioner, DocumentReference and DiagnosticReport - and no arm at all for Condition, MedicationRequest, Procedure or CarePlan, so their free-text elements survived masking: note[].text and code.text on Condition, note[] and dosageInstruction[].text on MedicationRequest, note[] and report[].display on Procedure, description on CarePlan, presentedForm[].title and .url on DiagnosticReport.
Observation and Encounter were clean, so this was inconsistency between rule sets rather than uniform absence - the guarantee had been written once per resource type, and was therefore missing for every type nobody got to. Clinical notes are exactly where a patient name or ID ends up in practice.
Closed as a class, not a list: GLOBAL_FREE_TEXT_MASKING_RULES is applied unconditionally to every resource rather than enumerated per type, so a resource type added later inherits the protection instead of silently missing it.
3. resourceType log-injection channel - FIXED
handleRead copied args.resourceType straight into the SecurityContext, and on the denial path auditSecurityDenial wrote it verbatim into the audit record - twice, as a top-level field and inside metadata. That path runs before validation succeeds and without authentication, so an unauthenticated caller could write chosen text, including forged record boundaries, into the audit stream.
resourceType is now deliberately absent from the audit allowlist and passes through AuditLogger.safeResourceType(), which emits a placeholder for any value not present in RESOURCE_PHI_MATRIX.
4. Open design question: is a logical id a direct identifier?
This one is not fixed, and deliberately so.
phi-classifier.ts:109-110 sets hasDirectIdentifiers when a key is identifier or id, and line 159 then upgrades MINIMAL to IDENTIFIABLE. Since virtually every resource fetched from a server carries an id, PHILevel.MINIMAL is unreachable in practice.
Three tests are quarantined in test-baseline.json pending a decision. They are not defects to be silently fixed - the decision costs something either way:
Narrowing
hasDirectIdentifierstoidentifier(plusidon patient-like types) reduces protection.Keeping the rule means
PHILevel.MINIMALis dead code and the tests should be changed to expectidentifiable.
No lane owned this decision and it was deliberately not made during the merge. The other five baseline failures (input sanitization, rate limiting x3, security headers) are pre-existing and outside the PHI remediation plan; they need their own triage.
Also unverified
DocumentReference is IDENTIFIABLE in RESOURCE_PHI_MATRIX (phi-types.ts:105) and carries per-type masking rules, but it is absent from InputValidator.isValidResourceType's allowlist (input-validator.ts:423-430). Reads and searches for it are rejected at validation, so its masking rules are unreachable through the tool surface. Whether this is an intentional restriction or an oversight is not recorded anywhere in the repository; it is documented here as a limitation, not a decision.
Testing
npm run build # required before any runtime testing — see the build trap
cd packages/mcp-fhir-server
npm test # jest, via the portable ESM launcher
npm run test:gate # jest + enforce test-baseline.json in both directions
npm run typecheck
npm run typecheck:tests
npm run lintCurrent status on this branch: 250 of 258 tests pass, with 8 known failures and 0 regressions (verified by npm run test:gate; 14 suites, 12 passing).
The 8 failures are enumerated with per-entry reasoning in packages/mcp-fhir-server/test-baseline.json: three are the PHILevel.MINIMAL design question above, five are pre-existing failures outside the PHI remediation plan.
test:gate enforces the baseline in both directions — an unlisted failure fails CI as a regression, and a listed test that starts passing also fails CI so the entry gets pruned. That is what stops the file from rotting into a blanket suppression.
Additional standalone scripts (not part of the jest suite):
node test-basic-functionality.js
node manual-qa-test.js
node tests/e2e/test-fhir-mcp.js
npm run test:e2e # scripts/lane-h-e2e.mjsConfiguration
Read by the MCP server (packages/mcp-fhir-server):
Variable | Description | Default |
| FHIR server base URL |
|
| Bearer token sent to the FHIR server | — |
| HL7 terminology service URL |
|
| Bearer token sent to the terminology service | — |
|
|
|
| Audit logging; any value other than |
|
| Caller identity. Unset → all IDENTIFIABLE reads denied. | — |
| Space-separated scopes from the table above | — |
|
| stdio |
| HTTP transport port |
|
| Shared bearer secret for HTTP mode. Unset → HTTP bridge is open. | — |
|
| stderr |
| Standard | — |
Read by the packages/examples/http-bridge example only, not by the MCP server: ALLOWED_ORIGINS, REQUIRE_HTTPS, SECURITY_LOGGING, PORT, NODE_ENV, FHIR_BASE_URL, TERMINOLOGY_BASE_URL.
Rate-limiter thresholds are compiled in, not configurable by environment variable.
Using with Claude
{
"mcpServers": {
"fhir": {
"command": "node",
"args": ["path/to/FHIR-MCP/packages/mcp-fhir-server/dist/index.js"],
"env": {
"FHIR_BASE_URL": "https://your-fhir-server.com/fhir",
"TERMINOLOGY_BASE_URL": "https://tx.fhir.org/r4",
"PHI_MODE": "safe",
"ENABLE_AUDIT": "true",
"MCP_SERVICE_PRINCIPAL_ID": "claude-desktop",
"MCP_SERVICE_PRINCIPAL_SCOPES": "system/*.read"
}
}
}
}Run npm run build first — this config points at dist/, which is not in the repository.
Omit MCP_SERVICE_PRINCIPAL_ID and the server will start and answer fhir.capabilities and terminology calls normally, but every Patient read will come back as Access denied: HEALTHCARE_COMPLIANCE_VIOLATION.
HTTP Bridge for Web Applications
For browser-based clients that cannot speak MCP directly.
cd packages/examples/http-bridge
npm run build# PowerShell
$env:PORT = "3001"
$env:FHIR_BASE_URL = "https://hapi.fhir.org/baseR4"
$env:TERMINOLOGY_BASE_URL = "https://tx.fhir.org/r4"
$env:PHI_MODE = "safe"
$env:ENABLE_AUDIT = "true"
npm start# bash
PORT=3001 FHIR_BASE_URL="https://hapi.fhir.org/baseR4" \
TERMINOLOGY_BASE_URL="https://tx.fhir.org/r4" \
PHI_MODE="safe" ENABLE_AUDIT="true" npm startDocker
docker-compose up --build
docker build -t fhir-mcp .
docker run -p 3002:3001 \
-e FHIR_BASE_URL="https://hapi.fhir.org/baseR4" \
-e TERMINOLOGY_BASE_URL="https://tx.fhir.org/r4" \
-e PHI_MODE="safe" -e ENABLE_AUDIT="true" fhir-mcpEndpoints at http://localhost:3002 (3001 for local dev):
GET /health— health checkGET /tools— list available toolsPOST /fhir/capabilities|/fhir/search|/fhir/read|/fhir/create|/fhir/updatePOST /terminology/lookup|/terminology/expand|/terminology/translatePOST /tools/{toolName}— generic tool interface
The MCP server's own HTTP transport (MCP_TRANSPORT=http) exposes /healthz separately.
Active controls
OWASP security headers
Multi-tier rate limiting (thresholds compiled in; three of the eight baseline test failures are rate-limiter behaviour disagreements, so verify against your own traffic rather than trusting the defaults)
Joi input validation and sanitization
PHI-aware authorization, fail-closed without a principal
Audit logging on every allowed and denied read
Roadmap
MVP: basic FHIR operations and terminology lookup
ES Modules: modern JavaScript module support
HTTP Bridge: web-accessible REST API
Docker: containerized deployment
Test infrastructure: jest running under ESM, golden corpus, PHI canary harness, enforced failure baseline (lane A)
PHI remediation lanes A–I: fail-closed guard construction, masking rules, masking engine, authorization invariant, log-leak closure, caller identity, uniform tokenization
Security Phase 1: not complete. Three of four audit findings are fixed; one open design question remains, and five pre-existing baseline failures (input sanitization, rate limiting, security headers) are untriaged. See Security issues found by the live audit. Previous revisions of this file marked this done; masking was unreachable in production until lane H and three PHI log leaks were open until lane G.
QA: not complete. 250/258 with 8 known failures; see docs/QA-REPORT.md.
Phase 2: real OAuth2 / SMART-on-FHIR token verification, advanced policy engine
Phase 3: delete operations, bulk export, R5 support
Future: GraphQL support, subscription webhooks
Contributing
Fork the repository
Create a feature branch (
git checkout -b feature/amazing-feature)Commit your changes (
git commit -m 'Add amazing feature')Push to the branch (
git push origin feature/amazing-feature)Open a Pull Request
If a change makes a listed known issue stale, update this README in the same commit. If a change makes a test-baseline.json entry pass, delete that entry in the same commit — test:gate will fail otherwise, on purpose.
License
MIT — see LICENSE.
Acknowledgments
HL7 FHIR for the interoperability standard
Model Context Protocol for the protocol specification
HAPI FHIR for the reference implementation
HL7 Terminology Services for code system management
This server cannot be deployed
Maintenance
Related MCP Connectors
Guardrailed FHIR access for AI agents: PHI redaction, audit trail, step-up auth, tenant isolation
HealthGuard - 12-tool health/medical AI safety MCP: PII redaction, HIPAA, GDPR Art.9.
- mcpOAuthcom.medplum
Securely access and manage FHIR healthcare data stored in Medplum.
Privacy-preserving synthetic health data generation. FHIR R4/R5 compliant.
Related MCP Servers
- AlicenseAqualityDmaintenanceEnables LLM-based agents to interact with FHIR healthcare data through natural language prompts, providing full CRUD operations on FHIR resources, document processing, and semantic search capabilities.1399MIT
- AlicenseNot gradedqualityNot gradedmaintenanceEnables seamless integration with FHIR APIs for healthcare applications, allowing users to search, retrieve, create, update, and analyze clinical information through natural language interactions. Supports SMART-on-FHIR authentication and works with various healthcare systems like EPIC and HAPI FHIR servers.Apache 2.0
- FlicenseAqualityDmaintenanceProvides read/write access to any FHIR-compliant healthcare API with built-in validation, supporting resource management, search operations, and granular permissions through natural language.51-
- AlicenseAqualityCmaintenanceProvides seamless integration with FHIR APIs, enabling AI/LLM tools to search, retrieve, and analyze clinical healthcare data with support for SMART-on-FHIR authentication and multiple transport protocols.7116 PyPI136Apache 2.0