@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.
@@ -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.1.0"
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.1.0",
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.1.0",
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`, which changes only which patient this
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 readOnly;
25180
+ let readOnly2;
25181
25181
  if (typeof data === "string") {
25182
25182
  byteLength = Buffer.byteLength(data);
25183
- readOnly = false;
25183
+ readOnly2 = false;
25184
25184
  } else if (isBlob(data)) {
25185
25185
  byteLength = data.size;
25186
- readOnly = false;
25186
+ readOnly2 = false;
25187
25187
  } else {
25188
25188
  data = toBuffer(data);
25189
25189
  byteLength = data.length;
25190
- readOnly = toBuffer.readOnly;
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 readOnly;
25227
+ let readOnly2;
25228
25228
  if (typeof data === "string") {
25229
25229
  byteLength = Buffer.byteLength(data);
25230
- readOnly = false;
25230
+ readOnly2 = false;
25231
25231
  } else if (isBlob(data)) {
25232
25232
  byteLength = data.size;
25233
- readOnly = false;
25233
+ readOnly2 = false;
25234
25234
  } else {
25235
25235
  data = toBuffer(data);
25236
25236
  byteLength = data.length;
25237
- readOnly = toBuffer.readOnly;
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 readOnly;
25285
+ let readOnly2;
25286
25286
  if (typeof data === "string") {
25287
25287
  byteLength = Buffer.byteLength(data);
25288
- readOnly = false;
25288
+ readOnly2 = false;
25289
25289
  } else if (isBlob(data)) {
25290
25290
  byteLength = data.size;
25291
- readOnly = false;
25291
+ readOnly2 = false;
25292
25292
  } else {
25293
25293
  data = toBuffer(data);
25294
25294
  byteLength = data.length;
25295
- readOnly = toBuffer.readOnly;
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, limit = true) {
34855
+ function inlineRef(schema, limit2 = true) {
34842
34856
  if (typeof schema == "boolean") return true;
34843
- if (limit === true) return !hasRef(schema);
34844
- if (!limit) return false;
34845
- return countKeys(schema) <= limit;
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
- let result;
61766
- try {
61767
- result = cb(...args);
61768
- } catch (err) {
61769
- return hintResultOrRethrow(err);
61770
- }
61771
- return result instanceof Promise ? result.catch(hintResultOrRethrow) : result;
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 McpToolError(
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: `api/${endpoint.replace(/^\/+/, "")}`,
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 first = msgs[0] ?? {};
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: first["deliveryInstantISO"],
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, limit = Infinity) {
69801
- return find(test, Array.isArray(node2) ? node2 : [node2], recurse, limit);
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, limit) {
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 (--limit <= 0) return result;
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, limit = Infinity) {
70108
+ function getElements(options, nodes, recurse, limit2 = Infinity) {
69896
70109
  const test = compileTest(options);
69897
- return test ? filter(test, nodes, recurse, limit) : [];
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, limit = Infinity) {
69904
- return filter(Checks["tag_name"](tagName), nodes, recurse, limit);
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, limit = Infinity) {
69907
- return filter(getAttribCheck("class", className), nodes, recurse, limit);
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, limit = Infinity) {
69910
- return filter(Checks["tag_type"](type), nodes, recurse, limit);
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.1.0";
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.1.0",
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": "^1.0.0",
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.1.0",
9
+ "version": "1.2.0",
10
10
  "packages": [
11
11
  {
12
12
  "registryType": "npm",
13
13
  "identifier": "@chrischall/myatriumhealth-mcp",
14
- "version": "1.1.0",
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
  }