@chrischall/myatriumhealth-mcp 0.3.0 → 0.4.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": "0.3.0"
10
+ "version": "0.4.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": "0.3.0",
18
+ "version": "0.4.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": "0.3.0",
4
+ "version": "0.4.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
@@ -137,11 +137,11 @@ stated rather than inferred.
137
137
  | `mah_list_care_team` | Care team providers, internal and external |
138
138
  | `mah_list_billing_accounts` | Billing accounts and balances (parsed from HTML) |
139
139
  | `mah_get_menu` | Which portal features this account exposes |
140
- | `mah_healthcheck` | Connection health — bridge status, or credential/session status in bridge-less mode |
141
- | `mah_auth_status` | Whether a session can be resumed and whether a device token is stored *(bridge-less only)* |
142
- | `mah_sign_in` | Sign in server-side; reports verification channels if a code is needed *(bridge-less only)* |
143
- | `mah_send_verification_code` | Ask the portal to send a code to the account holder *(bridge-less only)* |
144
- | `mah_verify_code` | Submit the code the user received *(bridge-less only)* |
140
+ | `mah_healthcheck` | Connection health — bridge status when relaying, credential and session status when signing in server-side |
141
+ | `mah_auth_status` | Whether a session can be resumed and whether a device token is stored |
142
+ | `mah_sign_in` | Sign in server-side; reports verification channels if a code is needed *(needs credentials)* |
143
+ | `mah_send_verification_code` | Ask the portal to send a code to the account holder *(needs credentials)* |
144
+ | `mah_verify_code` | Submit the code the user received *(needs credentials)* |
145
145
  | `mah_list_patients` | The patients this login can open — the account holder and any proxy subjects |
146
146
  | `mah_get_patient_context` | Which patient the readers are serving, confirmed with the portal |
147
147
  | `mah_set_active_patient` | Point every reader at one of those patients; survives restarts |
package/dist/bundle.js CHANGED
@@ -41365,6 +41365,8 @@ function validateFrame(raw) {
41365
41365
  return validateEncrypted(raw);
41366
41366
  if (t === "pair-pending")
41367
41367
  return validatePairPending(raw);
41368
+ if (t === "hello-rejected")
41369
+ return validateHelloRejected(raw);
41368
41370
  if (t === "extension-disconnected")
41369
41371
  return { type: "extension-disconnected" };
41370
41372
  throw new ProtocolError(`unknown frame type: ${String(t)}`);
@@ -41495,6 +41497,16 @@ function validatePairPending(raw) {
41495
41497
  }
41496
41498
  return { type: "pair-pending", mcpId: raw.mcpId, pairCode: raw.pairCode };
41497
41499
  }
41500
+ function validateHelloRejected(raw) {
41501
+ assertString(raw.mcpId, "hello-rejected.mcpId");
41502
+ if (!isValidMcpId(raw.mcpId))
41503
+ throw new ProtocolError("hello-rejected.mcpId: invalid format");
41504
+ assertString(raw.reason, "hello-rejected.reason");
41505
+ if (raw.reason.length > 200) {
41506
+ throw new ProtocolError("hello-rejected.reason: must be at most 200 characters");
41507
+ }
41508
+ return { type: "hello-rejected", mcpId: raw.mcpId, reason: raw.reason };
41509
+ }
41498
41510
  function validateInnerFrame(raw) {
41499
41511
  assertObject(raw, "inner");
41500
41512
  const t = raw.type;
@@ -42309,6 +42321,17 @@ var FetchproxySessionNotReadyError = class extends Error {
42309
42321
  Object.setPrototypeOf(this, new.target.prototype);
42310
42322
  }
42311
42323
  };
42324
+ var FetchproxyHelloRejectedError = class extends Error {
42325
+ mcpId;
42326
+ reason;
42327
+ constructor(info) {
42328
+ super(`fetchproxy: the extension refused the connection for "${info.mcpId}": ${info.reason}. This is the extension's own reason \u2014 it is not a timeout, and retrying unchanged will be refused the same way.`);
42329
+ this.name = "FetchproxyHelloRejectedError";
42330
+ this.mcpId = info.mcpId;
42331
+ this.reason = info.reason;
42332
+ Object.setPrototypeOf(this, new.target.prototype);
42333
+ }
42334
+ };
42312
42335
  async function awaitSessionReady(ready, opts) {
42313
42336
  const ms = opts.timeoutMs ?? SESSION_READY_TIMEOUT_MS;
42314
42337
  if (ms <= 0)
@@ -42334,12 +42357,21 @@ import { join as join3 } from "node:path";
42334
42357
 
42335
42358
  // node_modules/@fetchproxy/server/dist/identity.js
42336
42359
  import { readFile, writeFile, mkdir, chmod } from "node:fs/promises";
42337
- import { join as join2 } from "node:path";
42360
+ import { isAbsolute, join as join2 } from "node:path";
42338
42361
  import { homedir as homedir2 } from "node:os";
42339
42362
  var SAFE_PLAIN = /^[A-Za-z0-9._-]+$/;
42340
42363
  var SAFE_SCOPED = /^@[A-Za-z0-9._-]+\/[A-Za-z0-9._-]+$/;
42364
+ function envIdentityDir() {
42365
+ const raw = process.env.FETCHPROXY_IDENTITY_DIR;
42366
+ if (raw === void 0)
42367
+ return void 0;
42368
+ const dir = raw.trim();
42369
+ if (dir === "" || !isAbsolute(dir))
42370
+ return void 0;
42371
+ return dir;
42372
+ }
42341
42373
  function defaultIdentityDir() {
42342
- return join2(homedir2(), ".fetchproxy", "identity");
42374
+ return envIdentityDir() ?? join2(homedir2(), ".fetchproxy", "identity");
42343
42375
  }
42344
42376
  function safeIdentityFileBase(serverName) {
42345
42377
  if (!serverName || serverName === ".." || serverName.includes("..") || !SAFE_PLAIN.test(serverName) && !SAFE_SCOPED.test(serverName)) {
@@ -42347,42 +42379,57 @@ function safeIdentityFileBase(serverName) {
42347
42379
  }
42348
42380
  return serverName.replace(/\//g, "_");
42349
42381
  }
42350
- async function loadOrCreateIdentity(serverName, dir = defaultIdentityDir()) {
42351
- const safeFile = safeIdentityFileBase(serverName);
42352
- const path = join2(dir, `${safeFile}.json`);
42353
- await mkdir(dir, { recursive: true, mode: 448 });
42354
- try {
42355
- const raw = await readFile(path, "utf8");
42356
- const j2 = JSON.parse(raw);
42357
- return {
42358
- x25519Priv: fromB64(j2.x25519Priv),
42359
- x25519Pub: fromB64(j2.x25519Pub),
42360
- ed25519Priv: fromB64(j2.ed25519Priv),
42361
- ed25519Pub: fromB64(j2.ed25519Pub),
42362
- createdAt: j2.createdAt
42363
- };
42364
- } catch (e) {
42365
- if (e.code !== "ENOENT")
42366
- throw e;
42367
- }
42382
+ function identityFilePath(dir, serverName) {
42383
+ return join2(dir, `${safeIdentityFileBase(serverName)}.json`);
42384
+ }
42385
+ async function generateIdentity() {
42368
42386
  const x = await generateX25519();
42369
42387
  const ed = await generateEd25519();
42370
- const id = {
42388
+ return {
42371
42389
  x25519Priv: x.privateKey,
42372
42390
  x25519Pub: x.publicKey,
42373
42391
  ed25519Priv: ed.privateKey,
42374
42392
  ed25519Pub: ed.publicKey,
42375
42393
  createdAt: Date.now()
42376
42394
  };
42377
- const j = {
42395
+ }
42396
+ function serializeIdentity(id) {
42397
+ return JSON.stringify({
42378
42398
  x25519Priv: toB64(id.x25519Priv),
42379
42399
  x25519Pub: toB64(id.x25519Pub),
42380
42400
  ed25519Priv: toB64(id.ed25519Priv),
42381
42401
  ed25519Pub: toB64(id.ed25519Pub),
42382
42402
  createdAt: id.createdAt
42403
+ }, null, 2);
42404
+ }
42405
+ function parseIdentity(text2) {
42406
+ const j = JSON.parse(text2);
42407
+ return {
42408
+ x25519Priv: fromB64(j.x25519Priv),
42409
+ x25519Pub: fromB64(j.x25519Pub),
42410
+ ed25519Priv: fromB64(j.ed25519Priv),
42411
+ ed25519Pub: fromB64(j.ed25519Pub),
42412
+ createdAt: j.createdAt
42383
42413
  };
42384
- await writeFile(path, JSON.stringify(j, null, 2), { mode: 384 });
42414
+ }
42415
+ async function writeIdentityFile(dir, serverName, id) {
42416
+ const path = identityFilePath(dir, serverName);
42417
+ await mkdir(dir, { recursive: true, mode: 448 });
42418
+ await writeFile(path, serializeIdentity(id), { mode: 384 });
42385
42419
  await chmod(path, 384);
42420
+ return path;
42421
+ }
42422
+ async function loadOrCreateIdentity(serverName, dir = defaultIdentityDir()) {
42423
+ const path = identityFilePath(dir, serverName);
42424
+ await mkdir(dir, { recursive: true, mode: 448 });
42425
+ try {
42426
+ return parseIdentity(await readFile(path, "utf8"));
42427
+ } catch (e) {
42428
+ if (e.code !== "ENOENT")
42429
+ throw e;
42430
+ }
42431
+ const id = await generateIdentity();
42432
+ await writeIdentityFile(dir, serverName, id);
42386
42433
  return id;
42387
42434
  }
42388
42435
 
@@ -42486,6 +42533,9 @@ async function startHost(opts) {
42486
42533
  }
42487
42534
  });
42488
42535
  const ownHello = await buildServerHello({
42536
+ // 2.6.0: tell the extension it can say WHY it refused a hello, instead of
42537
+ // leaving us to time out and guess.
42538
+ accepts: ["hello-rejected"],
42489
42539
  identity: opts.ownIdentity,
42490
42540
  mcpId: opts.ownMcpId,
42491
42541
  serverName: opts.ownServerName,
@@ -42697,6 +42747,16 @@ async function startHost(opts) {
42697
42747
  extensionWs.send(JSON.stringify(frame));
42698
42748
  }
42699
42749
  }
42750
+ if (frame.type === "hello-rejected" && identified === "extension") {
42751
+ if (frame.mcpId === opts.ownMcpId) {
42752
+ rejectOwnSession(new FetchproxyHelloRejectedError({ mcpId: frame.mcpId, reason: frame.reason }));
42753
+ } else {
42754
+ const slot = peers.get(frame.mcpId);
42755
+ if (slot?.helloFrame.accepts?.includes("hello-rejected")) {
42756
+ slot.ws.send(JSON.stringify(frame));
42757
+ }
42758
+ }
42759
+ }
42700
42760
  if (frame.type === "pair-pending" && identified === "extension") {
42701
42761
  if (frame.mcpId === opts.ownMcpId) {
42702
42762
  ownPendingPairCode = frame.pairCode;
@@ -42808,7 +42868,8 @@ async function startPeer(opts) {
42808
42868
  graphqlOps: opts.graphqlOps,
42809
42869
  // 2.5.0: let a host ≥2.5.0 tell us when the extension leaves, so
42810
42870
  // `extensionConnected()` / `sessionLinked()` can go back to false.
42811
- accepts: ["extension-disconnected"]
42871
+ // 2.6.0: `hello-rejected` for the same reason, from the extension.
42872
+ accepts: ["extension-disconnected", "hello-rejected"]
42812
42873
  });
42813
42874
  const sessionNonce = fromB64(hello.sessionNonce);
42814
42875
  ws.send(JSON.stringify(hello));
@@ -42923,6 +42984,9 @@ async function startPeer(opts) {
42923
42984
  }
42924
42985
  return;
42925
42986
  }
42987
+ if (frame.type === "hello-rejected" && frame.mcpId === opts.mcpId) {
42988
+ rejectFirstReady(new FetchproxyHelloRejectedError({ mcpId: frame.mcpId, reason: frame.reason }));
42989
+ }
42926
42990
  if (frame.type === "pair-pending" && frame.mcpId === opts.mcpId) {
42927
42991
  pendingPairCode = frame.pairCode;
42928
42992
  pendingPairListeners.forEach((cb) => cb(frame.pairCode));
@@ -43020,6 +43084,8 @@ function classifyFetchError(error61) {
43020
43084
 
43021
43085
  // node_modules/@fetchproxy/server/dist/classify-bridge-error.js
43022
43086
  function classifyBridgeError(err) {
43087
+ if (err instanceof FetchproxyHelloRejectedError)
43088
+ return "hello_rejected";
43023
43089
  if (err instanceof FetchproxySessionNotReadyError)
43024
43090
  return "session_not_ready";
43025
43091
  if (err instanceof FetchproxyTimeoutError)
@@ -43120,11 +43186,20 @@ var FetchproxyNoTabError = class extends FetchproxyHintedError {
43120
43186
  this.name = "FetchproxyNoTabError";
43121
43187
  }
43122
43188
  };
43189
+ var FetchproxyTabOpeningError = class extends FetchproxyHintedError {
43190
+ constructor(originalError) {
43191
+ super(originalError, "the extension is opening that tab now \u2014 retry in a few seconds. You do not need to open one yourself, and this is not a version problem.");
43192
+ this.name = "FetchproxyTabOpeningError";
43193
+ }
43194
+ };
43123
43195
  var SCOPE_REJECTION = /not in declared/;
43124
- var NO_TAB_REJECTION = /no tab matching (?!.*content script loaded)/;
43196
+ var NO_TAB_REJECTION = /no tab matching (?!.*(content script loaded|one is still opening))/;
43197
+ var TAB_OPENING_REJECTION = /no tab matching .*one is still opening/;
43125
43198
  function protocolErrorFrom(error61) {
43126
43199
  if (SCOPE_REJECTION.test(error61))
43127
43200
  return new FetchproxyScopeError(error61);
43201
+ if (TAB_OPENING_REJECTION.test(error61))
43202
+ return new FetchproxyTabOpeningError(error61);
43128
43203
  if (NO_TAB_REJECTION.test(error61))
43129
43204
  return new FetchproxyNoTabError(error61);
43130
43205
  return new FetchproxyProtocolError(error61);
@@ -43872,9 +43947,9 @@ var FetchproxyServer = class {
43872
43947
  if (opts.subdomain !== void 0)
43873
43948
  assertSubdomainLabel(opts.subdomain);
43874
43949
  const baseDomain = this.resolveBaseDomain(opts.domain);
43875
- const isAbsolute = path.startsWith("http://") || path.startsWith("https://");
43950
+ const isAbsolute2 = path.startsWith("http://") || path.startsWith("https://");
43876
43951
  let host;
43877
- if (isAbsolute) {
43952
+ if (isAbsolute2) {
43878
43953
  try {
43879
43954
  host = new URL(path).host;
43880
43955
  } catch {
@@ -43883,7 +43958,7 @@ var FetchproxyServer = class {
43883
43958
  } else {
43884
43959
  host = opts.subdomain ? `${opts.subdomain}.${baseDomain}` : baseDomain;
43885
43960
  }
43886
- const url2 = isAbsolute ? path : `https://${host}${path}`;
43961
+ const url2 = isAbsolute2 ? path : `https://${host}${path}`;
43887
43962
  assertUrlInDomains("request url", url2, this.opts.domains);
43888
43963
  let tabUrl = `https://${host}/`;
43889
43964
  if (opts.viaTab !== void 0) {
@@ -44991,6 +45066,8 @@ var CREDENTIAL_ARMS = /* @__PURE__ */ new Set([
44991
45066
  "ok",
44992
45067
  "no_credential",
44993
45068
  "credential_rejected",
45069
+ "session_expired",
45070
+ "verification_pending",
44994
45071
  "timeout",
44995
45072
  "http",
44996
45073
  "transport",
@@ -45007,6 +45084,10 @@ function credentialHint(arm, prefix, hostLabel, source) {
45007
45084
  return `No credential resolved. Nothing was available to authenticate with \u2014 sign in and reconnect the connector so ${prefix} receives a token, or set the documented environment variable.`;
45008
45085
  case "credential_rejected":
45009
45086
  return `${hostLabel} rejected the credential from '${source}'. It is present but no longer valid \u2014 most often expired or revoked upstream. Re-authenticate and reconnect; retrying will not fix it.`;
45087
+ case "session_expired":
45088
+ return `The credential from '${source}' is configured, but no session is live \u2014 ${hostLabel} served a sign-in page rather than the data. Sign in again; a cookie-session portal expires these on its own, so this recurs between uses.`;
45089
+ case "verification_pending":
45090
+ return `${hostLabel} is holding the sign-in on a second factor rather than refusing it. Supply the verification code the ACCOUNT HOLDER received \u2014 the credential itself is not the problem, so changing it will not help.`;
45010
45091
  case "timeout":
45011
45092
  return `The credential from '${source}' resolved, but ${hostLabel} did not answer in time. Usually transient \u2014 retry. If it persists, ${hostLabel} is slow or unreachable from here.`;
45012
45093
  case "http":
@@ -45017,105 +45098,161 @@ function credentialHint(arm, prefix, hostLabel, source) {
45017
45098
  return `Unexpected failure \u2014 see error.message.`;
45018
45099
  }
45019
45100
  }
45020
- function registerCredentialHealthcheckTool(args) {
45021
- const { server, prefix, hostLabel, probePath, resolveCredential, probeFn, classifyThrown, hints } = args;
45022
- const probeUrl = probePath ? `https://${hostLabel}${probePath}` : void 0;
45023
- server.registerTool(`${prefix}_healthcheck`, {
45024
- title: "Verify credentials and upstream reachability",
45025
- description: `Resolves the credential the way real tools do, then makes one authenticated request to ${hostLabel}. Reports which source supplied the credential, whether ${hostLabel} accepted it, the round-trip time, and a plain-English hint distinguishing 'no credential' from 'credential rejected' from 'a ${hostLabel}-side problem'. Call this when a real tool fails and you want to know which hop broke. Read-only; never returns the credential itself.`,
45026
- annotations: {
45027
- title: "Verify credentials and upstream reachability",
45028
- readOnlyHint: true,
45029
- idempotentHint: true,
45030
- openWorldHint: true
45101
+ function credentialHealthcheckDescription(hostLabel) {
45102
+ return `Resolves the credential the way real tools do, then makes one authenticated request to ${hostLabel}. Reports which source supplied the credential, whether ${hostLabel} accepted it, the round-trip time, and a plain-English hint distinguishing 'no credential' from 'credential rejected' from 'a ${hostLabel}-side problem'. Read-only; never returns the credential itself.`;
45103
+ }
45104
+ async function runCredentialHealthcheck(args) {
45105
+ const { prefix, hostLabel, probePath, resolveCredential, probeFn, classifyThrown, hints } = args;
45106
+ const probeUrl = probePath ? `https://${hostLabel}${probePath.startsWith("/") ? "" : "/"}${probePath}` : void 0;
45107
+ let probeStarted = 0;
45108
+ let state;
45109
+ try {
45110
+ state = await resolveCredential();
45111
+ } catch (e) {
45112
+ const classified = classifyThrown?.(e);
45113
+ const result2 = {
45114
+ ok: false,
45115
+ // Still false, and still no source: a classification explains WHY
45116
+ // nothing resolved, it does not invent a credential that did.
45117
+ credential: { source: null, resolved: false },
45118
+ // No `url`: nothing was probed, and naming one implies it was tried.
45119
+ probe: { elapsed_ms: 0 },
45120
+ error: {
45121
+ kind: classified?.kind ?? "no_credential",
45122
+ message: truncateErrorMessage(messageOf(e)),
45123
+ ...classified?.detail !== void 0 ? { detail: classified.detail } : {}
45124
+ },
45125
+ // The hint must follow the KIND beside it. Falling back to
45126
+ // `no_credential`'s copy under a classified kind would state a cause
45127
+ // the kind contradicts — the same disagreement this path exists to
45128
+ // remove. So: an inline hint wins; else the classified arm's own
45129
+ // copy (consumer override first); else, for a kind this module has
45130
+ // no copy for, the neutral `unknown` text rather than one that
45131
+ // asserts a cause; else the unclassified `no_credential` default.
45132
+ hint: classified?.hint ?? (isArm(classified?.kind) ? hints?.[classified.kind] ?? credentialHint(classified.kind, prefix, hostLabel, null) : classified !== void 0 ? credentialHint("unknown", prefix, hostLabel, null) : hints?.no_credential ?? credentialHint("no_credential", prefix, hostLabel, null))
45133
+ };
45134
+ return { content: [{ type: "text", text: JSON.stringify(result2, null, 2) }] };
45135
+ }
45136
+ const credential = {
45137
+ source: state.source,
45138
+ resolved: state.source !== null,
45139
+ ...state.detail !== void 0 ? { detail: state.detail } : {}
45140
+ };
45141
+ if (!credential.resolved) {
45142
+ const result2 = {
45143
+ ok: false,
45144
+ credential,
45145
+ probe: { elapsed_ms: 0 },
45146
+ error: { kind: "no_credential", message: "no credential source resolved" },
45147
+ hint: hints?.no_credential ?? credentialHint("no_credential", prefix, hostLabel, null)
45148
+ };
45149
+ return { content: [{ type: "text", text: JSON.stringify(result2, null, 2) }] };
45150
+ }
45151
+ let arm = "ok";
45152
+ let error61;
45153
+ let status;
45154
+ let customHint;
45155
+ probeStarted = Date.now();
45156
+ try {
45157
+ await probeFn();
45158
+ } catch (e) {
45159
+ status = statusOf(e);
45160
+ const aborted2 = e instanceof Error && e.name === "AbortError";
45161
+ arm = status === 401 || status === 403 ? "credential_rejected" : status !== void 0 ? "http" : aborted2 || /timeout|timed out|ETIMEDOUT/i.test(messageOf(e)) ? "timeout" : /fetch failed|ENOTFOUND|ECONNREFUSED|ECONNRESET|network/i.test(messageOf(e)) ? "transport" : "unknown";
45162
+ let kind = arm;
45163
+ let detail;
45164
+ const custom2 = classifyThrown?.(e);
45165
+ if (custom2) {
45166
+ kind = custom2.kind;
45167
+ customHint = custom2.hint;
45168
+ detail = custom2.detail;
45169
+ }
45170
+ error61 = {
45171
+ kind,
45172
+ // Redacted AND bounded before it reaches the result: an upstream
45173
+ // failure routinely quotes what it was sent, and a healthcheck is
45174
+ // the tool people paste into a chat when something is broken.
45175
+ message: truncateErrorMessage(messageOf(e)),
45176
+ ...detail !== void 0 ? { detail } : {}
45177
+ };
45178
+ }
45179
+ const result = {
45180
+ ok: error61 === void 0,
45181
+ credential,
45182
+ probe: {
45183
+ ...probeUrl ? { url: probeUrl } : {},
45184
+ elapsed_ms: Date.now() - probeStarted,
45185
+ ...status !== void 0 ? { status } : {}
45031
45186
  },
45032
- inputSchema: {}
45033
- }, async () => {
45034
- let probeStarted = 0;
45035
- let state;
45036
- try {
45037
- state = await resolveCredential();
45038
- } catch (e) {
45039
- const classified = classifyThrown?.(e);
45040
- const result2 = {
45041
- ok: false,
45042
- // Still false, and still no source: a classification explains WHY
45043
- // nothing resolved, it does not invent a credential that did.
45044
- credential: { source: null, resolved: false },
45045
- // No `url`: nothing was probed, and naming one implies it was tried.
45046
- probe: { elapsed_ms: 0 },
45047
- error: {
45048
- kind: classified?.kind ?? "no_credential",
45049
- message: truncateErrorMessage(messageOf(e)),
45050
- ...classified?.detail !== void 0 ? { detail: classified.detail } : {}
45051
- },
45052
- // The hint must follow the KIND beside it. Falling back to
45053
- // `no_credential`'s copy under a classified kind would state a cause
45054
- // the kind contradicts — the same disagreement this path exists to
45055
- // remove. So: an inline hint wins; else the classified arm's own
45056
- // copy (consumer override first); else, for a kind this module has
45057
- // no copy for, the neutral `unknown` text rather than one that
45058
- // asserts a cause; else the unclassified `no_credential` default.
45059
- hint: classified?.hint ?? (isArm(classified?.kind) ? hints?.[classified.kind] ?? credentialHint(classified.kind, prefix, hostLabel, null) : classified !== void 0 ? credentialHint("unknown", prefix, hostLabel, null) : hints?.no_credential ?? credentialHint("no_credential", prefix, hostLabel, null))
45187
+ ...error61 ? { error: error61 } : {},
45188
+ hint: customHint ?? hints?.[arm] ?? credentialHint(arm, prefix, hostLabel, state.source)
45189
+ };
45190
+ return { content: [{ type: "text", text: JSON.stringify(result, null, 2) }] };
45191
+ }
45192
+ var SessionNotLiveError = class extends Error {
45193
+ hostLabel;
45194
+ detail;
45195
+ constructor(hostLabel, detail) {
45196
+ super(`${hostLabel} served a sign-in page rather than the data (${detail}).`);
45197
+ this.hostLabel = hostLabel;
45198
+ this.detail = detail;
45199
+ this.name = "SessionNotLiveError";
45200
+ }
45201
+ };
45202
+ var ProbeHttpError = class extends Error {
45203
+ hostLabel;
45204
+ status;
45205
+ constructor(hostLabel, status) {
45206
+ super(`${hostLabel} answered ${status}.`);
45207
+ this.hostLabel = hostLabel;
45208
+ this.status = status;
45209
+ this.name = "ProbeHttpError";
45210
+ }
45211
+ };
45212
+ function sessionProbe(opts) {
45213
+ const host = opts.hostLabel ?? "the upstream";
45214
+ return async () => {
45215
+ const { status, body } = await opts.request();
45216
+ if (status >= 300 && status < 400) {
45217
+ throw new SessionNotLiveError(host, `redirected with ${status}`);
45218
+ }
45219
+ if (status < 200 || status >= 300)
45220
+ throw new ProbeHttpError(host, status);
45221
+ if (opts.signedOut(body))
45222
+ throw new SessionNotLiveError(host, "sign-in or verification page");
45223
+ return body;
45224
+ };
45225
+ }
45226
+ function sessionClassifier(opts) {
45227
+ const { hostLabel, hints } = opts;
45228
+ const remedies = opts.remedies ?? {};
45229
+ const call = (tool, fallback) => tool !== void 0 ? `Call ${tool}.` : fallback;
45230
+ return (err) => {
45231
+ if (err instanceof ProbeHttpError) {
45232
+ return {
45233
+ kind: "http",
45234
+ hint: hints?.http ?? `${hostLabel} answered ${err.status}. The credential was never judged \u2014 that is an upstream problem, so retry, and if it persists the far side is down.`
45060
45235
  };
45061
- return { content: [{ type: "text", text: JSON.stringify(result2, null, 2) }] };
45062
45236
  }
45063
- const credential = {
45064
- source: state.source,
45065
- resolved: state.source !== null,
45066
- ...state.detail !== void 0 ? { detail: state.detail } : {}
45067
- };
45068
- if (!credential.resolved) {
45069
- const result2 = {
45070
- ok: false,
45071
- credential,
45072
- probe: { elapsed_ms: 0 },
45073
- error: { kind: "no_credential", message: "no credential source resolved" },
45074
- hint: hints?.no_credential ?? credentialHint("no_credential", prefix, hostLabel, null)
45237
+ if (!(err instanceof SessionNotLiveError))
45238
+ return void 0;
45239
+ if (opts.credentialsRejected?.() === true) {
45240
+ return {
45241
+ kind: "credential_rejected",
45242
+ hint: hints?.credential_rejected ?? `${hostLabel} refused this username and password. Correct them, then sign in ` + `again. ${call(remedies.signIn, "")}`.trim() + " Nothing retries for you: repeated failures escalate to a captcha or a lockout."
45075
45243
  };
45076
- return { content: [{ type: "text", text: JSON.stringify(result2, null, 2) }] };
45077
45244
  }
45078
- let arm = "ok";
45079
- let error61;
45080
- let status;
45081
- let customHint;
45082
- probeStarted = Date.now();
45083
- try {
45084
- await probeFn();
45085
- } catch (e) {
45086
- status = statusOf(e);
45087
- const aborted2 = e instanceof Error && e.name === "AbortError";
45088
- arm = status === 401 || status === 403 ? "credential_rejected" : status !== void 0 ? "http" : aborted2 || /timeout|timed out|ETIMEDOUT/i.test(messageOf(e)) ? "timeout" : /fetch failed|ENOTFOUND|ECONNREFUSED|ECONNRESET|network/i.test(messageOf(e)) ? "transport" : "unknown";
45089
- let kind = arm;
45090
- let detail;
45091
- const custom2 = classifyThrown?.(e);
45092
- if (custom2) {
45093
- kind = custom2.kind;
45094
- customHint = custom2.hint;
45095
- detail = custom2.detail;
45096
- }
45097
- error61 = {
45098
- kind,
45099
- // Redacted AND bounded before it reaches the result: an upstream
45100
- // failure routinely quotes what it was sent, and a healthcheck is
45101
- // the tool people paste into a chat when something is broken.
45102
- message: truncateErrorMessage(messageOf(e)),
45103
- ...detail !== void 0 ? { detail } : {}
45245
+ if (opts.verificationPending?.() === true) {
45246
+ return {
45247
+ kind: "verification_pending",
45248
+ hint: hints?.verification_pending ?? `A verification code is outstanding, so ${hostLabel} is holding the sign-in rather than refusing it. ` + (remedies.sendCode !== void 0 && remedies.verifyCode !== void 0 ? `Call ${remedies.sendCode}, then pass the code the ACCOUNT HOLDER receives to ${remedies.verifyCode}.` : "Supply the verification code the ACCOUNT HOLDER received; the credential itself is not the problem, so changing it will not help.")
45104
45249
  };
45105
45250
  }
45106
- const result = {
45107
- ok: error61 === void 0,
45108
- credential,
45109
- probe: {
45110
- ...probeUrl ? { url: probeUrl } : {},
45111
- elapsed_ms: Date.now() - probeStarted,
45112
- ...status !== void 0 ? { status } : {}
45113
- },
45114
- ...error61 ? { error: error61 } : {},
45115
- hint: customHint ?? hints?.[arm] ?? credentialHint(arm, prefix, hostLabel, state.source)
45251
+ return {
45252
+ kind: "session_expired",
45253
+ hint: hints?.session_expired ?? `The credentials are configured but no session is live \u2014 ${hostLabel} sessions are short-lived, so this recurs between uses. ` + call(remedies.signIn, "Sign in again.") + " Expect a verification code, which goes to the account holder."
45116
45254
  };
45117
- return { content: [{ type: "text", text: JSON.stringify(result, null, 2) }] };
45118
- });
45255
+ };
45119
45256
  }
45120
45257
 
45121
45258
  // node_modules/@chrischall/mcp-utils/dist/fetchproxy/index.js
@@ -45152,9 +45289,9 @@ function createFetchproxyTransport(opts) {
45152
45289
  async start() {
45153
45290
  await server.listen();
45154
45291
  if (logListening) {
45155
- console.error(`[${serverOpts.serverName}:bridge] listening on 127.0.0.1:${server.bridgeHealth().port} (role=${server.role ?? "unknown"}, version=${serverOpts.version})`);
45292
+ console.error(`[${serverOpts.serverName}:bridge] ready \u2014 127.0.0.1:${server.bridgeHealth().port} binds on the first request (version=${serverOpts.version})`);
45156
45293
  } else if (debug) {
45157
- console.error(`[${serverOpts.serverName}:bridge] listening (role=${server.role ?? "unknown"}, version=${serverOpts.version})`);
45294
+ console.error(`[${serverOpts.serverName}:bridge] ready \u2014 binds on the first request (version=${serverOpts.version})`);
45158
45295
  }
45159
45296
  },
45160
45297
  async close() {
@@ -45246,123 +45383,131 @@ function projectBridgeStatus(health) {
45246
45383
  } : {}
45247
45384
  };
45248
45385
  }
45249
- function registerBridgeHealthcheckTool(args) {
45250
- const { server, prefix, probePath, hostLabel, probeFn, classifyThrown, hints, path } = args;
45251
- const probeUrl = `https://${hostLabel}${probePath}`;
45386
+ function probeDisplayUrl(hostLabel, probePath) {
45387
+ return `https://${hostLabel}${probePath.startsWith("/") ? "" : "/"}${probePath}`;
45388
+ }
45389
+ function bridgeHealthcheckDescription(hostLabel, probePath) {
45390
+ return `Round-trips a small public ${hostLabel} URL (${probePath}) through the fetchproxy bridge and returns diagnostics: the bridge's role (host/peer/null), port, version, the extension link (linked / pair pending / not attached / never answered), the elapsed round-trip time, and a plain-English hint distinguishing 'bridge never came up' from 'extension not connected' from 'real ${hostLabel}-side problem'. Read-only, no auth required.`;
45391
+ }
45392
+ async function runBridgeHealthcheck(args) {
45393
+ const { prefix, probePath, hostLabel, probeFn, classifyThrown, hints, path } = args;
45394
+ const probeUrl = probeDisplayUrl(hostLabel, probePath);
45252
45395
  const resolveTransport = () => typeof args.transport === "function" ? args.transport() : args.transport;
45396
+ let probeBody = "";
45397
+ let thrown;
45398
+ const wrappedProbe = async (p) => {
45399
+ try {
45400
+ probeBody = await probeFn(p);
45401
+ return probeBody;
45402
+ } catch (e) {
45403
+ thrown = e;
45404
+ throw e;
45405
+ }
45406
+ };
45407
+ let ok;
45408
+ let elapsedMs;
45409
+ let bridge2;
45410
+ let rawError;
45411
+ let pathNow;
45412
+ if (path) {
45413
+ const start = Date.now();
45414
+ try {
45415
+ await wrappedProbe(probePath);
45416
+ ok = true;
45417
+ } catch (e) {
45418
+ ok = false;
45419
+ rawError = { kind: classifyBridgeError(e), message: truncateErrorMessage(messageOf(e)) };
45420
+ }
45421
+ elapsedMs = Date.now() - start;
45422
+ pathNow = path();
45423
+ const transport2 = resolveTransport();
45424
+ bridge2 = transport2 ? projectBridgeStatus(transport2.status()) : void 0;
45425
+ } else {
45426
+ const transport2 = resolveTransport();
45427
+ if (!transport2) {
45428
+ throw new Error("bridge healthcheck: transport() returned nothing and no `path` was supplied \u2014 a bridge-only healthcheck needs its bridge.");
45429
+ }
45430
+ const probeResult = await transport2.runProbe(wrappedProbe, probePath);
45431
+ ok = probeResult.ok;
45432
+ elapsedMs = probeResult.elapsed_ms;
45433
+ bridge2 = {
45434
+ ...probeResult.bridge,
45435
+ last_extension_message_at: transport2.status().lastExtensionMessageAt
45436
+ };
45437
+ rawError = probeResult.error;
45438
+ }
45439
+ const probe = ok ? { url: probeUrl, elapsed_ms: elapsedMs, status: 200, body_length: probeBody.length } : { url: probeUrl, elapsed_ms: elapsedMs };
45440
+ let error61;
45441
+ let bridgeHint;
45442
+ let customHint;
45443
+ let customDetail;
45444
+ if (rawError) {
45445
+ let kind = rawError.kind === "other" ? "unknown" : rawError.kind;
45446
+ if (thrown instanceof FetchproxySessionNotReadyError) {
45447
+ kind = "session_not_ready";
45448
+ bridgeHint = thrown.hint;
45449
+ } else if (thrown instanceof FetchproxyBridgeDownError) {
45450
+ bridgeHint = thrown.hint;
45451
+ }
45452
+ if (thrown !== void 0 && classifyThrown) {
45453
+ const custom2 = classifyThrown(thrown);
45454
+ if (custom2) {
45455
+ kind = custom2.kind;
45456
+ customHint = custom2.hint;
45457
+ customDetail = custom2.detail;
45458
+ }
45459
+ }
45460
+ error61 = {
45461
+ kind,
45462
+ message: rawError.message,
45463
+ ...bridgeHint !== void 0 ? { bridge_hint: bridgeHint } : {},
45464
+ ...customDetail !== void 0 ? { detail: customDetail } : {}
45465
+ };
45466
+ }
45467
+ const direct = pathNow ? pathNow.transport === "direct" : bridge2 === void 0;
45468
+ const arm = ok ? "ok" : error61?.kind === "session_not_ready" ? "session_not_ready" : error61?.kind === "bridge_down" ? "bridge_down" : direct ? "direct" : bridge2 === void 0 || bridge2.role === null ? "no_role" : error61?.kind === "timeout" ? "timeout" : error61?.kind === "protocol" || error61?.kind === "http" ? "protocol" : "unknown";
45469
+ const defaultHint = healthcheckHint({
45470
+ ok,
45471
+ role: bridge2?.role ?? null,
45472
+ // The real configured port from bridgeHealth(), never a literal 37149.
45473
+ port: bridge2?.port ?? null,
45474
+ hostLabel,
45475
+ prefix,
45476
+ probePath,
45477
+ errorKind: error61?.kind,
45478
+ bridgeHint: error61?.kind === "bridge_down" ? bridgeHint : void 0,
45479
+ session: {
45480
+ ...bridge2?.session_state !== void 0 ? { state: bridge2.session_state } : {},
45481
+ pairCode: bridge2?.pending_pair_code ?? (thrown instanceof FetchproxySessionNotReadyError ? thrown.pairCode : null)
45482
+ },
45483
+ direct
45484
+ });
45485
+ const result = {
45486
+ ok,
45487
+ ...bridge2 ? { bridge: bridge2 } : {},
45488
+ ...pathNow ? { transport: pathNow } : {},
45489
+ probe,
45490
+ ...error61 ? { error: error61 } : {},
45491
+ // Precedence: classifyThrown's hint > per-arm override > default ladder.
45492
+ hint: customHint ?? hints?.[arm] ?? defaultHint
45493
+ };
45494
+ return {
45495
+ content: [{ type: "text", text: JSON.stringify(result, null, 2) }]
45496
+ };
45497
+ }
45498
+ function registerAdaptiveHealthcheckTool(args) {
45499
+ const { server, prefix, hostLabel, usingBridge, bridge: bridge2, credential } = args;
45253
45500
  server.registerTool(`${prefix}_healthcheck`, {
45254
- title: "Verify the fetchproxy bridge end-to-end",
45255
- description: `Round-trips a small public ${hostLabel} URL (${probePath}) through the fetchproxy bridge and returns diagnostics: the bridge's role (host/peer/null), port, version, the extension link (linked / pair pending / not attached / never answered), the elapsed round-trip time, and a plain-English hint distinguishing 'bridge never came up' from 'extension not connected' from 'real ${hostLabel}-side problem'. Call this when a real tool fails and you want to know which hop broke. Read-only, no auth required.`,
45501
+ title: "Verify this server can reach its upstream",
45502
+ description: `Reports which hop is broken when a real tool fails, for whichever path this server is actually using. Relaying through your signed-in browser tab: ${bridgeHealthcheckDescription(hostLabel, bridge2.probePath)} Signing in server-side with configured credentials: ${credentialHealthcheckDescription(hostLabel)}`,
45256
45503
  annotations: {
45257
- title: "Verify the fetchproxy bridge end-to-end",
45504
+ title: "Verify this server can reach its upstream",
45258
45505
  readOnlyHint: true,
45259
45506
  idempotentHint: true,
45260
45507
  openWorldHint: true
45261
45508
  },
45262
45509
  inputSchema: {}
45263
- }, async () => {
45264
- let probeBody = "";
45265
- let thrown;
45266
- const wrappedProbe = async (p) => {
45267
- try {
45268
- probeBody = await probeFn(p);
45269
- return probeBody;
45270
- } catch (e) {
45271
- thrown = e;
45272
- throw e;
45273
- }
45274
- };
45275
- let ok;
45276
- let elapsedMs;
45277
- let bridge2;
45278
- let rawError;
45279
- let pathNow;
45280
- if (path) {
45281
- const start = Date.now();
45282
- try {
45283
- await wrappedProbe(probePath);
45284
- ok = true;
45285
- } catch (e) {
45286
- ok = false;
45287
- rawError = { kind: classifyBridgeError(e), message: truncateErrorMessage(messageOf(e)) };
45288
- }
45289
- elapsedMs = Date.now() - start;
45290
- pathNow = path();
45291
- const transport2 = resolveTransport();
45292
- bridge2 = transport2 ? projectBridgeStatus(transport2.status()) : void 0;
45293
- } else {
45294
- const transport2 = resolveTransport();
45295
- if (!transport2) {
45296
- throw new Error("registerBridgeHealthcheckTool: transport() returned nothing and no `path` was supplied \u2014 a bridge-only healthcheck needs its bridge.");
45297
- }
45298
- const probeResult = await transport2.runProbe(wrappedProbe, probePath);
45299
- ok = probeResult.ok;
45300
- elapsedMs = probeResult.elapsed_ms;
45301
- bridge2 = {
45302
- ...probeResult.bridge,
45303
- last_extension_message_at: transport2.status().lastExtensionMessageAt
45304
- };
45305
- rawError = probeResult.error;
45306
- }
45307
- const probe = ok ? { url: probeUrl, elapsed_ms: elapsedMs, status: 200, body_length: probeBody.length } : { url: probeUrl, elapsed_ms: elapsedMs };
45308
- let error61;
45309
- let bridgeHint;
45310
- let customHint;
45311
- let customDetail;
45312
- if (rawError) {
45313
- let kind = rawError.kind === "other" ? "unknown" : rawError.kind;
45314
- if (thrown instanceof FetchproxySessionNotReadyError) {
45315
- kind = "session_not_ready";
45316
- bridgeHint = thrown.hint;
45317
- } else if (thrown instanceof FetchproxyBridgeDownError) {
45318
- bridgeHint = thrown.hint;
45319
- }
45320
- if (thrown !== void 0 && classifyThrown) {
45321
- const custom2 = classifyThrown(thrown);
45322
- if (custom2) {
45323
- kind = custom2.kind;
45324
- customHint = custom2.hint;
45325
- customDetail = custom2.detail;
45326
- }
45327
- }
45328
- error61 = {
45329
- kind,
45330
- message: rawError.message,
45331
- ...bridgeHint !== void 0 ? { bridge_hint: bridgeHint } : {},
45332
- ...customDetail !== void 0 ? { detail: customDetail } : {}
45333
- };
45334
- }
45335
- const direct = pathNow ? pathNow.transport === "direct" : bridge2 === void 0;
45336
- const arm = ok ? "ok" : error61?.kind === "session_not_ready" ? "session_not_ready" : error61?.kind === "bridge_down" ? "bridge_down" : direct ? "direct" : bridge2 === void 0 || bridge2.role === null ? "no_role" : error61?.kind === "timeout" ? "timeout" : error61?.kind === "protocol" || error61?.kind === "http" ? "protocol" : "unknown";
45337
- const defaultHint = healthcheckHint({
45338
- ok,
45339
- role: bridge2?.role ?? null,
45340
- // The real configured port from bridgeHealth(), never a literal 37149.
45341
- port: bridge2?.port ?? null,
45342
- hostLabel,
45343
- prefix,
45344
- probePath,
45345
- errorKind: error61?.kind,
45346
- bridgeHint: error61?.kind === "bridge_down" ? bridgeHint : void 0,
45347
- session: {
45348
- ...bridge2?.session_state !== void 0 ? { state: bridge2.session_state } : {},
45349
- pairCode: bridge2?.pending_pair_code ?? (thrown instanceof FetchproxySessionNotReadyError ? thrown.pairCode : null)
45350
- },
45351
- direct
45352
- });
45353
- const result = {
45354
- ok,
45355
- ...bridge2 ? { bridge: bridge2 } : {},
45356
- ...pathNow ? { transport: pathNow } : {},
45357
- probe,
45358
- ...error61 ? { error: error61 } : {},
45359
- // Precedence: classifyThrown's hint > per-arm override > default ladder.
45360
- hint: customHint ?? hints?.[arm] ?? defaultHint
45361
- };
45362
- return {
45363
- content: [{ type: "text", text: JSON.stringify(result, null, 2) }]
45364
- };
45365
- });
45510
+ }, async () => usingBridge() ? runBridgeHealthcheck({ server, prefix, hostLabel, ...bridge2 }) : runCredentialHealthcheck({ server, prefix, hostLabel, ...credential }));
45366
45511
  }
45367
45512
 
45368
45513
  // src/transport-fetchproxy.ts
@@ -45608,6 +45753,15 @@ function registerAccountTools(server, client2, patients2) {
45608
45753
 
45609
45754
  // src/tools/auth.ts
45610
45755
  function registerAuthTools(server, auth2) {
45756
+ const configured = () => {
45757
+ if (auth2 !== void 0) return auth2;
45758
+ throw new McpToolError(
45759
+ "This server is relaying through your signed-in my.atriumhealth.org browser tab, so there is nothing here to sign in to.",
45760
+ {
45761
+ hint: "Sign in to my.atriumhealth.org in the browser instead. To have this server sign in on its own \u2014 no tab, no extension \u2014 set MAH_USERNAME and MAH_PASSWORD and restart it; verification stays with the account holder either way."
45762
+ }
45763
+ );
45764
+ };
45611
45765
  server.registerTool(
45612
45766
  "mah_auth_status",
45613
45767
  {
@@ -45616,6 +45770,14 @@ function registerAuthTools(server, auth2) {
45616
45770
  inputSchema: {}
45617
45771
  },
45618
45772
  async () => {
45773
+ if (auth2 === void 0) {
45774
+ return minifiedResult({
45775
+ sessionResumable: false,
45776
+ verificationPending: false,
45777
+ trustedDeviceStored: false,
45778
+ nextStep: "This server holds no credentials and signs in to nothing \u2014 requests relay through your signed-in my.atriumhealth.org tab. Run mah_healthcheck to check that path."
45779
+ });
45780
+ }
45619
45781
  const hasDevice = auth2.deviceId() !== void 0;
45620
45782
  const resumable = await auth2.isSignedIn();
45621
45783
  return minifiedResult({
@@ -45635,7 +45797,7 @@ function registerAuthTools(server, auth2) {
45635
45797
  },
45636
45798
  async () => {
45637
45799
  try {
45638
- await auth2.login();
45800
+ await configured().login();
45639
45801
  return minifiedResult({ signedIn: true });
45640
45802
  } catch (e) {
45641
45803
  if (e instanceof MfaRequiredError) {
@@ -45663,7 +45825,7 @@ function registerAuthTools(server, auth2) {
45663
45825
  }
45664
45826
  },
45665
45827
  async ({ channel, resend }) => {
45666
- await auth2.sendCode(channel, resend);
45828
+ await configured().sendCode(channel, resend);
45667
45829
  return minifiedResult({
45668
45830
  sent: true,
45669
45831
  channel,
@@ -45684,7 +45846,7 @@ function registerAuthTools(server, auth2) {
45684
45846
  }
45685
45847
  },
45686
45848
  async ({ code, rememberDevice }) => {
45687
- const r = await auth2.verifyCode(code, rememberDevice);
45849
+ const r = await configured().verifyCode(code, rememberDevice);
45688
45850
  return minifiedResult({
45689
45851
  verified: true,
45690
45852
  trustedDeviceStored: r.remembered,
@@ -49519,6 +49681,30 @@ var kElementsClosedByOpening = {
49519
49681
  H6: {
49520
49682
  h6: true,
49521
49683
  H6: true
49684
+ },
49685
+ dt: {
49686
+ dt: true,
49687
+ dd: true,
49688
+ DT: true,
49689
+ DD: true
49690
+ },
49691
+ DT: {
49692
+ dt: true,
49693
+ dd: true,
49694
+ DT: true,
49695
+ DD: true
49696
+ },
49697
+ dd: {
49698
+ dt: true,
49699
+ dd: true,
49700
+ DT: true,
49701
+ DD: true
49702
+ },
49703
+ DD: {
49704
+ dt: true,
49705
+ dd: true,
49706
+ DT: true,
49707
+ DD: true
49522
49708
  }
49523
49709
  };
49524
49710
  var kElementsClosedByClosing = {
@@ -49589,6 +49775,38 @@ var kElementsClosedByClosing = {
49589
49775
  table: true,
49590
49776
  TR: true,
49591
49777
  TABLE: true
49778
+ },
49779
+ dt: {
49780
+ dl: true,
49781
+ body: true,
49782
+ html: true,
49783
+ DL: true,
49784
+ BODY: true,
49785
+ HTML: true
49786
+ },
49787
+ DT: {
49788
+ dl: true,
49789
+ body: true,
49790
+ html: true,
49791
+ DL: true,
49792
+ BODY: true,
49793
+ HTML: true
49794
+ },
49795
+ dd: {
49796
+ dl: true,
49797
+ body: true,
49798
+ html: true,
49799
+ DL: true,
49800
+ BODY: true,
49801
+ HTML: true
49802
+ },
49803
+ DD: {
49804
+ dl: true,
49805
+ body: true,
49806
+ html: true,
49807
+ DL: true,
49808
+ BODY: true,
49809
+ HTML: true
49592
49810
  }
49593
49811
  };
49594
49812
  var kElementsClosedByClosingExcept = { p: {
@@ -49869,46 +50087,79 @@ function registerBillingTools(server, client2, patients2) {
49869
50087
  }
49870
50088
 
49871
50089
  // src/tools/healthcheck.ts
49872
- function registerHealthcheckTools(server, client2, transport2) {
49873
- registerBridgeHealthcheckTool({
49874
- server,
49875
- prefix: "mah",
49876
- // The app root renders for a signed-in user and redirects to the login
49877
- // page otherwise, so this probe distinguishes "bridge down" from
49878
- // "signed out" — the two failures users actually hit.
49879
- probePath: "Home",
49880
- hostLabel: "my.atriumhealth.org",
49881
- transport: transport2,
49882
- probeFn: (path) => client2.page(path)
49883
- });
49884
- }
49885
- function registerBridgelessHealthcheckTools(server, auth2) {
49886
- registerCredentialHealthcheckTool({
50090
+ var HOST = "my.atriumhealth.org";
50091
+ function registerMahHealthcheckTool(server, client2, arms) {
50092
+ const { auth: auth2, bridge: bridge2 } = arms;
50093
+ registerAdaptiveHealthcheckTool({
49887
50094
  server,
49888
50095
  prefix: "mah",
49889
- hostLabel: "my.atriumhealth.org",
49890
- // Leading slash + app root: this string is only for display, and the
49891
- // factory concatenates it onto hostLabel — without them it renders as
49892
- // 'my.atriumhealth.orgHome', which reads like a broken URL in a bug report.
49893
- probePath: "/myatriumhealth/Home",
49894
- // Report the SOURCE and non-secret facts only — never the password, the
49895
- // device token, or any cookie. This is the output people paste into a chat
49896
- // when something is broken.
49897
- resolveCredential: async () => {
49898
- const resumable = await auth2.isSignedIn();
49899
- const detail = {
49900
- sessionResumable: resumable,
49901
- trustedDeviceStored: auth2.deviceId() !== void 0,
49902
- verificationPending: auth2.mfaPending
49903
- };
49904
- return { source: "env", detail };
50096
+ hostLabel: HOST,
50097
+ usingBridge: () => bridge2 !== void 0,
50098
+ bridge: {
50099
+ // The app root renders for a signed-in user and redirects to the login
50100
+ // page otherwise, so this probe distinguishes "bridge down" from
50101
+ // "signed out" — the two failures users actually hit.
50102
+ probePath: "Home",
50103
+ // Only consulted on the bridge arm, which only runs when `bridge` is set.
50104
+ transport: () => bridge2,
50105
+ probeFn: (path) => client2.page(path)
49905
50106
  },
49906
- // Deliberately NOT routed through the client/transport: that would run
49907
- // ensureSession(), so a healthcheck on a signed-out session would silently
49908
- // submit credentials. A healthcheck must observe state, never change it —
49909
- // and on an account whose login controller can switch on a captcha, a
49910
- // diagnostic that logs in is actively harmful.
49911
- probeFn: () => auth2.request("Home")
50107
+ credential: {
50108
+ // Leading slash + app root: this string is only for display, and the
50109
+ // factory concatenates it onto hostLabel.
50110
+ probePath: "/myatriumhealth/Home",
50111
+ // Report the SOURCE and non-secret facts only — never the password, the
50112
+ // device token, or any cookie. This is the output people paste into a chat
50113
+ // when something is broken.
50114
+ resolveCredential: async () => {
50115
+ if (auth2 === void 0) return { source: null };
50116
+ const resumable = await auth2.isSignedIn();
50117
+ const detail = {
50118
+ sessionResumable: resumable,
50119
+ trustedDeviceStored: auth2.deviceId() !== void 0,
50120
+ verificationPending: auth2.mfaPending
50121
+ };
50122
+ return { source: "env", detail };
50123
+ },
50124
+ // The status/redirect/auth-wall ladder and the three signed-out arms now
50125
+ // live in mcp-utils. What stays here is the part a library cannot write.
50126
+ probeFn: sessionProbe({
50127
+ // Deliberately `auth.request`, NOT the client or transport: those run
50128
+ // ensureSession(), so a healthcheck on a signed-out session would
50129
+ // silently submit credentials. A diagnostic must observe state, never
50130
+ // change it — and on an account whose login controller can switch on a
50131
+ // captcha, one that logs in is actively harmful.
50132
+ request: async () => {
50133
+ const { res, body } = await auth2.request("Home");
50134
+ return { status: res.status, body };
50135
+ },
50136
+ // THE site-specific closure, and the readers' own test rather than a
50137
+ // second copy that could drift: it is title-scoped because every
50138
+ // signed-in page links to two-factor setup, so a body-wide match
50139
+ // reports "signed out" for every request.
50140
+ signedOut: isAuthWall,
50141
+ hostLabel: HOST
50142
+ }),
50143
+ classifyThrown: sessionClassifier({
50144
+ hostLabel: HOST,
50145
+ // Named, never derived. The library used to build these from the tool
50146
+ // prefix, which happened to be right here and wrong everywhere else —
50147
+ // simplepractice signs in with simplepractice_request_sign_in_link,
50148
+ // kiaaccess with kia_start_login.
50149
+ remedies: {
50150
+ signIn: "mah_sign_in",
50151
+ sendCode: "mah_send_verification_code",
50152
+ verifyCode: "mah_verify_code"
50153
+ },
50154
+ verificationPending: () => auth2?.mfaPending === true,
50155
+ credentialsRejected: () => auth2?.credentialsRejected === true,
50156
+ hints: {
50157
+ // The one default worth overriding: naming the two variables beats
50158
+ // "correct them", and this portal's escalation is worth stating.
50159
+ credential_rejected: `${HOST} refused this username and password. Check MAH_USERNAME and MAH_PASSWORD, then call mah_sign_in \u2014 nothing retries for you, because the portal counts failed sign-ins and escalates to a captcha.`
50160
+ }
50161
+ })
50162
+ }
49912
50163
  });
49913
50164
  }
49914
50165
 
@@ -50211,7 +50462,7 @@ function registerVisitTools(server, client2, patients2) {
50211
50462
  }
50212
50463
 
50213
50464
  // src/version.ts
50214
- var VERSION = "0.3.0";
50465
+ var VERSION = "0.4.0";
50215
50466
 
50216
50467
  // src/index.ts
50217
50468
  var dotenvCandidates = [
@@ -50235,7 +50486,7 @@ if (!bridgeless && (username !== void 0 || password !== void 0)) {
50235
50486
  );
50236
50487
  } else if (!bridgeless) {
50237
50488
  console.error(
50238
- "[myatriumhealth-mcp] No MAH_USERNAME / MAH_PASSWORD found, so the browser bridge is starting. Set both (env, or a .env beside the server / MAH_DOTENV) for bridge-less sign-in, which binds no port and needs no tab."
50489
+ "[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."
50239
50490
  );
50240
50491
  }
50241
50492
  var port = readPortEnv("MAH_WS_PORT", DEFAULT_PORT);
@@ -50281,10 +50532,10 @@ await runMcp({
50281
50532
  (server) => registerAccountTools(server, client, patients),
50282
50533
  (server) => registerBillingTools(server, client, patients),
50283
50534
  (server) => registerPatientTools(server, client, patients),
50284
- ...auth !== void 0 ? [
50285
- (server) => registerAuthTools(server, auth),
50286
- (server) => registerBridgelessHealthcheckTools(server, auth)
50287
- ] : [],
50288
- ...bridge !== void 0 ? [(server) => registerHealthcheckTools(server, client, bridge)] : []
50535
+ // Unconditional, in both modes. What varies is what they DO, not whether
50536
+ // they exist — see registerAuthTools for why a tool surface that follows
50537
+ // the environment is a bug rather than a tidiness.
50538
+ (server) => registerAuthTools(server, auth),
50539
+ (server) => registerMahHealthcheckTool(server, client, { auth, bridge })
50289
50540
  ]
50290
50541
  });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@chrischall/myatriumhealth-mcp",
3
- "version": "0.3.0",
3
+ "version": "0.4.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,8 +42,8 @@
42
42
  "access": "public"
43
43
  },
44
44
  "dependencies": {
45
- "@chrischall/mcp-utils": "^0.23.1",
46
- "@fetchproxy/server": "^2.1.0",
45
+ "@chrischall/mcp-utils": "^0.26.0",
46
+ "@fetchproxy/server": "^2.9.0",
47
47
  "@modelcontextprotocol/sdk": "^1.22.0",
48
48
  "dotenv": "^17.4.2",
49
49
  "node-html-parser": "^9.0.3",
@@ -51,10 +51,10 @@
51
51
  },
52
52
  "devDependencies": {
53
53
  "@types/node": "^26.0.0",
54
- "@vitest/coverage-v8": "^4.1.7",
54
+ "@vitest/coverage-v8": "^5.0.0",
55
55
  "esbuild": "^0.28.1",
56
56
  "tsx": "^4.19.0",
57
57
  "typescript": "^7.0.2",
58
- "vitest": "^4.1.7"
58
+ "vitest": "^5.0.0"
59
59
  }
60
60
  }
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": "0.3.0",
9
+ "version": "0.4.0",
10
10
  "packages": [
11
11
  {
12
12
  "registryType": "npm",
13
13
  "identifier": "@chrischall/myatriumhealth-mcp",
14
- "version": "0.3.0",
14
+ "version": "0.4.0",
15
15
  "transport": {
16
16
  "type": "stdio"
17
17
  },