@chrischall/myatriumhealth-mcp 1.1.0 → 1.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.claude-plugin/marketplace.json +2 -2
- package/.claude-plugin/plugin.json +1 -1
- package/README.md +28 -3
- package/dist/bundle.js +525 -53
- package/mint.yaml +5 -0
- package/package.json +2 -2
- package/server.json +8 -2
|
@@ -7,7 +7,7 @@
|
|
|
7
7
|
},
|
|
8
8
|
"metadata": {
|
|
9
9
|
"description": "MCP server for Angi — find home-service pros by trade and city, read ratings, reviews and pro profiles. Angi serves its pages only to a real browser, so requests route through the user's own angi.com tab via the fetchproxy browser extension, reusing their existing session. The trade/city taxonomy is read from Angi's public sitemaps directly.",
|
|
10
|
-
"version": "1.
|
|
10
|
+
"version": "1.2.0"
|
|
11
11
|
},
|
|
12
12
|
"plugins": [
|
|
13
13
|
{
|
|
@@ -15,7 +15,7 @@
|
|
|
15
15
|
"displayName": "MyAtriumHealth",
|
|
16
16
|
"source": "./",
|
|
17
17
|
"description": "Read MyAtriumHealth (Epic MyChart) records — test results, medications, allergies, immunizations, health issues, visits — by relaying requests through the user's signed-in browser tab via the fetchproxy bridge.",
|
|
18
|
-
"version": "1.
|
|
18
|
+
"version": "1.2.0",
|
|
19
19
|
"author": {
|
|
20
20
|
"name": "Chris Hall"
|
|
21
21
|
},
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "myatriumhealth-mcp",
|
|
3
3
|
"displayName": "MyAtriumHealth",
|
|
4
|
-
"version": "1.
|
|
4
|
+
"version": "1.2.0",
|
|
5
5
|
"description": "MyAtriumHealth (Epic MyChart) access for Claude — test results, medications, allergies, immunizations, visits",
|
|
6
6
|
"author": {
|
|
7
7
|
"name": "Chris Hall",
|
package/README.md
CHANGED
|
@@ -111,11 +111,13 @@ call prints a pair code — approve it once in the Transporter popup.
|
|
|
111
111
|
| `MAH_PASSWORD` | — | Portal password. Both are required; setting only one falls back to the bridge (with a warning). |
|
|
112
112
|
| `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. |
|
|
113
113
|
| `MAH_WS_PORT` | `37149` | fetchproxy concentrator port (bridge mode only). The whole fleet shares this one port; override only when hosting. |
|
|
114
|
+
| `MAH_READ_ONLY` | `false` | `true` refuses `mah_reply_message` sends (previews still work). The tool stays listed either way. |
|
|
114
115
|
|
|
115
116
|
## Tools
|
|
116
117
|
|
|
117
|
-
All read-only except `mah_set_active_patient
|
|
118
|
-
connector reads — it writes nothing to any chart.
|
|
118
|
+
All read-only except two. `mah_set_active_patient` changes only which patient this
|
|
119
|
+
connector reads — it writes nothing to any chart. `mah_reply_message` **sends a message
|
|
120
|
+
a provider will see**, which cannot be undone: see [Replying](#replying).
|
|
119
121
|
|
|
120
122
|
Every reading tool returns `{ patient, data }`, so the chart a response belongs to is
|
|
121
123
|
stated rather than inferred.
|
|
@@ -132,7 +134,8 @@ stated rather than inferred.
|
|
|
132
134
|
| `mah_list_goals` | Patient goals |
|
|
133
135
|
| `mah_get_health_summary` | Health-summary header and action plans |
|
|
134
136
|
| `mah_list_message_folders` | Message Center folders with unread counts |
|
|
135
|
-
| `mah_list_messages` | Message Center conversations for a folder |
|
|
137
|
+
| `mah_list_messages` | Message Center conversations for a folder, each with the `conversationId` to reply to |
|
|
138
|
+
| `mah_reply_message` | Reply to a conversation as the active patient — previews unless `confirm: true` *(irreversible)* |
|
|
136
139
|
| `mah_list_insurance` | Insurance coverages on file |
|
|
137
140
|
| `mah_list_care_team` | Care team providers, internal and external |
|
|
138
141
|
| `mah_list_billing_accounts` | Billing accounts and balances (parsed from HTML) |
|
|
@@ -186,6 +189,28 @@ local organization returns HTTP 500. The client assembles this from
|
|
|
186
189
|
|
|
187
190
|
`api/item-feed/FetchItemFeed` still needs parameters that have not been captured.
|
|
188
191
|
|
|
192
|
+
## Replying
|
|
193
|
+
|
|
194
|
+
`mah_reply_message` takes a `conversationId` from `mah_list_messages`, a plain-text
|
|
195
|
+
`body`, and optionally `attachments`. It replies **as whichever patient is active**.
|
|
196
|
+
|
|
197
|
+
- **It previews by default.** Without `confirm: true` it returns the thread, recipients
|
|
198
|
+
and body and sends nothing. The send itself is irreversible and provider-visible.
|
|
199
|
+
- **`MAH_READ_ONLY=true` refuses every send.** The tool stays registered — a hosted
|
|
200
|
+
connector publishes the tool list of a child with no env of its own, so a tool that
|
|
201
|
+
registered only when writes were allowed would disappear for everyone.
|
|
202
|
+
- **Limits come from the portal**: 500 characters, 3 attachments, 10 MB per document or
|
|
203
|
+
image (64 MB per video), BMP/DOC/DOCX/JPEG/JPG/PDF/PNG/TIF/TIFF and common video types.
|
|
204
|
+
- **Attachments need bridge-less mode.** fetchproxy relays a request body as text, which
|
|
205
|
+
a binary upload does not survive, so through the browser bridge a reply with
|
|
206
|
+
attachments is refused before anything is uploaded.
|
|
207
|
+
- **A send is never retried.** If the session re-authenticates between confirming the
|
|
208
|
+
patient and sending, nothing is sent: a fresh sign-in serves the account holder, and
|
|
209
|
+
the reply would go out as them. If the portal's answer to the send cannot be read, the
|
|
210
|
+
result says the reply *may* have been sent — check the thread before trying again.
|
|
211
|
+
- On success it returns the new message's `wmgId` and `deliveryInstantISO`, found by
|
|
212
|
+
reading the thread back (the send itself returns only the thread id).
|
|
213
|
+
|
|
189
214
|
## Development
|
|
190
215
|
|
|
191
216
|
```sh
|
package/dist/bundle.js
CHANGED
|
@@ -25177,17 +25177,17 @@ var require_sender = __commonJS({
|
|
|
25177
25177
|
*/
|
|
25178
25178
|
ping(data, mask, cb) {
|
|
25179
25179
|
let byteLength;
|
|
25180
|
-
let
|
|
25180
|
+
let readOnly2;
|
|
25181
25181
|
if (typeof data === "string") {
|
|
25182
25182
|
byteLength = Buffer.byteLength(data);
|
|
25183
|
-
|
|
25183
|
+
readOnly2 = false;
|
|
25184
25184
|
} else if (isBlob(data)) {
|
|
25185
25185
|
byteLength = data.size;
|
|
25186
|
-
|
|
25186
|
+
readOnly2 = false;
|
|
25187
25187
|
} else {
|
|
25188
25188
|
data = toBuffer(data);
|
|
25189
25189
|
byteLength = data.length;
|
|
25190
|
-
|
|
25190
|
+
readOnly2 = toBuffer.readOnly;
|
|
25191
25191
|
}
|
|
25192
25192
|
if (byteLength > 125) {
|
|
25193
25193
|
throw new RangeError("The data size must not be greater than 125 bytes");
|
|
@@ -25199,7 +25199,7 @@ var require_sender = __commonJS({
|
|
|
25199
25199
|
mask,
|
|
25200
25200
|
maskBuffer: this._maskBuffer,
|
|
25201
25201
|
opcode: 9,
|
|
25202
|
-
readOnly,
|
|
25202
|
+
readOnly: readOnly2,
|
|
25203
25203
|
rsv1: false
|
|
25204
25204
|
};
|
|
25205
25205
|
if (isBlob(data)) {
|
|
@@ -25224,17 +25224,17 @@ var require_sender = __commonJS({
|
|
|
25224
25224
|
*/
|
|
25225
25225
|
pong(data, mask, cb) {
|
|
25226
25226
|
let byteLength;
|
|
25227
|
-
let
|
|
25227
|
+
let readOnly2;
|
|
25228
25228
|
if (typeof data === "string") {
|
|
25229
25229
|
byteLength = Buffer.byteLength(data);
|
|
25230
|
-
|
|
25230
|
+
readOnly2 = false;
|
|
25231
25231
|
} else if (isBlob(data)) {
|
|
25232
25232
|
byteLength = data.size;
|
|
25233
|
-
|
|
25233
|
+
readOnly2 = false;
|
|
25234
25234
|
} else {
|
|
25235
25235
|
data = toBuffer(data);
|
|
25236
25236
|
byteLength = data.length;
|
|
25237
|
-
|
|
25237
|
+
readOnly2 = toBuffer.readOnly;
|
|
25238
25238
|
}
|
|
25239
25239
|
if (byteLength > 125) {
|
|
25240
25240
|
throw new RangeError("The data size must not be greater than 125 bytes");
|
|
@@ -25246,7 +25246,7 @@ var require_sender = __commonJS({
|
|
|
25246
25246
|
mask,
|
|
25247
25247
|
maskBuffer: this._maskBuffer,
|
|
25248
25248
|
opcode: 10,
|
|
25249
|
-
readOnly,
|
|
25249
|
+
readOnly: readOnly2,
|
|
25250
25250
|
rsv1: false
|
|
25251
25251
|
};
|
|
25252
25252
|
if (isBlob(data)) {
|
|
@@ -25282,17 +25282,17 @@ var require_sender = __commonJS({
|
|
|
25282
25282
|
let opcode = options.binary ? 2 : 1;
|
|
25283
25283
|
let rsv1 = options.compress;
|
|
25284
25284
|
let byteLength;
|
|
25285
|
-
let
|
|
25285
|
+
let readOnly2;
|
|
25286
25286
|
if (typeof data === "string") {
|
|
25287
25287
|
byteLength = Buffer.byteLength(data);
|
|
25288
|
-
|
|
25288
|
+
readOnly2 = false;
|
|
25289
25289
|
} else if (isBlob(data)) {
|
|
25290
25290
|
byteLength = data.size;
|
|
25291
|
-
|
|
25291
|
+
readOnly2 = false;
|
|
25292
25292
|
} else {
|
|
25293
25293
|
data = toBuffer(data);
|
|
25294
25294
|
byteLength = data.length;
|
|
25295
|
-
|
|
25295
|
+
readOnly2 = toBuffer.readOnly;
|
|
25296
25296
|
}
|
|
25297
25297
|
if (this._firstFragment) {
|
|
25298
25298
|
this._firstFragment = false;
|
|
@@ -25312,7 +25312,7 @@ var require_sender = __commonJS({
|
|
|
25312
25312
|
mask: options.mask,
|
|
25313
25313
|
maskBuffer: this._maskBuffer,
|
|
25314
25314
|
opcode,
|
|
25315
|
-
readOnly,
|
|
25315
|
+
readOnly: readOnly2,
|
|
25316
25316
|
rsv1
|
|
25317
25317
|
};
|
|
25318
25318
|
if (isBlob(data)) {
|
|
@@ -27298,6 +27298,20 @@ var require_websocket_server = __commonJS({
|
|
|
27298
27298
|
}
|
|
27299
27299
|
});
|
|
27300
27300
|
|
|
27301
|
+
// node_modules/@chrischall/mcp-utils/dist/cancel/index.js
|
|
27302
|
+
import { AsyncLocalStorage } from "node:async_hooks";
|
|
27303
|
+
var storage = new AsyncLocalStorage();
|
|
27304
|
+
function withCallSignal(signal, fn, request) {
|
|
27305
|
+
return signal ? storage.run({ signal, ...request ? { request } : {} }, fn) : fn();
|
|
27306
|
+
}
|
|
27307
|
+
|
|
27308
|
+
// node_modules/@chrischall/mcp-utils/dist/caller/index.js
|
|
27309
|
+
import { AsyncLocalStorage as AsyncLocalStorage2 } from "node:async_hooks";
|
|
27310
|
+
var storage2 = new AsyncLocalStorage2();
|
|
27311
|
+
function withCallerCapabilities(capabilities, fn) {
|
|
27312
|
+
return capabilities ? storage2.run(capabilities, fn) : fn();
|
|
27313
|
+
}
|
|
27314
|
+
|
|
27301
27315
|
// node_modules/@modelcontextprotocol/server/dist/chunk-Br0eD_fh.mjs
|
|
27302
27316
|
var __create2 = Object.create;
|
|
27303
27317
|
var __defProp2 = Object.defineProperty;
|
|
@@ -34838,11 +34852,11 @@ var require_resolve = /* @__PURE__ */ __commonJSMin(((exports) => {
|
|
|
34838
34852
|
"enum",
|
|
34839
34853
|
"const"
|
|
34840
34854
|
]);
|
|
34841
|
-
function inlineRef(schema,
|
|
34855
|
+
function inlineRef(schema, limit2 = true) {
|
|
34842
34856
|
if (typeof schema == "boolean") return true;
|
|
34843
|
-
if (
|
|
34844
|
-
if (!
|
|
34845
|
-
return countKeys(schema) <=
|
|
34857
|
+
if (limit2 === true) return !hasRef(schema);
|
|
34858
|
+
if (!limit2) return false;
|
|
34859
|
+
return countKeys(schema) <= limit2;
|
|
34846
34860
|
}
|
|
34847
34861
|
exports.inlineRef = inlineRef;
|
|
34848
34862
|
const REF_KEYWORDS = /* @__PURE__ */ new Set([
|
|
@@ -61759,17 +61773,73 @@ Hint: ${err.hint}`);
|
|
|
61759
61773
|
}
|
|
61760
61774
|
throw err;
|
|
61761
61775
|
}
|
|
61776
|
+
function mcpRequestFrom(args) {
|
|
61777
|
+
const ctx = args.at(-1);
|
|
61778
|
+
if (typeof ctx !== "object" || ctx === null)
|
|
61779
|
+
return void 0;
|
|
61780
|
+
const req = ctx.mcpReq;
|
|
61781
|
+
if (typeof req !== "object" || req === null)
|
|
61782
|
+
return void 0;
|
|
61783
|
+
return typeof req.notify === "function" ? req : void 0;
|
|
61784
|
+
}
|
|
61785
|
+
function callSignalFrom(args) {
|
|
61786
|
+
const ctx = args.at(-1);
|
|
61787
|
+
if (typeof ctx !== "object" || ctx === null)
|
|
61788
|
+
return void 0;
|
|
61789
|
+
const req = ctx.mcpReq;
|
|
61790
|
+
if (typeof req !== "object" || req === null)
|
|
61791
|
+
return void 0;
|
|
61792
|
+
const signal = req.signal;
|
|
61793
|
+
return signal instanceof AbortSignal ? signal : void 0;
|
|
61794
|
+
}
|
|
61795
|
+
function declaredCapabilitiesFrom(server) {
|
|
61796
|
+
const inner = server.server;
|
|
61797
|
+
if (typeof inner !== "object" || inner === null)
|
|
61798
|
+
return void 0;
|
|
61799
|
+
const read = inner.getClientCapabilities;
|
|
61800
|
+
if (typeof read !== "function")
|
|
61801
|
+
return void 0;
|
|
61802
|
+
try {
|
|
61803
|
+
const declared = read.call(inner);
|
|
61804
|
+
return typeof declared === "object" && declared !== null ? declared : void 0;
|
|
61805
|
+
} catch {
|
|
61806
|
+
return void 0;
|
|
61807
|
+
}
|
|
61808
|
+
}
|
|
61762
61809
|
function surfaceToolHints(server) {
|
|
61763
61810
|
const register2 = server.registerTool.bind(server);
|
|
61764
|
-
server.registerTool = (name, config2, cb) => register2(name, config2, (...args) =>
|
|
61765
|
-
|
|
61766
|
-
|
|
61767
|
-
|
|
61768
|
-
|
|
61769
|
-
|
|
61770
|
-
|
|
61771
|
-
|
|
61772
|
-
|
|
61811
|
+
server.registerTool = (name, config2, cb) => register2(name, config2, (...args) => (
|
|
61812
|
+
// THE CALLER'S CANCELLATION, made ambient for the whole handler
|
|
61813
|
+
// (`cancel/index.ts`). The SDK delivers it and the fleet ignored it:
|
|
61814
|
+
// measured against @modelcontextprotocol/server 2.0.0, a cancelled
|
|
61815
|
+
// call aborts `ctx.mcpReq.signal` with the caller's reason and the
|
|
61816
|
+
// handler runs to completion regardless — so the HTTP request stays
|
|
61817
|
+
// in flight, the child keeps burning metered CPU, and the upstream
|
|
61818
|
+
// keeps being hit for somebody who has gone. claude.ai sent 101
|
|
61819
|
+
// cancellations in the week to 2026-09-20.
|
|
61820
|
+
//
|
|
61821
|
+
// Here because this is the one wrapper every tool already passes
|
|
61822
|
+
// through: threading the signal by hand would mean editing several
|
|
61823
|
+
// hundred handlers and missing exactly the ones nobody edits.
|
|
61824
|
+
withCallSignal(callSignalFrom(args), () => (
|
|
61825
|
+
// WHAT THE CALLER CAN DO, made ambient for the same reason and in
|
|
61826
|
+
// the same place (`caller/index.ts`). Only the 2025-era half needs
|
|
61827
|
+
// the wrapper — a 2026-07-28 request answers for itself off its own
|
|
61828
|
+
// envelope — but a guarded tool that cannot tell whether the caller
|
|
61829
|
+
// can be shown a prompt returns one the SDK then refuses to deliver,
|
|
61830
|
+
// and that refusal reaches the caller as an unexplained protocol
|
|
61831
|
+
// error it had no chance to catch.
|
|
61832
|
+
withCallerCapabilities(declaredCapabilitiesFrom(server), () => {
|
|
61833
|
+
let result;
|
|
61834
|
+
try {
|
|
61835
|
+
result = cb(...args);
|
|
61836
|
+
} catch (err) {
|
|
61837
|
+
return hintResultOrRethrow(err);
|
|
61838
|
+
}
|
|
61839
|
+
return result instanceof Promise ? result.catch(hintResultOrRethrow) : result;
|
|
61840
|
+
})
|
|
61841
|
+
), mcpRequestFrom(args))
|
|
61842
|
+
));
|
|
61773
61843
|
}
|
|
61774
61844
|
async function createMcpServer(opts) {
|
|
61775
61845
|
const server = new McpServer({ name: opts.name, version: opts.version }, { supportedProtocolVersions: [...SERVER_PROTOCOL_VERSIONS] });
|
|
@@ -61842,6 +61912,20 @@ function readEnvVar(key, opts = {}) {
|
|
|
61842
61912
|
}
|
|
61843
61913
|
return opts.default;
|
|
61844
61914
|
}
|
|
61915
|
+
var TRUE_TOKENS = /* @__PURE__ */ new Set(["1", "true", "yes", "on"]);
|
|
61916
|
+
var FALSE_TOKENS = /* @__PURE__ */ new Set(["0", "false", "no", "off"]);
|
|
61917
|
+
function parseBoolEnv(key, opts = {}) {
|
|
61918
|
+
const fallback = opts.default ?? false;
|
|
61919
|
+
const raw = readEnvVar(key, { env: opts.env });
|
|
61920
|
+
if (raw === void 0)
|
|
61921
|
+
return fallback;
|
|
61922
|
+
const token = raw.toLowerCase();
|
|
61923
|
+
if (TRUE_TOKENS.has(token))
|
|
61924
|
+
return true;
|
|
61925
|
+
if (FALSE_TOKENS.has(token))
|
|
61926
|
+
return false;
|
|
61927
|
+
return fallback;
|
|
61928
|
+
}
|
|
61845
61929
|
function readPortEnv(key, fallback, opts = {}) {
|
|
61846
61930
|
const raw = readEnvVar(key, { env: opts.env });
|
|
61847
61931
|
if (raw === void 0)
|
|
@@ -61890,6 +61974,7 @@ function toolAnnotations(opts = {}) {
|
|
|
61890
61974
|
...opts.title !== void 0 ? { title: opts.title } : {},
|
|
61891
61975
|
readOnlyHint: opts.readOnly ?? true,
|
|
61892
61976
|
...opts.idempotent !== void 0 ? { idempotentHint: opts.idempotent } : {},
|
|
61977
|
+
...opts.destructive !== void 0 ? { destructiveHint: opts.destructive } : {},
|
|
61893
61978
|
...opts.openWorld !== void 0 ? { openWorldHint: opts.openWorld } : {}
|
|
61894
61979
|
};
|
|
61895
61980
|
}
|
|
@@ -62383,7 +62468,25 @@ var MyAtriumHealthAuth = class {
|
|
|
62383
62468
|
}
|
|
62384
62469
|
};
|
|
62385
62470
|
|
|
62471
|
+
// src/transport.ts
|
|
62472
|
+
var NotAcceptedError = class extends McpToolError {
|
|
62473
|
+
constructor(message, opts) {
|
|
62474
|
+
super(message, opts);
|
|
62475
|
+
this.name = "NotAcceptedError";
|
|
62476
|
+
}
|
|
62477
|
+
};
|
|
62478
|
+
|
|
62386
62479
|
// src/client.ts
|
|
62480
|
+
var UPLOAD_CONTEXT = (organizationId) => ({
|
|
62481
|
+
form: {
|
|
62482
|
+
AddDCSToCache: "true",
|
|
62483
|
+
IsPending: "true",
|
|
62484
|
+
DCSSource: "820",
|
|
62485
|
+
OrganizationId: organizationId,
|
|
62486
|
+
EncryptDCSOnRemote: ""
|
|
62487
|
+
},
|
|
62488
|
+
context: { isPending: true, addDCSToCache: true, dcsSource: "820", organizationId }
|
|
62489
|
+
});
|
|
62387
62490
|
var TOKEN_PAGE = "Home";
|
|
62388
62491
|
var TOKEN_RE = /name="__RequestVerificationToken"[^>]*value="([^"]+)"/;
|
|
62389
62492
|
var NONCE_RE = /id=['"]cspScripts['"][^>]*nonce=['"]([0-9a-f]{32})['"]/;
|
|
@@ -62402,7 +62505,7 @@ function emptyBody(what) {
|
|
|
62402
62505
|
);
|
|
62403
62506
|
}
|
|
62404
62507
|
function notSignedIn() {
|
|
62405
|
-
return new
|
|
62508
|
+
return new NotAcceptedError(
|
|
62406
62509
|
"Not signed in to MyAtriumHealth \u2014 the portal returned a sign-in or verification page.",
|
|
62407
62510
|
{
|
|
62408
62511
|
hint: "Open https://my.atriumhealth.org/ in Chrome and sign in, completing any verification prompt, then retry. MyChart sessions are short-lived, so this recurs between uses."
|
|
@@ -62475,7 +62578,7 @@ var MyAtriumHealthClient = class {
|
|
|
62475
62578
|
parse(body, endpoint) {
|
|
62476
62579
|
const trimmed = body.trimStart();
|
|
62477
62580
|
if (trimmed === "") throw emptyBody(endpoint);
|
|
62478
|
-
if (!trimmed.startsWith("{") && !trimmed.startsWith("[")) {
|
|
62581
|
+
if (!trimmed.startsWith("{") && !trimmed.startsWith("[") && !trimmed.startsWith('"')) {
|
|
62479
62582
|
if (isAuthWall(body)) throw notSignedIn();
|
|
62480
62583
|
const oops = /<title>([^<]*)</.exec(body)?.[1]?.trim();
|
|
62481
62584
|
throw new McpToolError(
|
|
@@ -62496,22 +62599,90 @@ var MyAtriumHealthClient = class {
|
|
|
62496
62599
|
return this.parse(res.body, endpoint);
|
|
62497
62600
|
}
|
|
62498
62601
|
/** POST a modern `api/<area>/<Action>` endpoint. Body defaults to `{}`. */
|
|
62499
|
-
async api(endpoint, body = {}) {
|
|
62602
|
+
async api(endpoint, body = {}, opts = {}) {
|
|
62603
|
+
return this.postJson(`api/${endpoint.replace(/^\/+/, "")}`, body, endpoint, opts);
|
|
62604
|
+
}
|
|
62605
|
+
/** POST a JSON body to any path under the app root, as the web app does. */
|
|
62606
|
+
async postJson(path, body, endpoint, opts = {}) {
|
|
62500
62607
|
const token = await this.getToken();
|
|
62501
62608
|
return this.send(
|
|
62502
62609
|
{
|
|
62503
62610
|
method: "POST",
|
|
62504
|
-
path
|
|
62611
|
+
path,
|
|
62505
62612
|
headers: {
|
|
62506
62613
|
__RequestVerificationToken: token,
|
|
62507
62614
|
Accept: "application/json",
|
|
62508
62615
|
"Content-Type": "application/json"
|
|
62509
62616
|
},
|
|
62510
|
-
body: typeof body === "string" ? body : JSON.stringify(body)
|
|
62617
|
+
body: typeof body === "string" ? body : JSON.stringify(body),
|
|
62618
|
+
...opts.replay === false ? { replay: false } : {}
|
|
62511
62619
|
},
|
|
62512
62620
|
endpoint
|
|
62513
62621
|
);
|
|
62514
62622
|
}
|
|
62623
|
+
/**
|
|
62624
|
+
* One conversation with its messages, viewers and reply flags. The thread id
|
|
62625
|
+
* is the `hthId` from GetConversationList; the body is what the app sends
|
|
62626
|
+
* when a thread is opened.
|
|
62627
|
+
*/
|
|
62628
|
+
async conversationDetails(id, organizationId = "") {
|
|
62629
|
+
return this.api("conversations/GetConversationDetails", {
|
|
62630
|
+
id,
|
|
62631
|
+
messageId: "",
|
|
62632
|
+
organizationId,
|
|
62633
|
+
PageNonce: await this.pageNonce()
|
|
62634
|
+
});
|
|
62635
|
+
}
|
|
62636
|
+
/**
|
|
62637
|
+
* Upload one file for a message, exactly as the composer does. The file is
|
|
62638
|
+
* held PENDING: it reaches nobody until a send names its DocumentId, and
|
|
62639
|
+
* {@link deleteDocument} discards it.
|
|
62640
|
+
*
|
|
62641
|
+
* Multipart, so it needs a transport that carries binary — the browser bridge
|
|
62642
|
+
* refuses it.
|
|
62643
|
+
*/
|
|
62644
|
+
async uploadDocument(file2, organizationId = "") {
|
|
62645
|
+
const token = await this.getToken();
|
|
62646
|
+
const form = new FormData();
|
|
62647
|
+
form.append("__file__[]", new Blob([file2.bytes], { type: file2.mimeType }), file2.filename);
|
|
62648
|
+
for (const [k, v] of Object.entries(UPLOAD_CONTEXT(organizationId).form)) form.append(k, v);
|
|
62649
|
+
form.append("__RequestVerificationToken", token);
|
|
62650
|
+
const res = await this.send(
|
|
62651
|
+
{
|
|
62652
|
+
method: "POST",
|
|
62653
|
+
path: "DocumentUpload/UploadFile",
|
|
62654
|
+
headers: { __RequestVerificationToken: token },
|
|
62655
|
+
body: form
|
|
62656
|
+
},
|
|
62657
|
+
"DocumentUpload/UploadFile"
|
|
62658
|
+
);
|
|
62659
|
+
const doc = res.Success === true ? res.Data?.[0] : void 0;
|
|
62660
|
+
if (doc?.DocumentId === void 0) {
|
|
62661
|
+
throw new McpToolError(`MyAtriumHealth did not accept the upload of ${file2.filename}.`, {
|
|
62662
|
+
hint: "Nothing was sent. Check the file type and size against the portal limits."
|
|
62663
|
+
});
|
|
62664
|
+
}
|
|
62665
|
+
return {
|
|
62666
|
+
documentId: doc.DocumentId,
|
|
62667
|
+
filename: doc.FileDisplayName ?? file2.filename,
|
|
62668
|
+
fileExtension: doc.FileExtension ?? ""
|
|
62669
|
+
};
|
|
62670
|
+
}
|
|
62671
|
+
/** Discard a pending upload, with the context the composer sends. */
|
|
62672
|
+
async deleteDocument(doc, organizationId = "") {
|
|
62673
|
+
await this.postJson(
|
|
62674
|
+
"DocumentUpload/DeleteFile",
|
|
62675
|
+
{
|
|
62676
|
+
DocumentId: doc.documentId,
|
|
62677
|
+
ContextData: UPLOAD_CONTEXT(organizationId).context,
|
|
62678
|
+
FileDisplayName: doc.filename,
|
|
62679
|
+
FileExtension: doc.fileExtension.replace(/^\./, ""),
|
|
62680
|
+
FileReference: "",
|
|
62681
|
+
AllowPreview: false
|
|
62682
|
+
},
|
|
62683
|
+
"DocumentUpload/DeleteFile"
|
|
62684
|
+
);
|
|
62685
|
+
}
|
|
62515
62686
|
/**
|
|
62516
62687
|
* List Message Center conversations for a folder tag.
|
|
62517
62688
|
*
|
|
@@ -62634,6 +62805,12 @@ var ServerTransport = class {
|
|
|
62634
62805
|
this.auth.persistIfDirty();
|
|
62635
62806
|
if (/<title>[^<]*Login Page/i.test(out.body)) {
|
|
62636
62807
|
this.loggedIn = false;
|
|
62808
|
+
if (init.replay === false) {
|
|
62809
|
+
throw new NotAcceptedError(
|
|
62810
|
+
"The MyAtriumHealth session expired before this was accepted, so nothing was sent.",
|
|
62811
|
+
{ hint: "Retry the call; it signs in again first. A verification code may be required." }
|
|
62812
|
+
);
|
|
62813
|
+
}
|
|
62637
62814
|
await this.ensureSession();
|
|
62638
62815
|
out = await send();
|
|
62639
62816
|
this.auth.persistIfDirty();
|
|
@@ -62842,6 +63019,31 @@ var PatientContext = class {
|
|
|
62842
63019
|
{ hint: "Run mah_get_patient_context, then retry the read." }
|
|
62843
63020
|
);
|
|
62844
63021
|
}
|
|
63022
|
+
/**
|
|
63023
|
+
* Run a WRITE as the selected patient — once, never retried.
|
|
63024
|
+
*
|
|
63025
|
+
* {@link readAs} recovers from a mid-flight sign-in by reading again. A write
|
|
63026
|
+
* cannot: repeating it could send twice, and the fresh session serves the
|
|
63027
|
+
* account holder, so the repeat would go out as someone else. Instead the
|
|
63028
|
+
* write gets `assertUnchanged`, to call immediately before its irreversible
|
|
63029
|
+
* step; it throws if any sign-in has happened since the patient was
|
|
63030
|
+
* confirmed, while nothing has been sent yet.
|
|
63031
|
+
*/
|
|
63032
|
+
async writeAs(client2, write) {
|
|
63033
|
+
const patient = await this.ensure(client2);
|
|
63034
|
+
const generation = this.generation;
|
|
63035
|
+
return write({
|
|
63036
|
+
patient,
|
|
63037
|
+
assertUnchanged: () => {
|
|
63038
|
+
if (this.generation !== generation) {
|
|
63039
|
+
throw new McpToolError(
|
|
63040
|
+
`MyAtriumHealth re-authenticated after ${patient} was confirmed, so nothing was sent: the new session may be serving a different patient.`,
|
|
63041
|
+
{ hint: "Retry the call; it confirms the patient again before sending." }
|
|
63042
|
+
);
|
|
63043
|
+
}
|
|
63044
|
+
}
|
|
63045
|
+
});
|
|
63046
|
+
}
|
|
62845
63047
|
};
|
|
62846
63048
|
|
|
62847
63049
|
// src/tools/patients.ts
|
|
@@ -62877,7 +63079,7 @@ function registerPatientTools(server, client2, patients2) {
|
|
|
62877
63079
|
"mah_set_active_patient",
|
|
62878
63080
|
{
|
|
62879
63081
|
description: "Point every reading tool at one of the patients from mah_list_patients. The switch is confirmed with the portal before it is stored, and it survives restarts. Select the account holder to return to the default.",
|
|
62880
|
-
annotations: toolAnnotations({ readOnly: false, idempotent: true }),
|
|
63082
|
+
annotations: toolAnnotations({ readOnly: false, idempotent: true, destructive: false }),
|
|
62881
63083
|
inputSchema: external_exports.object({
|
|
62882
63084
|
patient_id: external_exports.string().min(1).describe("id from mah_list_patients")
|
|
62883
63085
|
})
|
|
@@ -68246,6 +68448,14 @@ var FetchproxyTransport = class {
|
|
|
68246
68448
|
return this.inner.status();
|
|
68247
68449
|
}
|
|
68248
68450
|
async fetch(init) {
|
|
68451
|
+
if (init.body !== void 0 && typeof init.body !== "string") {
|
|
68452
|
+
throw new McpToolError(
|
|
68453
|
+
"File uploads cannot go through the browser bridge \u2014 it relays a request body as text, and a binary upload would arrive corrupted.",
|
|
68454
|
+
{
|
|
68455
|
+
hint: "Send without attachments, or set MAH_USERNAME and MAH_PASSWORD so this server signs in itself; uploads work on that path."
|
|
68456
|
+
}
|
|
68457
|
+
);
|
|
68458
|
+
}
|
|
68249
68459
|
const response = await this.inner.fetch({
|
|
68250
68460
|
method: init.method,
|
|
68251
68461
|
path: `/${APP_ROOT}/${init.path.replace(/^\/+/, "")}`,
|
|
@@ -68346,7 +68556,7 @@ function registerAccountTools(server, client2, patients2) {
|
|
|
68346
68556
|
server.registerTool(
|
|
68347
68557
|
"mah_list_messages",
|
|
68348
68558
|
{
|
|
68349
|
-
description: "List Message Center conversations for a folder. Folder tags come from mah_list_message_folders (1 = Conversations/inbox, 2 = Archive).",
|
|
68559
|
+
description: "List Message Center conversations for a folder. Folder tags come from mah_list_message_folders (1 = Conversations/inbox, 2 = Archive). Each carries the conversationId that mah_reply_message takes.",
|
|
68350
68560
|
annotations: toolAnnotations({ readOnly: true }),
|
|
68351
68561
|
inputSchema: external_exports.object({
|
|
68352
68562
|
folder: external_exports.number().int().default(1).describe("Folder tag, from mah_list_message_folders."),
|
|
@@ -68364,14 +68574,17 @@ function registerAccountTools(server, client2, patients2) {
|
|
|
68364
68574
|
"conversations/GetConversationList",
|
|
68365
68575
|
(r) => r.conversations?.map((c) => {
|
|
68366
68576
|
const msgs = c["messages"] ?? [];
|
|
68367
|
-
const
|
|
68577
|
+
const last = msgs.at(-1) ?? {};
|
|
68368
68578
|
const participants = (c["userKeys"] ?? []).map((k) => r.users?.[k]?.name).filter((n) => typeof n === "string" && n !== "");
|
|
68369
68579
|
return tidy({
|
|
68580
|
+
// The id mah_reply_message takes.
|
|
68581
|
+
conversationId: c["hthId"],
|
|
68370
68582
|
subject: c["subject"],
|
|
68371
68583
|
participants: participants.length > 0 ? participants : void 0,
|
|
68372
68584
|
preview: c["previewText"],
|
|
68373
68585
|
messageType: c["messageType"],
|
|
68374
|
-
date:
|
|
68586
|
+
date: last["deliveryInstantISO"],
|
|
68587
|
+
lastMessageId: last["wmgId"],
|
|
68375
68588
|
unread: msgs.some((m) => m["isUnread"] === true),
|
|
68376
68589
|
hasAttachments: c["hasAttachments"],
|
|
68377
68590
|
urgent: c["hasUrgentMsgs"],
|
|
@@ -68500,7 +68713,7 @@ function registerAuthTools(server, auth2) {
|
|
|
68500
68713
|
"mah_sign_in",
|
|
68501
68714
|
{
|
|
68502
68715
|
description: "Sign in to MyAtriumHealth server-side. If the portal requires a verification code, this reports the available channels \u2014 ask the user which they want.",
|
|
68503
|
-
annotations: toolAnnotations({ readOnly: false }),
|
|
68716
|
+
annotations: toolAnnotations({ readOnly: false, destructive: true }),
|
|
68504
68717
|
inputSchema: external_exports.object({})
|
|
68505
68718
|
},
|
|
68506
68719
|
async () => {
|
|
@@ -68524,7 +68737,7 @@ function registerAuthTools(server, auth2) {
|
|
|
68524
68737
|
"mah_send_verification_code",
|
|
68525
68738
|
{
|
|
68526
68739
|
description: "Ask MyAtriumHealth to send a verification code to the account holder on the channel they chose. The code goes to them, not to this server.",
|
|
68527
|
-
annotations: toolAnnotations({ readOnly: false }),
|
|
68740
|
+
annotations: toolAnnotations({ readOnly: false, destructive: true }),
|
|
68528
68741
|
inputSchema: external_exports.object({
|
|
68529
68742
|
channel: external_exports.enum(["sms", "email", "totp"]).describe(
|
|
68530
68743
|
"Channel the user chose, from the list mah_sign_in reported. 'totp' is an authenticator app \u2014 nothing is sent; read the code from the app."
|
|
@@ -68545,7 +68758,7 @@ function registerAuthTools(server, auth2) {
|
|
|
68545
68758
|
"mah_verify_code",
|
|
68546
68759
|
{
|
|
68547
68760
|
description: "Submit the verification code the user received. On success the session is stored so restarts resume without signing in again, until it lapses.",
|
|
68548
|
-
annotations: toolAnnotations({ readOnly: false }),
|
|
68761
|
+
annotations: toolAnnotations({ readOnly: false, destructive: true }),
|
|
68549
68762
|
inputSchema: external_exports.object({
|
|
68550
68763
|
code: external_exports.string().min(4).describe("The code the USER received. Never guess or generate it."),
|
|
68551
68764
|
rememberDevice: external_exports.boolean().default(true).describe(
|
|
@@ -69797,10 +70010,10 @@ function prepend(elem, prev) {
|
|
|
69797
70010
|
prev.next = elem;
|
|
69798
70011
|
elem.prev = prev;
|
|
69799
70012
|
}
|
|
69800
|
-
function filter(test, node2, recurse = true,
|
|
69801
|
-
return find(test, Array.isArray(node2) ? node2 : [node2], recurse,
|
|
70013
|
+
function filter(test, node2, recurse = true, limit2 = Infinity) {
|
|
70014
|
+
return find(test, Array.isArray(node2) ? node2 : [node2], recurse, limit2);
|
|
69802
70015
|
}
|
|
69803
|
-
function find(test, nodes, recurse,
|
|
70016
|
+
function find(test, nodes, recurse, limit2) {
|
|
69804
70017
|
const result = [];
|
|
69805
70018
|
const nodeStack = [Array.isArray(nodes) ? nodes : [nodes]];
|
|
69806
70019
|
const indexStack = [0];
|
|
@@ -69814,7 +70027,7 @@ function find(test, nodes, recurse, limit) {
|
|
|
69814
70027
|
const elem = nodeStack[0][indexStack[0]++];
|
|
69815
70028
|
if (test(elem)) {
|
|
69816
70029
|
result.push(elem);
|
|
69817
|
-
if (--
|
|
70030
|
+
if (--limit2 <= 0) return result;
|
|
69818
70031
|
}
|
|
69819
70032
|
if (recurse && hasChildren(elem) && elem.children.length > 0) {
|
|
69820
70033
|
indexStack.unshift(0);
|
|
@@ -69892,22 +70105,22 @@ function testElement(options, node2) {
|
|
|
69892
70105
|
const test = compileTest(options);
|
|
69893
70106
|
return test ? test(node2) : true;
|
|
69894
70107
|
}
|
|
69895
|
-
function getElements(options, nodes, recurse,
|
|
70108
|
+
function getElements(options, nodes, recurse, limit2 = Infinity) {
|
|
69896
70109
|
const test = compileTest(options);
|
|
69897
|
-
return test ? filter(test, nodes, recurse,
|
|
70110
|
+
return test ? filter(test, nodes, recurse, limit2) : [];
|
|
69898
70111
|
}
|
|
69899
70112
|
function getElementById(id, nodes, recurse = true) {
|
|
69900
70113
|
if (!Array.isArray(nodes)) nodes = [nodes];
|
|
69901
70114
|
return findOne$1(getAttribCheck("id", id), nodes, recurse);
|
|
69902
70115
|
}
|
|
69903
|
-
function getElementsByTagName(tagName, nodes, recurse = true,
|
|
69904
|
-
return filter(Checks["tag_name"](tagName), nodes, recurse,
|
|
70116
|
+
function getElementsByTagName(tagName, nodes, recurse = true, limit2 = Infinity) {
|
|
70117
|
+
return filter(Checks["tag_name"](tagName), nodes, recurse, limit2);
|
|
69905
70118
|
}
|
|
69906
|
-
function getElementsByClassName(className, nodes, recurse = true,
|
|
69907
|
-
return filter(getAttribCheck("class", className), nodes, recurse,
|
|
70119
|
+
function getElementsByClassName(className, nodes, recurse = true, limit2 = Infinity) {
|
|
70120
|
+
return filter(getAttribCheck("class", className), nodes, recurse, limit2);
|
|
69908
70121
|
}
|
|
69909
|
-
function getElementsByTagType(type, nodes, recurse = true,
|
|
69910
|
-
return filter(Checks["tag_type"](type), nodes, recurse,
|
|
70122
|
+
function getElementsByTagType(type, nodes, recurse = true, limit2 = Infinity) {
|
|
70123
|
+
return filter(Checks["tag_type"](type), nodes, recurse, limit2);
|
|
69911
70124
|
}
|
|
69912
70125
|
function removeSubsets$1(nodes) {
|
|
69913
70126
|
let idx = nodes.length;
|
|
@@ -72794,6 +73007,262 @@ function registerBillingTools(server, client2, patients2) {
|
|
|
72794
73007
|
);
|
|
72795
73008
|
}
|
|
72796
73009
|
|
|
73010
|
+
// src/reply.ts
|
|
73011
|
+
var MIME = {
|
|
73012
|
+
BMP: "image/bmp",
|
|
73013
|
+
DOC: "application/msword",
|
|
73014
|
+
DOCX: "application/vnd.openxmlformats-officedocument.wordprocessingml.document",
|
|
73015
|
+
JPEG: "image/jpeg",
|
|
73016
|
+
JPG: "image/jpeg",
|
|
73017
|
+
PDF: "application/pdf",
|
|
73018
|
+
PNG: "image/png",
|
|
73019
|
+
TIF: "image/tiff",
|
|
73020
|
+
TIFF: "image/tiff",
|
|
73021
|
+
"3GP": "video/3gpp",
|
|
73022
|
+
"3GPP": "video/3gpp",
|
|
73023
|
+
AVI: "video/x-msvideo",
|
|
73024
|
+
MOV: "video/quicktime",
|
|
73025
|
+
MP4: "video/mp4",
|
|
73026
|
+
MPEG: "video/mpeg",
|
|
73027
|
+
MPG: "video/mpeg",
|
|
73028
|
+
WMV: "video/x-ms-wmv"
|
|
73029
|
+
};
|
|
73030
|
+
function toParagraphs(body) {
|
|
73031
|
+
return body.split(/\r\n|[\r\n]/);
|
|
73032
|
+
}
|
|
73033
|
+
function paragraphsOf(html) {
|
|
73034
|
+
const root = parse3(html);
|
|
73035
|
+
for (const s of root.querySelectorAll("style")) s.remove();
|
|
73036
|
+
return root.querySelectorAll("[data-paragraph]").map((p) => p.text);
|
|
73037
|
+
}
|
|
73038
|
+
var sameText = (a, b) => {
|
|
73039
|
+
const norm = (x) => x.join("\n").replace(/\s+/g, " ").trim();
|
|
73040
|
+
return norm(a) === norm(b);
|
|
73041
|
+
};
|
|
73042
|
+
function limit(value, what) {
|
|
73043
|
+
if (typeof value === "number") return value;
|
|
73044
|
+
throw new McpToolError(`MyAtriumHealth did not report its ${what}, so the reply cannot be checked against it.`, {
|
|
73045
|
+
hint: "Nothing was sent. GetComposeSettings may have changed shape; see docs/MYATRIUMHEALTH-API.md."
|
|
73046
|
+
});
|
|
73047
|
+
}
|
|
73048
|
+
function prepareAttachments(files, settings) {
|
|
73049
|
+
if (files.length === 0) return [];
|
|
73050
|
+
const a = settings.attachmentSettings ?? {};
|
|
73051
|
+
if (a.canAttach !== true) {
|
|
73052
|
+
throw new McpToolError("MyAtriumHealth does not allow attachments on this reply.");
|
|
73053
|
+
}
|
|
73054
|
+
const max = limit(a.maxNumberOfAttachments, "attachment count limit");
|
|
73055
|
+
if (files.length > max) {
|
|
73056
|
+
throw new McpToolError(`MyAtriumHealth allows at most ${max} attachments per message.`);
|
|
73057
|
+
}
|
|
73058
|
+
return files.map((f) => {
|
|
73059
|
+
const ext = /\.([^.]+)$/.exec(f.filename)?.[1]?.toUpperCase() ?? "";
|
|
73060
|
+
const kind = a.docAndImageSettings?.allowedFileExtensions?.includes(ext) ? a.docAndImageSettings : a.videoSettings?.allowedFileExtensions?.includes(ext) ? a.videoSettings : void 0;
|
|
73061
|
+
if (kind === void 0 || MIME[ext] === void 0) {
|
|
73062
|
+
const allowed = [
|
|
73063
|
+
...a.docAndImageSettings?.allowedFileExtensions ?? [],
|
|
73064
|
+
...a.videoSettings?.allowedFileExtensions ?? []
|
|
73065
|
+
];
|
|
73066
|
+
throw new McpToolError(`MyAtriumHealth does not accept ${ext || "extension-less"} files (${f.filename}).`, {
|
|
73067
|
+
hint: `Accepted: ${allowed.join(", ")}.`
|
|
73068
|
+
});
|
|
73069
|
+
}
|
|
73070
|
+
if (!/^[A-Za-z0-9+/\s]*={0,2}\s*$/.test(f.contentBase64)) {
|
|
73071
|
+
throw new McpToolError(`${f.filename}: contentBase64 is not valid base64.`);
|
|
73072
|
+
}
|
|
73073
|
+
const bytes = new Uint8Array(Buffer.from(f.contentBase64, "base64"));
|
|
73074
|
+
if (bytes.length === 0) throw new McpToolError(`${f.filename} is empty.`);
|
|
73075
|
+
const maxKb = limit(kind.maxFileSize, "file size limit");
|
|
73076
|
+
if (bytes.length > maxKb * 1024) {
|
|
73077
|
+
throw new McpToolError(
|
|
73078
|
+
`${f.filename} is too large: ${bytes.length} bytes, over the portal limit of ${maxKb} KB.`
|
|
73079
|
+
);
|
|
73080
|
+
}
|
|
73081
|
+
return { filename: f.filename, mimeType: MIME[ext], bytes };
|
|
73082
|
+
});
|
|
73083
|
+
}
|
|
73084
|
+
async function prepare(client2, input2) {
|
|
73085
|
+
const details = await client2.conversationDetails(input2.conversationId);
|
|
73086
|
+
if (details?.hthId !== input2.conversationId) {
|
|
73087
|
+
throw new McpToolError(`No conversation ${input2.conversationId} for the current patient.`, {
|
|
73088
|
+
hint: "conversationId is the conversationId from mah_list_messages. Threads belong to one patient \u2014 check mah_get_patient_context."
|
|
73089
|
+
});
|
|
73090
|
+
}
|
|
73091
|
+
if (details.replyFlags?.canReply !== true) {
|
|
73092
|
+
throw new McpToolError(
|
|
73093
|
+
`This conversation cannot be replied to (portal reason code ${details.replyFlags?.cannotReplyReason ?? "unknown"}).`,
|
|
73094
|
+
{ hint: "Start a new message in the portal instead." }
|
|
73095
|
+
);
|
|
73096
|
+
}
|
|
73097
|
+
const selves = Object.values(details.viewers ?? {}).filter((v) => v.isSelf === true && v.wprId);
|
|
73098
|
+
if (selves.length !== 1) {
|
|
73099
|
+
throw new McpToolError(
|
|
73100
|
+
`Expected exactly one viewer marked as you on this conversation, found ${selves.length}.`,
|
|
73101
|
+
{ hint: "Reply in the portal instead; this case has not been observed." }
|
|
73102
|
+
);
|
|
73103
|
+
}
|
|
73104
|
+
const organizationId = details.organizationId ?? "";
|
|
73105
|
+
const settings = await client2.api("conversations/GetComposeSettings", {
|
|
73106
|
+
organizationId
|
|
73107
|
+
});
|
|
73108
|
+
if (input2.body.trim() === "") throw new McpToolError("The reply body is empty.");
|
|
73109
|
+
const maxLen = limit(settings.maxMessageLength, "message length limit");
|
|
73110
|
+
if (input2.body.length > maxLen) {
|
|
73111
|
+
throw new McpToolError(
|
|
73112
|
+
`The reply is ${input2.body.length} characters; MyAtriumHealth allows ${maxLen}.`,
|
|
73113
|
+
{ hint: "Shorten it, or split it across more than one reply." }
|
|
73114
|
+
);
|
|
73115
|
+
}
|
|
73116
|
+
const files = prepareAttachments(input2.attachments ?? [], settings);
|
|
73117
|
+
const recipients = (details.userKeys ?? []).map((k) => details.users?.[k]?.name).filter((n) => typeof n === "string" && n !== "");
|
|
73118
|
+
return {
|
|
73119
|
+
details,
|
|
73120
|
+
organizationId,
|
|
73121
|
+
selfWprId: selves[0].wprId,
|
|
73122
|
+
paragraphs: toParagraphs(input2.body),
|
|
73123
|
+
files,
|
|
73124
|
+
recipients
|
|
73125
|
+
};
|
|
73126
|
+
}
|
|
73127
|
+
var UNCERTAIN_HINT = "Check the thread with mah_list_messages before retrying \u2014 sending again may post it twice.";
|
|
73128
|
+
async function replyToConversation(client2, patients2, input2, opts) {
|
|
73129
|
+
return patients2.writeAs(client2, async ({ patient, assertUnchanged }) => {
|
|
73130
|
+
const p = await prepare(client2, input2);
|
|
73131
|
+
const summary = {
|
|
73132
|
+
patient,
|
|
73133
|
+
conversationId: input2.conversationId,
|
|
73134
|
+
subject: p.details.subject,
|
|
73135
|
+
recipients: p.recipients,
|
|
73136
|
+
body: input2.body,
|
|
73137
|
+
attachments: p.files.map((f) => ({ filename: f.filename, bytes: f.bytes.length }))
|
|
73138
|
+
};
|
|
73139
|
+
const readOnlyReason = "MAH_READ_ONLY is set on this server, so replies are refused. Unset it (or set it to false) and restart to allow sending.";
|
|
73140
|
+
const bridgeReason = "Attachments need the credential transport: this server relays through the browser bridge, which cannot carry a file upload.";
|
|
73141
|
+
const blocked = opts.readOnly ? readOnlyReason : p.files.length > 0 && !opts.attachmentsSupported ? bridgeReason : void 0;
|
|
73142
|
+
if (input2.confirm !== true) {
|
|
73143
|
+
return {
|
|
73144
|
+
sent: false,
|
|
73145
|
+
...summary,
|
|
73146
|
+
...blocked !== void 0 ? { sendBlocked: blocked } : {},
|
|
73147
|
+
nextStep: "Nothing was sent. Show this to the user; if they approve, call mah_reply_message again with the same arguments and confirm: true. Sending is irreversible."
|
|
73148
|
+
};
|
|
73149
|
+
}
|
|
73150
|
+
if (opts.readOnly) throw new McpToolError(readOnlyReason);
|
|
73151
|
+
if (p.files.length > 0 && !opts.attachmentsSupported) {
|
|
73152
|
+
throw new McpToolError(bridgeReason, {
|
|
73153
|
+
hint: "Send without attachments, or set MAH_USERNAME and MAH_PASSWORD so the server signs in itself."
|
|
73154
|
+
});
|
|
73155
|
+
}
|
|
73156
|
+
assertUnchanged();
|
|
73157
|
+
const composeId = await client2.api("conversations/GetComposeId", {});
|
|
73158
|
+
const uploaded = [];
|
|
73159
|
+
const cleanUp = async () => {
|
|
73160
|
+
for (const d of uploaded) await client2.deleteDocument(d, p.organizationId).catch(() => {
|
|
73161
|
+
});
|
|
73162
|
+
await client2.api("conversations/RemoveComposeId", { composeId }).catch(() => {
|
|
73163
|
+
});
|
|
73164
|
+
};
|
|
73165
|
+
const payload = () => ({
|
|
73166
|
+
conversationId: input2.conversationId,
|
|
73167
|
+
organizationId: p.organizationId,
|
|
73168
|
+
viewers: [{ wprId: p.selfWprId }],
|
|
73169
|
+
messageBody: p.paragraphs,
|
|
73170
|
+
documentIds: uploaded.map((d) => d.documentId),
|
|
73171
|
+
includeOtherViewers: false,
|
|
73172
|
+
composeId
|
|
73173
|
+
});
|
|
73174
|
+
try {
|
|
73175
|
+
assertUnchanged();
|
|
73176
|
+
for (const f of p.files) uploaded.push(await client2.uploadDocument(f, p.organizationId));
|
|
73177
|
+
assertUnchanged();
|
|
73178
|
+
const draft = await client2.api(
|
|
73179
|
+
"conversations/SaveReplyDraft",
|
|
73180
|
+
payload()
|
|
73181
|
+
);
|
|
73182
|
+
if (draft?.conversationId !== input2.conversationId || draft.error !== 0) {
|
|
73183
|
+
throw new McpToolError("MyAtriumHealth refused the reply draft, so nothing was sent.", {
|
|
73184
|
+
hint: `The portal answered ${JSON.stringify(draft).slice(0, 120)}.`
|
|
73185
|
+
});
|
|
73186
|
+
}
|
|
73187
|
+
assertUnchanged();
|
|
73188
|
+
} catch (e) {
|
|
73189
|
+
await cleanUp();
|
|
73190
|
+
throw e;
|
|
73191
|
+
}
|
|
73192
|
+
let answer;
|
|
73193
|
+
try {
|
|
73194
|
+
answer = await client2.api("conversations/SendReply", payload(), { replay: false });
|
|
73195
|
+
} catch (e) {
|
|
73196
|
+
if (e instanceof NotAcceptedError) {
|
|
73197
|
+
await cleanUp();
|
|
73198
|
+
throw e;
|
|
73199
|
+
}
|
|
73200
|
+
throw new McpToolError(
|
|
73201
|
+
`The reply may have been sent \u2014 the portal's answer could not be read (${e.message}).`,
|
|
73202
|
+
{ hint: UNCERTAIN_HINT }
|
|
73203
|
+
);
|
|
73204
|
+
}
|
|
73205
|
+
const echoed = answer === input2.conversationId;
|
|
73206
|
+
if (echoed) await client2.api("conversations/RemoveComposeId", { composeId }).catch(() => {
|
|
73207
|
+
});
|
|
73208
|
+
const seen = new Set((p.details.messages ?? []).map((m) => m.wmgId));
|
|
73209
|
+
let message;
|
|
73210
|
+
try {
|
|
73211
|
+
const after = await client2.conversationDetails(input2.conversationId, p.organizationId);
|
|
73212
|
+
message = (after.messages ?? []).filter(
|
|
73213
|
+
(m) => !seen.has(m.wmgId) && m.author?.wprKey === p.selfWprId && sameText(paragraphsOf(m.body ?? ""), p.paragraphs)
|
|
73214
|
+
).at(-1);
|
|
73215
|
+
} catch {
|
|
73216
|
+
message = void 0;
|
|
73217
|
+
}
|
|
73218
|
+
if (!echoed && message === void 0) {
|
|
73219
|
+
throw new McpToolError("MyAtriumHealth did not confirm the reply; it may have been sent.", {
|
|
73220
|
+
hint: UNCERTAIN_HINT
|
|
73221
|
+
});
|
|
73222
|
+
}
|
|
73223
|
+
if (!echoed) await client2.api("conversations/RemoveComposeId", { composeId }).catch(() => {
|
|
73224
|
+
});
|
|
73225
|
+
return {
|
|
73226
|
+
sent: true,
|
|
73227
|
+
verified: message !== void 0,
|
|
73228
|
+
...summary,
|
|
73229
|
+
...message !== void 0 ? {
|
|
73230
|
+
message: {
|
|
73231
|
+
wmgId: message.wmgId,
|
|
73232
|
+
deliveryInstantISO: message.deliveryInstantISO,
|
|
73233
|
+
isUnread: message.isUnread,
|
|
73234
|
+
attachments: message.attachments ?? []
|
|
73235
|
+
}
|
|
73236
|
+
} : {
|
|
73237
|
+
note: "MyAtriumHealth confirmed the send, but the new message could not be read back from the thread. Check it with mah_list_messages; do not resend."
|
|
73238
|
+
}
|
|
73239
|
+
};
|
|
73240
|
+
});
|
|
73241
|
+
}
|
|
73242
|
+
|
|
73243
|
+
// src/tools/messages.ts
|
|
73244
|
+
function registerMessageTools(server, client2, patients2, opts) {
|
|
73245
|
+
server.registerTool(
|
|
73246
|
+
"mah_reply_message",
|
|
73247
|
+
{
|
|
73248
|
+
description: "Reply to a Message Center conversation as the active patient. The provider sees the reply; sending is IRREVERSIBLE. Without confirm: true this only previews (thread, recipients, body) and sends nothing. Plain-text body, max 500 characters; each line becomes a paragraph. Optional attachments (PDF, image, Word or video, at most 3) need the server to sign in itself; the browser bridge cannot upload. Refused when MAH_READ_ONLY is set.",
|
|
73249
|
+
annotations: toolAnnotations({ readOnly: false, destructive: true }),
|
|
73250
|
+
inputSchema: external_exports.object({
|
|
73251
|
+
conversationId: external_exports.string().min(1).describe("The conversationId of the thread, from mah_list_messages."),
|
|
73252
|
+
body: external_exports.string().min(1).describe("Reply text. Line breaks start new paragraphs."),
|
|
73253
|
+
attachments: external_exports.array(
|
|
73254
|
+
external_exports.object({
|
|
73255
|
+
filename: external_exports.string().min(1).describe("File name with extension, e.g. scan.pdf."),
|
|
73256
|
+
contentBase64: external_exports.string().min(1).describe("The file content, base64-encoded.")
|
|
73257
|
+
})
|
|
73258
|
+
).optional().describe("Files to attach. Uploaded only when confirm is true."),
|
|
73259
|
+
confirm: external_exports.boolean().default(false).describe("Must be true to send. Otherwise the tool returns a preview and sends nothing.")
|
|
73260
|
+
})
|
|
73261
|
+
},
|
|
73262
|
+
async (args) => minifiedResult(await replyToConversation(client2, patients2, args, opts))
|
|
73263
|
+
);
|
|
73264
|
+
}
|
|
73265
|
+
|
|
72797
73266
|
// src/tools/healthcheck.ts
|
|
72798
73267
|
var HOST = "my.atriumhealth.org";
|
|
72799
73268
|
function registerMahHealthcheckTool(server, client2, arms) {
|
|
@@ -73170,7 +73639,7 @@ function registerVisitTools(server, client2, patients2) {
|
|
|
73170
73639
|
}
|
|
73171
73640
|
|
|
73172
73641
|
// src/version.ts
|
|
73173
|
-
var VERSION = "1.
|
|
73642
|
+
var VERSION = "1.2.0";
|
|
73174
73643
|
|
|
73175
73644
|
// src/index.ts
|
|
73176
73645
|
var dotenvCandidates = [
|
|
@@ -73197,6 +73666,7 @@ if (!bridgeless && (username !== void 0 || password !== void 0)) {
|
|
|
73197
73666
|
"[myatriumhealth-mcp] No MAH_USERNAME / MAH_PASSWORD found, so requests will relay through your signed-in my.atriumhealth.org tab. Set both (env, or a .env beside the server / MAH_DOTENV) for bridge-less sign-in, which needs no tab."
|
|
73198
73667
|
);
|
|
73199
73668
|
}
|
|
73669
|
+
var readOnly = parseBoolEnv("MAH_READ_ONLY", { default: false });
|
|
73200
73670
|
var port = readPortEnv("MAH_WS_PORT", DEFAULT_PORT);
|
|
73201
73671
|
var transport;
|
|
73202
73672
|
var auth;
|
|
@@ -73239,6 +73709,8 @@ await runMcp({
|
|
|
73239
73709
|
(server) => registerVisitTools(server, client, patients),
|
|
73240
73710
|
(server) => registerAccountTools(server, client, patients),
|
|
73241
73711
|
(server) => registerBillingTools(server, client, patients),
|
|
73712
|
+
// Uploads need a transport that carries binary — only the credential one.
|
|
73713
|
+
(server) => registerMessageTools(server, client, patients, { readOnly, attachmentsSupported: bridgeless }),
|
|
73242
73714
|
(server) => registerPatientTools(server, client, patients),
|
|
73243
73715
|
// Unconditional, in both modes. What varies is what they DO, not whether
|
|
73244
73716
|
// they exist — see registerAuthTools for why a tool surface that follows
|
package/mint.yaml
CHANGED
|
@@ -31,6 +31,11 @@ env:
|
|
|
31
31
|
Override the session-state file path. Defaults to device.json under the
|
|
32
32
|
data directory, which is where it belongs when hosted — and with
|
|
33
33
|
per-user children each caller gets their own.
|
|
34
|
+
- name: MAH_READ_ONLY
|
|
35
|
+
required: false
|
|
36
|
+
help: >-
|
|
37
|
+
true refuses mah_reply_message sends; previews still work. Default false —
|
|
38
|
+
every send already requires confirm: true.
|
|
34
39
|
- name: MAH_WS_PORT
|
|
35
40
|
required: false
|
|
36
41
|
help: >-
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@chrischall/myatriumhealth-mcp",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.2.0",
|
|
4
4
|
"description": "MCP server for MyAtriumHealth (Atrium Health's Epic MyChart patient portal) via the fetchproxy browser bridge.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
@@ -42,7 +42,7 @@
|
|
|
42
42
|
"access": "public"
|
|
43
43
|
},
|
|
44
44
|
"dependencies": {
|
|
45
|
-
"@chrischall/mcp-utils": "^
|
|
45
|
+
"@chrischall/mcp-utils": "^2.2.0",
|
|
46
46
|
"@fetchproxy/server": "^3.0.1",
|
|
47
47
|
"@modelcontextprotocol/server": "^2.0.0",
|
|
48
48
|
"dotenv": "^17.4.2",
|
package/server.json
CHANGED
|
@@ -6,12 +6,12 @@
|
|
|
6
6
|
"url": "https://github.com/chrischall/myatriumhealth-mcp",
|
|
7
7
|
"source": "github"
|
|
8
8
|
},
|
|
9
|
-
"version": "1.
|
|
9
|
+
"version": "1.2.0",
|
|
10
10
|
"packages": [
|
|
11
11
|
{
|
|
12
12
|
"registryType": "npm",
|
|
13
13
|
"identifier": "@chrischall/myatriumhealth-mcp",
|
|
14
|
-
"version": "1.
|
|
14
|
+
"version": "1.2.0",
|
|
15
15
|
"transport": {
|
|
16
16
|
"type": "stdio"
|
|
17
17
|
},
|
|
@@ -21,6 +21,12 @@
|
|
|
21
21
|
"description": "Override the fetchproxy WebSocket port (default 37149).",
|
|
22
22
|
"isRequired": false,
|
|
23
23
|
"format": "string"
|
|
24
|
+
},
|
|
25
|
+
{
|
|
26
|
+
"name": "MAH_READ_ONLY",
|
|
27
|
+
"description": "true refuses mah_reply_message sends (previews still work). Default false.",
|
|
28
|
+
"isRequired": false,
|
|
29
|
+
"format": "boolean"
|
|
24
30
|
}
|
|
25
31
|
]
|
|
26
32
|
}
|