Skip to main content
Glama
chrischall

MyAtriumHealth MCP Server

by chrischall
README.md
# myatriumhealth-mcp

MCP server for **MyAtriumHealth** — Atrium Health's [Epic MyChart](https://www.mychart.com/)
patient portal at `my.atriumhealth.org`. Reads test results, medications, allergies,
immunizations, health issues, goals and visits.

> This project was developed and is maintained by AI. Use at your own discretion.
> It reads personal health information, and only ever from the signed-in user's own account.

## Two ways to authenticate

| Mode | When | Credentials held |
|---|---|---|
| **Browser bridge** (default) | no credentials configured | **none** |
| **Bridge-less** | `MAH_USERNAME` + `MAH_PASSWORD` set | your portal password, on disk |

Bridge-less logs in server-side — no browser, no extension — which is what makes
hosting possible. Verification is **human-in-the-loop**: the portal challenges,
`mah_sign_in` reports the channels your account allows, the portal sends a code to
**you**, and `mah_verify_code` submits only what you provide. Nothing here bypasses the
second factor.

**When the session expires** you do *not* reconnect the MCP or restart anything. The
next tool call returns an actionable error naming the channels and your masked
destinations; you pick one, the portal texts or emails **you**, and `mah_verify_code`
resumes the session in place. A pending challenge is remembered, so further tool calls
report it rather than re-submitting your password each time.

**Where credentials are read from.** A real `MAH_USERNAME` / `MAH_PASSWORD` in the
environment always wins. Failing that the server reads the first `.env` it finds, in
this order: `MAH_DOTENV`, then `~/.myatriumhealth-mcp/.env`, then `./.env`.

The middle one exists because MCP clients launch the server from whatever directory
they happen to be in, so a `.env` sitting in a checkout is invisible to it — and the
failure is quiet: with no credentials the server falls back to the browser bridge,
binds a port and waits for a signed-in tab. If you meant to run bridge-less and see the
bridge start, that is what happened; the startup line now says so.

**How the session persists.** After one verification the cookie jar is stored (0600,
bound to the account) and reused, so restarts resume the existing session rather than
signing in again — no browser and no further codes until the session lapses.

**How long that lasts, measured rather than assumed.** Every one of the ten cookies
MyChart sets is a *session* cookie — none carries an `expires` or `max-age` — so the
lifetime is the server's alone and cannot be read from the jar. A jar left **idle for
219 minutes** no longer authenticated: the next call fell through to a fresh sign-in
and was challenged for a code immediately.

What that does and does not establish: it bounds an **idle** session, and says nothing
about an active one. The measurement cannot tell an idle timeout from an absolute one,
and portals of this kind usually expire on inactivity — so a session in steady use may
outlive 3.6h comfortably, while one left alone will not. Plan re-verification around
**gaps in use**, not around wall-clock age.

**Why the jar is written back mid-session.** Every response's `Set-Cookie` is absorbed,
and the jar is re-persisted whenever a value actually changed (`transport-server.ts`
after each request; a no-op write when nothing rotated). That is not bookkeeping: if
the portal refreshes its ticket as a session is used, the refreshed one only survives a
process restart by reaching disk. On a scale-to-zero host — where the child exits
between tool calls — that write-back is the whole reason an extended session is still
there on the next call, rather than the copy frozen at sign-in.

Two things none of this changes: a lapse still costs one code rather than a reconnect,
and detection alone sends nothing — only `mah_sign_in` asks the portal to send
anything.

> **What does NOT work, measured rather than assumed:** the `RememberDeviceId` this
> portal returns is *not* a device-tracking id it will accept back. Sending it neither
> skips verification nor is harmless — it breaks the challenge, leaving the
> SecondaryValidation page without its `templateContext` so the antiforgery token
> cannot be read and `SendCode` returns 500. The account reports
> `RememberMeSettings.EnrollDeviceTracking: False`, which fits. The token is therefore
> stored but deliberately never sent.

The bridge mode's real virtue is that it holds **no credentials at all**. Prefer it
unless you specifically need bridge-less.

## How it works

Every MyChart cookie is `HttpOnly` and login is MFA-gated, so the session cannot be
copied out of the browser and replayed from Node. Requests are therefore relayed
through the user's own signed-in tab via the
[fetchproxy](https://github.com/chrischall/fetchproxy) bridge and the ContextMint Bridge
browser extension, reusing their authenticated session. **The server never reads or stores the
session cookie.**

The portal's web app talks to a JSON API in two generations — modern
`POST api/<area>/<Action>` and legacy form-encoded `POST <Area>/<Controller>/<Action>`.
Both are documented, with live-captured shapes, in
[`docs/MYATRIUMHEALTH-API.md`](docs/MYATRIUMHEALTH-API.md).

## Install

```jsonc
{
  "mcpServers": {
    "myatriumhealth": {
      "command": "npx",
      "args": ["-y", "@chrischall/myatriumhealth-mcp"]
    }
  }
}
```

Bridge mode needs **ContextMint Bridge**, installed from its
[releases page](https://github.com/nullnet-app/contextmint-bridge/releases) — in Chrome,
unzip the `chrome` zip and add it via `chrome://extensions` → Developer mode → Load
unpacked. Use Chrome for now: the Safari build will ship inside the ContextMint app,
which has no public download yet.

ContextMint Bridge is the fetchproxy browser extension under its new name, from the
same maintainer — fetchproxy's own [README](https://github.com/chrischall/fetchproxy#extension)
points to it. Its source is public at
[nullnet-app/contextmint-bridge](https://github.com/nullnet-app/contextmint-bridge): build it
yourself, or check a release zip against the `.sha256` file published beside it
(`shasum -a 256 -c contextmint-bridge-chrome-<version>.zip.sha256`).

Then sign in to `my.atriumhealth.org` in your browser and call `mah_healthcheck`. The
first call prints a pair code — approve it once in the ContextMint Bridge popup.

| Env | Default | Purpose |
|---|---|---|
| `MAH_USERNAME` | — | MyAtriumHealth username. Set **with** `MAH_PASSWORD` to enable bridge-less mode. |
| `MAH_PASSWORD` | — | Portal password. Both are required; setting only one falls back to the bridge (with a warning). |
| `MAH_DEVICE_FILE` | `~/.myatriumhealth-mcp/device.json` | Session state (0600). Holds the **live cookie jar** as well as the device token — treat as a credential. |
| `MAH_WS_PORT` | `37149` | fetchproxy concentrator port (bridge mode only). The whole fleet shares this one port; override only when hosting. |
| `MAH_READ_ONLY` | `false` | `true` refuses `mah_reply_message` sends (previews still work). The tool stays listed either way. |
| `MCP_CONFIRM_MODE` | `ask-user` | What a reply does on a client that cannot show a confirmation prompt (claude.ai, Claude Desktop). `ask-user`: two steps — the first call sends nothing and returns a preview plus a token, and the model must get your approval in chat before calling again with it. `auto`: the same two steps, but the model may use the token after reviewing the preview itself. `refuse`: replies are refused on such clients. A client that can show prompts (Claude Code) always gets the real prompt, unless `MCP_CONFIRM_ELICITATION=off`. An unrecognised value is treated as `refuse`. |
| `MCP_CONFIRM_ELICITATION` | `on` | `off` never shows a confirmation prompt, so every client gets the `MCP_CONFIRM_MODE` path. Set it for a client that claims to support prompts but never shows one (the reply hangs — opencode 2.0.x). Any other value stays `on`, with a warning on stderr. |
| `MCP_CONFIRM_TTL_SECONDS` | `600` | How long a token stays valid. |
| `MCP_CONFIRM_SECRET` | random per process | Signing key; set it only if tokens must survive a server restart. |

## Tools

All read-only except two. `mah_set_active_patient` changes only which patient this
connector reads — it writes nothing to any chart. `mah_reply_message` **sends a message
a provider will see**, which cannot be undone: see [Replying](#replying).

Every reading tool returns `{ patient, data }`, so the chart a response belongs to is
stated rather than inferred.

| Tool | What it returns |
|---|---|
| `mah_list_allergies` | Allergies with reactions and severity |
| `mah_list_health_issues` | The problem list |
| `mah_list_immunizations` | Immunizations and dates, by organization |
| `mah_list_medications` | Medications with dosing instructions (sig) and prescriber |
| `mah_list_test_results` | Labs and imaging: name, abnormal flag, date, provider comments |
| `mah_list_upcoming_visits` | Upcoming and in-progress appointments |
| `mah_list_past_visits` | Past visits, grouped by organization |
| `mah_list_goals` | Patient goals |
| `mah_get_health_summary` | Health-summary header and action plans |
| `mah_list_message_folders` | Message Center folders with unread counts |
| `mah_list_messages` | Message Center conversations for a folder, each with the `conversationId` to reply to |
| `mah_reply_message` | Reply to a conversation as the active patient — asks you to confirm first: a prompt where the client supports one, otherwise a preview and a `confirmToken` *(irreversible)* |
| `mah_list_insurance` | Insurance coverages on file |
| `mah_list_care_team` | Care team providers, internal and external |
| `mah_list_billing_accounts` | Billing accounts and balances (parsed from HTML) |
| `mah_get_menu` | Which portal features this account exposes |
| `mah_healthcheck` | Connection health — bridge status when relaying, credential and session status when signing in server-side |
| `mah_auth_status` | Whether a session can be resumed and whether a device token is stored |
| `mah_sign_in` | Sign in server-side; reports verification channels if a code is needed *(needs credentials)* |
| `mah_send_verification_code` | Ask the portal to send a code to the account holder *(needs credentials)* |
| `mah_verify_code` | Submit the code the user received *(needs credentials)* |
| `mah_list_patients` | The patients this login can open — the account holder and any proxy subjects |
| `mah_get_patient_context` | Which patient the readers are serving, confirmed with the portal |
| `mah_set_active_patient` | Point every reader at one of those patients; survives restarts. Through the browser bridge this switches your own tab too, and a read refuses rather than switching it back if you change patients there |

Every reading tool takes `view`: `compact` (the default) or `full`. The raw envelopes
are large — test results ~33 KB, medications ~30 KB — so `compact` is what you want for
browsing, and `full` returns MyAtriumHealth's payload untouched.

`compact` always strips image and avatar URLs, which is subtractive and cannot drop a
field nobody knew about. Ten readers additionally reduce each record to its clinically
meaningful fields, because their real payloads were captured and a projection derived
from them: `mah_list_allergies`, `mah_list_health_issues`, `mah_list_immunizations`,
`mah_list_medications`, `mah_list_care_team`, `mah_list_goals`, `mah_list_test_results`,
`mah_list_past_visits`, `mah_list_insurance` and `mah_list_messages`.

Every other reader gets the URL strip only. That field list is applied where one was
actually established and nowhere else — the list above is generated from
`PROJECTED_ENDPOINTS` and checked against the `project()` call sites by a test, so it
cannot quietly drift out of step with the code. If the portal's shape drifts, the
projection warns to stderr and returns `{ projectionFailed: true, endpoint, topLevelKeys,
note }` rather than an empty list — key names only, never the raw health record; ask for
`view: 'full'` to see the portal's payload.

## Sessions expire, and they do it quietly

MyChart answers an **expired session with HTTP 200 whose body is the login page**, never
a 401, and the JSON endpoints then return `{}`. Tools raise a "Not signed in" error with
the remedy rather than reporting empty results. Sessions are short-lived; expect to sign
in again between uses.

## Without the MCP

[`skills/myatriumhealth-fpx`](skills/myatriumhealth-fpx) does the same thing from a
shell with the `fpx` CLI — no server to run. Its `references/endpoints.md` carries
live-verified `jq` recipes for every endpoint here.

## Messages

`mah_list_messages` is the one endpoint that cannot be called with an empty body. It
needs a five-key request whose `PageNonce` is the CSP nonce of an `/app/*` page, and
whose `externalLoadParams` lists the **non-local** organizations only — passing the
local organization returns HTTP 500. The client assembles this from
`conversations/GetOrganizations` and its explicit `isLocal` flag.

`api/item-feed/FetchItemFeed` still needs parameters that have not been captured.

## Replying

`mah_reply_message` takes a `conversationId` from `mah_list_messages`, a plain-text
`body`, and optionally `attachments`. It replies **as whichever patient is active**.

- **It asks before it sends.** A client that can show a confirmation prompt (Claude
  Code) gets one with the thread, recipients, body and attachments, unless
  `MCP_CONFIRM_ELICITATION=off`. Elsewhere the first
  call sends nothing and returns that preview plus a `confirmToken`; only a repeat call
  with the same arguments and that token sends. `MCP_CONFIRM_MODE` (above) decides
  whether the model must get your approval in chat before using it (`ask-user`, the
  default), may use it itself (`auto`), or is refused (`refuse`). The send itself is
  irreversible and provider-visible.
- **A send must follow its own preview.** The token is bound to the patient, thread,
  recipients, body and attachments the preview showed (the fleet's shared mechanism from
  `@chrischall/mcp-utils`). Anything else is refused: a different reply or thread
  (`DRAFT_CHANGED`, with the new preview and a fresh token), after 10 minutes
  (`TOKEN_EXPIRED`), a token already used (`TOKEN_REUSED`) or not issued for this reply
  (`TOKEN_INVALID`). Each preview authorizes one send, and the token is spent once the send
  is authorized, so any retry, even after a failure before anything went out, needs a
  fresh preview. So message text the model has read cannot talk it into a one-call send
  the user never saw.
- **`MAH_READ_ONLY=true` refuses every send.** The tool stays registered — a hosted
  connector publishes the tool list of a child with no env of its own, so a tool that
  registered only when writes were allowed would disappear for everyone.
- **Limits come from the portal**: 500 characters, 3 attachments, 10 MB per document or
  image (64 MB per video), BMP/DOC/DOCX/JPEG/JPG/PDF/PNG/TIF/TIFF and common video types.
- **Attachments need bridge-less mode.** fetchproxy relays a request body as text, which
  a binary upload does not survive, so through the browser bridge a reply with
  attachments is refused before anything is uploaded.
- **A send is never retried.** If the session re-authenticates between confirming the
  patient and sending, nothing is sent: a fresh sign-in serves the account holder, and
  the reply would go out as them. If the portal's answer to the send cannot be read, the
  result says the reply *may* have been sent — check the thread before trying again.
- On success it returns the new message's `wmgId` and `deliveryInstantISO`, found by
  reading the thread back (the send itself returns only the thread id).

## Development

```sh
npm install
npm test          # typecheck + vitest
npm run build
```

## License

MIT

TDQS

A3.6/5.0

Scored across 24 tools

Disambiguation4/5

Each list_* tool targets a distinct clinical resource (allergies, meds, immunizations, visits, messages, etc.), and the auth tools form a clear sign-in/verify flow, so boundaries are generally crisp. The only mild overlap is mah_get_health_summary versus the individual list tools, which could cause a moment of hesitation about which to call.

Naming Consistency4/5

Nearly all tools use a consistent mah_ prefix with verb_noun form (list_allergies, get_health_summary, set_active_patient, reply_message). A few deviate slightly with noun-only names like mah_auth_status and mah_healthcheck, but the convention is still highly predictable.

Tool Count4/5

24 tools is on the heavy side, but the domain—a full patient portal with labs, meds, messages, billing, insurance, proxy patients, and multi-step auth—genuinely justifies broad coverage. It stays just under the point where count alone becomes a problem.

Completeness4/5

The surface covers a wide read-oriented lifecycle: demographics/proxy patients, clinical summaries, results, visits, messages, insurance, and billing, plus auth and diagnostics. Gaps exist around write actions (composing new messages rather than only replying, scheduling, refill requests), but core workflows are supported.

Maintenance

ActivityActive
ResponsivenessResponsive