Skip to main content
Glama

FHIR-MCP - FHIR Model Context Protocol Server

TypeScript Node.js License

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), pagination

  • Interoperable: 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

  1. Install dependencies (from the repository root — this is an npm workspace):

    npm install
  2. Build. dist/ is gitignored but load-bearing at runtime — see The build trap. This step is not optional.

    npm run build
  3. Configure.

    # 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"
  4. Start the server:

    cd packages/mcp-fhir-server
    npm start
  5. Smoke test:

    node test-basic-functionality.js

The build trap

dist/ is in .gitignore (line 8), and it is what actually runs:

Consumer

Path

packages/mcp-fhir-server/package.json main

dist/index.js

packages/mcp-fhir-server/package.json bin.fhir-mcp

dist/index.js

npm start

node dist/index.js

npm run start:http

node dist/http.js

Dockerfile runtime stage

copies packages/*/dist from the builder

MCP client config (below)

.../dist/index.js

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.js

or cmd /c "set MCP_TRANSPORT=http && set PORT=8080 && node dist/http.js".

Available Tools

FHIR Operations

  • fhir.capabilities — get server capability statement

  • fhir.search — search resources with filtering and pagination

  • fhir.read — read a resource by ID

  • fhir.create — create a resource

  • fhir.update — update a resource

Terminology Services

  • terminology.lookup — look up code properties and display names

  • terminology.expand — expand ValueSets

  • terminology.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 script

PHI 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

identifier

replaced with a pseudonym token (PT_…)

resource.id

replaced with a pseudonym token

Reference.reference

rewritten to Type/PT_…

name

replaced with ***

birthDate

removed

telecom, address, contact, communication

removed

text.div (narrative)

removed by default (narrativePolicy; scrub is the alternative)

meta.source

removed

meta.security[].display, meta.tag[].display

removed (system+code retained deliberately)

Attachment .data

removed at ten known paths; contentType, size, hash, title, creation retained

contained[], Bundle.entry[].resource

recursed into and masked as resources in their own right

extension[] / modifierExtension[]

rebuilt from a whitelist — url retained, every value[x] dropped — including extensions on primitives via the _field sibling

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

MCP_SERVICE_PRINCIPAL_ID

Principal id. Absent or empty → no principal → PHI denied.

MCP_SERVICE_PRINCIPAL_SCOPES

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

patient/*.read, user/*.read, system/*.read

patient:read

patient/*.write, user/*.write, system/*.write

patient:write

x-restricted/*.read

restricted:read

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:

  • stdioStaticIdentityProvider holds the configured principal for the process lifetime.

  • HTTP/SSERequestScopedIdentityProvider carries the principal in an AsyncLocalStorage entered only after a bearer token has been verified. A tool call arriving by any route that did not go through runWithPrincipal gets no identity at all.

  • DefaultANONYMOUS_IDENTITY_PROVIDER, which returns undefined. 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) and AuthConfigSchema (src/tools/schemas.ts) are orphan type declarations with no implementation behind them.

  • No FHIR AuditEvent resources are emitted. Audit records are this project's own JSON shape, written with console.error (or console.log when AUDIT_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:

  • originalInput is 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), never error.message — a thrown message routinely quotes the resource that broke it.

  • resourceId is replaced by resourceIdHash, a keyed HMAC-SHA256 (AH_ prefix). It was previously an unsalted sha256 truncated to 16 hex and was reversible; see issue 1 below.

  • Denied reads now produce an audit record. They previously produced none: fhir-tools returned as soon as allowed was false and never reached logFhirOperation, 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 hasDirectIdentifiers to identifier (plus id on patient-like types) reduces protection.

  • Keeping the rule means PHILevel.MINIMAL is dead code and the tests should be changed to expect identifiable.

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 lint

Current 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.mjs

Configuration

Read by the MCP server (packages/mcp-fhir-server):

Variable

Description

Default

FHIR_BASE_URL

FHIR server base URL

https://hapi.fhir.org/baseR4

FHIR_TOKEN

Bearer token sent to the FHIR server

TERMINOLOGY_BASE_URL

HL7 terminology service URL

https://tx.fhir.org/r4

TERMINOLOGY_TOKEN

Bearer token sent to the terminology service

PHI_MODE

safe or trusted. Unrecognised values are a startup error.

safe

ENABLE_AUDIT

Audit logging; any value other than "false" enables it

true

MCP_SERVICE_PRINCIPAL_ID

Caller identity. Unset → all IDENTIFIABLE reads denied.

MCP_SERVICE_PRINCIPAL_SCOPES

Space-separated scopes from the table above

MCP_TRANSPORT

http selects the HTTP/SSE bridge

stdio

PORT

HTTP transport port

8080

AUTH_TOKEN

Shared bearer secret for HTTP mode. Unset → HTTP bridge is open.

AUDIT_SINK

stdout writes audit records to stdout instead of stderr

stderr

NODE_ENV

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 start

Docker

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-mcp

Endpoints at http://localhost:3002 (3001 for local dev):

  • GET /health — health check

  • GET /tools — list available tools

  • POST /fhir/capabilities | /fhir/search | /fhir/read | /fhir/create | /fhir/update

  • POST /terminology/lookup | /terminology/expand | /terminology/translate

  • POST /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

  1. Fork the repository

  2. Create a feature branch (git checkout -b feature/amazing-feature)

  3. Commit your changes (git commit -m 'Add amazing feature')

  4. Push to the branch (git push origin feature/amazing-feature)

  5. 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

Related MCP Connectors

Related MCP Servers

  • A
    license
    A
    quality
    D
    maintenance
    Enables 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.
    13
    99
    MIT
  • A
    license
    Not graded
    quality
    Not graded
    maintenance
    Enables 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
  • F
    license
    A
    quality
    D
    maintenance
    Provides read/write access to any FHIR-compliant healthcare API with built-in validation, supporting resource management, search operations, and granular permissions through natural language.
    5
    1
    -
  • A
    license
    A
    quality
    C
    maintenance
    Provides 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.
    7
    116 PyPI
    136
    Apache 2.0