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

B3.4/5.0

Scored across 23 tools

Disambiguation5/5

Each tool targets a clearly distinct resource or lifecycle step: 14 list tools map to separate clinical/financial categories, and patient-context (list/get/set), auth (status/sign-in/send-code/verify), and messaging tools occupy non-overlapping roles. mah_get_health_summary (header/action plans) versus the individual list_* health data tools is the only place an agent might pause, but the descriptions keep them separate.

Naming Consistency4/5

All names use the mah_ prefix and snake_case, and the dominant pattern is verb_noun (mah_list_*, mah_get_*, mah_set_*, mah_reply_message). A few names break the verb pattern (mah_auth_status, mah_healthcheck), keeping it just short of fully predictable.

Tool Count3/5

At 23 tools this is on the heavy side of the 16-25 'borderline' band. Many list tools are justified by distinct MyChart data categories, but the set also carries separate auth, patient-context, and diagnostics tools, so the surface feels larger than the core job requires.

Completeness3/5

Read coverage is broad (care team, summary, allergies, issues, immunizations, medications, goals, results, visits, messages, insurance, billing) and auth/patient switching are complete, but there are notable gaps: individual test-result values are explicitly not returned and no detail tool exists, visits have no detail operation, and messaging supports replies only with no way to compose a new conversation. These are more than minor work-arounds for an agent needing full portal coverage.

Maintenance

ActivityActive
ResponsivenessResponsive