insync-mcp
# insync-mcp
MCP server for an **InSync Healthcare (Qualifacts)** patient portal — the
behavioral-health portals at `https://<practice>intouch.insynchcs.com` (the
owner's is `pfbhintouch`). It reads appointments, medications, allergies, secure
messages, forms, surveys, documents, profile and account balance; downloads
portal files; uploads an insurance-card image; and sends secure messages,
refill requests, appointment requests and cancellation requests.
> 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.
## Not read-only
This server is **not** read-only. Alongside the reads it:
- **downloads** message attachments and account documents (a download changes no
portal state and has an inverse — not persisting — so the download tools are
still annotated `readOnly`);
- **uploads** an insurance-card image to your own profile — a real write the
practice sees, with no delete/undo through this server, so it is annotated
`destructive` and **confirm-gated** (see below);
- `insync_get_message` opening an **unread** message can **mark it read**, which
the practice can see and which has no inverse. That tool is annotated
`destructive` and gated behind `allow_mark_read` (default false): until you
pass it, the tool returns the list-row metadata and a note, and never fetches
the detail;
- **sends things to the practice**, each one confirm-gated and annotated
`destructive`, because none can be unsent or withdrawn through this server:
| Tool | What it sends | Look up first with |
|---|---|---|
| `insync_send_message` | a new secure message to a practice group | `insync_list_message_recipients` |
| `insync_reply_message` | a reply to a message (refuses an unread one unless `allow_mark_read`) | `insync_list_messages` |
| `insync_request_refill` | a refill request for current medications | `insync_get_refill_options` |
| `insync_request_appointment` | an appointment **request** in an open slot (the practice confirms it) | `insync_find_appointment_slots` |
| `insync_cancel_appointment` | a cancellation **request** with a reason (the practice reviews it) | `insync_list_appointments` |
Not built: message attachments and patient signatures on messages, the
"flexible window" appointment request, withdrawing a request, payments, and
form submission.
## Two ways to authenticate
| Mode | When | Credentials held |
|---|---|---|
| **Bridge-less** (default) | `INSYNC_USERNAME` + `INSYNC_PASSWORD` set | your portal password, in memory for the process |
| **Browser bridge** | no credentials configured | **none** |
**Bridge-less** signs in server-side with its own cookie jar — no browser, no
extension — which is what makes hosting possible. The password login was
**live-verified on 2026-10-28** against the owner's practice: a single POST to
`Login/userLogin`, no MFA, no device challenge. A rejected sign-in is **never
retried automatically**, because the portal counts failed attempts toward an
account lockout. The cookie jar lives in memory for the life of the process;
nothing is written to disk.
**Browser bridge** relays requests through your own signed-in portal tab via the
[fetchproxy](https://github.com/chrischall/fetchproxy) bridge and the ContextMint
Bridge extension, reusing that session. It holds **no credentials at all** — its
one virtue — so prefer it unless you specifically want the hosted, bridge-less
path.
Selection is automatic: `server` when both credentials are set, otherwise
`fetchproxy`. `INSYNC_TRANSPORT=server|fetchproxy` forces one.
## Storage configuration is held, never used directly
The login response carries some object-storage configuration. This server
**never uses it to talk to object storage directly** — all file transfer goes
through the portal's own session-authorized endpoints, so every read and write
stays under the portal's per-patient authorization. Any such configuration is
captured onto the session only, and is **scrubbed by value** from every tool
result, error and log. A download URL is refused unless it stays on
`*.insynchcs.com`.
## Writes are unverified on the wire
Every lookup the write tools make — recipients, the reply form, the refill pick
lists, facilities, providers, visit types, slots, the cancellation window — was
**verified live, read-only**. The sends are **derived from the portal's own
JavaScript and partial views**. A new message (`insync_send_message`) and a
reply (`insync_reply_message`) were **verified live** on 2026-10-28 in
owner-supervised tests; the refill, appointment and cancellation sends have not
been exercised yet, because each one creates a request the practice acts on. A send that the portal
answers with a 5xx, or whose connection drops, is reported as
`status: "unknown"` rather than as an error, so a model checks the matching
list before trying again instead of sending twice. See
[`docs/INSYNC-API.md`](docs/INSYNC-API.md).
### Insurance-card upload
The insurance-card upload shape is **derived from the portal's own JavaScript**
and is **UNVERIFIED on a live POST**: no automated run performs a real upload,
because it mutates practice-visible state. An owner-supervised live test confirms
it later. The confirm gate, the file vetting and the re-read-after-write guidance
are all in place; only the on-the-wire success signal is pending confirmation.
See [`docs/INSYNC-API.md`](docs/INSYNC-API.md).
## Confirm gate
Every write is gated by `@chrischall/mcp-utils`' `confirmWrite` (the
send/request tools through its `defineWrite` / `registerWrite`). The first call
sends nothing and returns a **preview + a `confirmToken`**; only a second call
carrying that token — after you approve the preview — sends. The send/request
tools may read the portal while previewing (to resolve names and check the slot
or recipient); the upload preview reads nothing. The exact request body (for the
upload, the image bytes) and the shown preview are bound into the token, so an
argument, file or portal state that changed since the preview is refused as
`DRAFT_CHANGED`. `MCP_CONFIRM_MODE` / `MCP_CONFIRM_ELICITATION` tune this (see the
env table).
## Install
```jsonc
{
"mcpServers": {
"insync": {
"command": "npx",
"args": ["-y", "@chrischall/insync-mcp"],
"env": {
"INSYNC_PORTAL": "https://<practice>intouch.insynchcs.com",
"INSYNC_USERNAME": "your-portal-username",
"INSYNC_PASSWORD": "your-portal-password"
}
}
}
}
```
Leave `INSYNC_USERNAME` / `INSYNC_PASSWORD` out to use the browser bridge
instead; that path needs **ContextMint Bridge**, the fetchproxy browser
extension, installed from its
[releases page](https://github.com/nullnet-app/contextmint-bridge/releases), and
your portal open and signed in. Then call `insync_healthcheck`; the first call
prints a pair code to approve once in the extension popup.
## Configuration
| Variable | Required | Default | Purpose |
|---|---|---|---|
| `INSYNC_PORTAL` | yes | — | Your practice portal, scheme + host only (`https://<practice>intouch.insynchcs.com`), no path. The portal is per-practice, so a wrong host reads a different practice. |
| `INSYNC_USERNAME` | bridge-less only | — | Portal username. Set with the password to sign in server-side. |
| `INSYNC_PASSWORD` | bridge-less only | — | Portal password. Never retried on rejection (lockout risk). |
| `INSYNC_TRANSPORT` | no | auto | `server` or `fetchproxy`. Default: `server` when both credentials are set, else `fetchproxy`. |
| `INSYNC_WS_PORT` | no | `37149` | fetchproxy concentrator port. The whole fleet shares it; override only when hosting. |
| `INSYNC_OUTPUT_DIR` | no | `~/Downloads`, `~/Documents` | Where downloaded files are written (a path-delimiter list; `~` expands). A per-call `dest` must resolve inside one of these. |
| `INSYNC_INLINE_DOWNLOADS` | no | `false` | `true` returns downloaded bytes inline (base64) instead of writing to disk — for a hosted runner whose disk you cannot reach. |
| `MCP_CONFIRM_MODE` | no | `ask-user` | Confirm-gate mode for every write. `ask-user` = preview + `confirmToken` round-trip; `auto` lets a capable host confirm in one step; an unrecognised value fails closed (`refuse`). |
| `MCP_CONFIRM_ELICITATION` | no | `on` | `on` lets the host confirm a write interactively; `off` forces the `confirmToken` round-trip. |
A `.env` is read for local development; real environment values always win. See
[`.env.example`](.env.example).
## The fpx skill
Bundled under [`skills/insync-fpx/`](skills/insync-fpx/) is a lightweight
alternative to running this server: the `insync-fpx` skill reads the same portal
from a shell through your signed-in browser tab with the
[`fpx`](https://github.com/chrischall/fetchproxy) CLI (capture the session once,
then curl the endpoints). Use it when you want portal data in a script or without
the MCP installed. The MCP is the richer surface; the skill is the quick path.
## How it works
The portal is ASP.NET MVC. A signed-out session is never a `401`: it is the login
page served `200` (`<title>Portal :: Login`), an `{"isTimeout":true}` body, or a
`302` to `/Login`. This server detects that by content and surfaces an actionable
"not signed in" error naming the live transport. Every endpoint shape — reads,
file transfer and the upload — is documented, keys only (no values, which are
PHI), in [`docs/INSYNC-API.md`](docs/INSYNC-API.md).
## License
MIT
TDQS
Scored across 26 tools
Most tools target a clear resource+action pair, and descriptions explicitly fence off near-neighbors (list_refill_requests "does NOT submit", list_messages "does NOT mark read", reply vs send, list_attachments vs list_documents). The only mild overlaps are list_appointments vs get_appointment_calendar (upcoming/past records vs monthly grid) and the list/download pairs, but the descriptions disambiguate them adequately.
All 26 tools use the same insync_ prefix plus a lowercase snake_case verb_noun pattern (list_medications, get_message, download_document, request_refill, cancel_appointment). Verbs vary only where the meaning differs (list/get/download/send/request/cancel), which is predictable rather than inconsistent.
At 26 tools the surface is heavy and pushes past the comfortable 3-15 band; the read side alone has a dozen list/get calls. Nearly every tool does map to a distinct portal operation, so it is not padding, but the set is large enough that discovery and selection get noticeably harder.
Core write workflows (messaging, refills, appointment request/cancel, document and attachment retrieval) are covered end-to-end and healthcheck aids diagnosis. However, forms and surveys are list-only with no submit path, and lab results, problems, and immunizations appear only as badge counts with no tool to read them — real dead ends for a patient portal.