simplepractice-mcp 0.2.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.
package/dist/bundle.js CHANGED
@@ -2985,7 +2985,7 @@ var require_compile = __commonJS({
2985
2985
  const schOrFunc = root.refs[ref];
2986
2986
  if (schOrFunc)
2987
2987
  return schOrFunc;
2988
- let _sch = resolve2.call(this, root, ref);
2988
+ let _sch = resolve3.call(this, root, ref);
2989
2989
  if (_sch === void 0) {
2990
2990
  const schema = (_a3 = root.localRefs) === null || _a3 === void 0 ? void 0 : _a3[ref];
2991
2991
  const { schemaId } = this.opts;
@@ -3012,7 +3012,7 @@ var require_compile = __commonJS({
3012
3012
  function sameSchemaEnv(s1, s2) {
3013
3013
  return s1.schema === s2.schema && s1.root === s2.root && s1.baseId === s2.baseId;
3014
3014
  }
3015
- function resolve2(root, ref) {
3015
+ function resolve3(root, ref) {
3016
3016
  let sch;
3017
3017
  while (typeof (sch = this.refs[ref]) == "string")
3018
3018
  ref = sch;
@@ -3837,7 +3837,7 @@ var require_fast_uri = __commonJS({
3837
3837
  }
3838
3838
  return uri;
3839
3839
  }
3840
- function resolve2(baseURI, relativeURI, options) {
3840
+ function resolve3(baseURI, relativeURI, options) {
3841
3841
  const schemelessOptions = options ? Object.assign({ scheme: "null" }, options) : { scheme: "null" };
3842
3842
  const {
3843
3843
  parsed: baseParsed,
@@ -4199,7 +4199,7 @@ var require_fast_uri = __commonJS({
4199
4199
  var fastUri = {
4200
4200
  SCHEMES,
4201
4201
  normalize,
4202
- resolve: resolve2,
4202
+ resolve: resolve3,
4203
4203
  resolveComponent,
4204
4204
  equal,
4205
4205
  serialize,
@@ -29146,7 +29146,7 @@ var Protocol = class {
29146
29146
  return;
29147
29147
  }
29148
29148
  const pollInterval = task2.pollInterval ?? this._options?.defaultTaskPollInterval ?? 1e3;
29149
- await new Promise((resolve2) => setTimeout(resolve2, pollInterval));
29149
+ await new Promise((resolve3) => setTimeout(resolve3, pollInterval));
29150
29150
  options?.signal?.throwIfAborted();
29151
29151
  }
29152
29152
  } catch (error51) {
@@ -29163,7 +29163,7 @@ var Protocol = class {
29163
29163
  */
29164
29164
  request(request, resultSchema, options) {
29165
29165
  const { relatedRequestId, resumptionToken, onresumptiontoken, task, relatedTask } = options ?? {};
29166
- return new Promise((resolve2, reject) => {
29166
+ return new Promise((resolve3, reject) => {
29167
29167
  const earlyReject = (error51) => {
29168
29168
  reject(error51);
29169
29169
  };
@@ -29241,7 +29241,7 @@ var Protocol = class {
29241
29241
  if (!parseResult.success) {
29242
29242
  reject(parseResult.error);
29243
29243
  } else {
29244
- resolve2(parseResult.data);
29244
+ resolve3(parseResult.data);
29245
29245
  }
29246
29246
  } catch (error51) {
29247
29247
  reject(error51);
@@ -29502,12 +29502,12 @@ var Protocol = class {
29502
29502
  }
29503
29503
  } catch {
29504
29504
  }
29505
- return new Promise((resolve2, reject) => {
29505
+ return new Promise((resolve3, reject) => {
29506
29506
  if (signal.aborted) {
29507
29507
  reject(new McpError(ErrorCode.InvalidRequest, "Request cancelled"));
29508
29508
  return;
29509
29509
  }
29510
- const timeoutId = setTimeout(resolve2, interval);
29510
+ const timeoutId = setTimeout(resolve3, interval);
29511
29511
  signal.addEventListener("abort", () => {
29512
29512
  clearTimeout(timeoutId);
29513
29513
  reject(new McpError(ErrorCode.InvalidRequest, "Request cancelled"));
@@ -30598,7 +30598,7 @@ var McpServer = class {
30598
30598
  let task = createTaskResult.task;
30599
30599
  const pollInterval = task.pollInterval ?? 5e3;
30600
30600
  while (task.status !== "completed" && task.status !== "failed" && task.status !== "cancelled") {
30601
- await new Promise((resolve2) => setTimeout(resolve2, pollInterval));
30601
+ await new Promise((resolve3) => setTimeout(resolve3, pollInterval));
30602
30602
  const updatedTask = await extra.taskStore.getTask(taskId);
30603
30603
  if (!updatedTask) {
30604
30604
  throw new McpError(ErrorCode.InternalError, `Task ${taskId} not found during polling`);
@@ -31262,12 +31262,12 @@ var StdioServerTransport = class {
31262
31262
  this.onclose?.();
31263
31263
  }
31264
31264
  send(message) {
31265
- return new Promise((resolve2) => {
31265
+ return new Promise((resolve3) => {
31266
31266
  const json2 = serializeMessage(message);
31267
31267
  if (this._stdout.write(json2)) {
31268
- resolve2();
31268
+ resolve3();
31269
31269
  } else {
31270
- this._stdout.once("drain", resolve2);
31270
+ this._stdout.once("drain", resolve3);
31271
31271
  }
31272
31272
  });
31273
31273
  }
@@ -31326,12 +31326,82 @@ function messageOf(err) {
31326
31326
  return String(err);
31327
31327
  }
31328
31328
 
31329
- // node_modules/@chrischall/mcp-utils/dist/response/index.js
31330
- function textResult(data) {
31331
- return {
31332
- content: [{ type: "text", text: JSON.stringify(data, null, 2) }]
31333
- };
31329
+ // node_modules/@chrischall/mcp-utils/dist/response/view.js
31330
+ var VIEWS = ["compact", "full", "raw"];
31331
+ var DEFAULT_VIEW = "compact";
31332
+ var BLURB = {
31333
+ compact: '"compact" (default) drops fields the response already carries elsewhere',
31334
+ full: '"full" returns every field this server understands',
31335
+ raw: '"raw" returns the upstream payload unprojected'
31336
+ };
31337
+ function viewParam(honoured, opts = {}) {
31338
+ if (honoured.length < 2) {
31339
+ throw new Error("viewParam needs at least two rungs: a parameter offering one value decides nothing");
31340
+ }
31341
+ if (!honoured.includes("compact")) {
31342
+ throw new Error('viewParam must offer "compact": a tool with no cheap rung has nothing to default to');
31343
+ }
31344
+ const ordered = VIEWS.filter((v) => honoured.includes(v));
31345
+ const sentence = `Response shape: ${ordered.map((v) => BLURB[v]).join("; ")}.`;
31346
+ return external_exports.enum(Object.fromEntries(ordered.map((v) => [v, v]))).optional().describe(opts.note ? `${sentence} ${opts.note}` : sentence);
31347
+ }
31348
+ function resolveView(value, honoured) {
31349
+ return value !== void 0 && honoured.includes(value) ? value : DEFAULT_VIEW;
31350
+ }
31351
+ function minifiedResult(data) {
31352
+ return { content: [{ type: "text", text: JSON.stringify(data) }] };
31353
+ }
31354
+
31355
+ // node_modules/@chrischall/mcp-utils/dist/response/media.js
31356
+ var MEDIA_NOUN = "(?:avatar|picture|photo|thumbnail|thumb|image|icon|banner|profile_pic(?:ture)?|logo)";
31357
+ var MEDIA_QUALIFIER = "(?:primary|secondary|main|default|cover|hero|profile|master|rendered|small|medium|large|full|original|tall)";
31358
+ var MEDIA_KEY = new RegExp(`^(?:(?:${MEDIA_QUALIFIER}|${MEDIA_NOUN})[_-]?)?${MEDIA_NOUN}s?(?:[_-]?(?:link|uri|url|src)s?)?$`, "i");
31359
+ var MEDIA_URL = /^https?:\/\/[^\s]+?\.(png|jpe?g|gif|webp|svg|avif|bmp|ico)([?#]|$)/i;
31360
+ function stripMediaUrls(value, opts = {}) {
31361
+ const keep = normalizeRules(opts.keep ?? []);
31362
+ const drop = normalizeRules(opts.drop ?? []);
31363
+ return walk(value, keep, drop);
31364
+ }
31365
+ function normalizeRules(rules) {
31366
+ return rules.map((rule) => typeof rule === "string" ? rule.toLowerCase() : new RegExp(rule.source, rule.flags));
31367
+ }
31368
+ function matchesRule(key, rules) {
31369
+ const lower = key.toLowerCase();
31370
+ for (const rule of rules) {
31371
+ if (typeof rule === "string") {
31372
+ if (rule === lower)
31373
+ return true;
31374
+ continue;
31375
+ }
31376
+ rule.lastIndex = 0;
31377
+ if (rule.test(key))
31378
+ return true;
31379
+ }
31380
+ return false;
31334
31381
  }
31382
+ function walk(value, keep, drop) {
31383
+ if (Array.isArray(value))
31384
+ return value.map((v) => walk(v, keep, drop));
31385
+ if (value === null || typeof value !== "object")
31386
+ return value;
31387
+ if (Object.getPrototypeOf(value) !== Object.prototype && Object.getPrototypeOf(value) !== null)
31388
+ return value;
31389
+ const out = {};
31390
+ for (const [key, v] of Object.entries(value)) {
31391
+ if (matchesRule(key, keep)) {
31392
+ out[key] = v;
31393
+ continue;
31394
+ }
31395
+ if (MEDIA_KEY.test(key) || matchesRule(key, drop))
31396
+ continue;
31397
+ if (typeof v === "string" && MEDIA_URL.test(v))
31398
+ continue;
31399
+ out[key] = walk(v, keep, drop);
31400
+ }
31401
+ return out;
31402
+ }
31403
+
31404
+ // node_modules/@chrischall/mcp-utils/dist/response/index.js
31335
31405
  function errorResult(message) {
31336
31406
  return {
31337
31407
  content: [{ type: "text", text: redactSecrets(message) }],
@@ -31530,11 +31600,11 @@ function toolAnnotations(opts = {}) {
31530
31600
  }
31531
31601
 
31532
31602
  // src/version.ts
31533
- var VERSION = "0.2.0";
31603
+ var VERSION = "0.4.0";
31534
31604
 
31535
31605
  // node_modules/@chrischall/mcp-utils/dist/session/index.js
31536
- import { existsSync, readFileSync, writeFileSync, mkdirSync, chmodSync, renameSync } from "node:fs";
31537
- import { dirname } from "node:path";
31606
+ import { existsSync, readFileSync, writeFileSync, mkdirSync, chmodSync, renameSync, unlinkSync } from "node:fs";
31607
+ import { dirname, join as join2, resolve as resolve2 } from "node:path";
31538
31608
  function normalizeOrigin(input) {
31539
31609
  try {
31540
31610
  return new URL(input).origin.replace(/\/$/, "");
@@ -31670,7 +31740,7 @@ var TOKEN_REFRESH_SKEW_MS = 5 * 60 * 1e3;
31670
31740
 
31671
31741
  // src/config.ts
31672
31742
  import { homedir as homedir2 } from "node:os";
31673
- import { join as join2 } from "node:path";
31743
+ import { join as join3 } from "node:path";
31674
31744
  var API_VERSION = "2026-05-25";
31675
31745
  var APPLICATION_BUILD_VERSION = "0.0.0";
31676
31746
  var APPLICATION_PLATFORM = "web";
@@ -31688,13 +31758,21 @@ function resolvePortalHost(raw) {
31688
31758
  if (!/^[a-z0-9][a-z0-9-]*$/.test(label)) return null;
31689
31759
  return value;
31690
31760
  }
31761
+ function practiceHostFromLink(raw) {
31762
+ if (!raw) return null;
31763
+ const hash2 = raw.indexOf("#");
31764
+ if (hash2 < 0) return null;
31765
+ const prefix = raw.slice(0, hash2).trim();
31766
+ if (!prefix.includes(".")) return null;
31767
+ return resolvePortalHost(prefix);
31768
+ }
31691
31769
  function readPortalHost() {
31692
31770
  return resolvePortalHost(readEnvVar("SIMPLEPRACTICE_PRACTICE"));
31693
31771
  }
31694
31772
  function sessionFilePath() {
31695
31773
  const override = readEnvVar("SIMPLEPRACTICE_SESSION_FILE");
31696
31774
  if (override) return expandPath(override);
31697
- return join2(homedir2(), ".simplepractice-mcp", "session.json");
31775
+ return join3(homedir2(), ".simplepractice-mcp", "session.json");
31698
31776
  }
31699
31777
 
31700
31778
  // src/jsonapi.ts
@@ -31768,36 +31846,139 @@ function buildQuery(params) {
31768
31846
  }
31769
31847
  var SimplePracticeClient = class {
31770
31848
  store;
31771
- configError;
31772
- host;
31773
31849
  fetchImpl;
31850
+ /** A practice learned at runtime — from a sign-in link, or named on a tool call. */
31851
+ adoptedHost = null;
31774
31852
  constructor(opts = {}) {
31775
31853
  this.fetchImpl = opts.fetchImpl ?? globalThis.fetch;
31776
- const host = readPortalHost();
31777
- this.configError = host ? null : new McpToolError(
31778
- "SIMPLEPRACTICE_PRACTICE is not set, or is not a valid Client Portal address.",
31779
- {
31780
- hint: `Set SIMPLEPRACTICE_PRACTICE to your practice's portal address \u2014 either the slug ("achievebalancetherapy") or the full host ("achievebalancetherapy.clientsecure.me"). It is the host in the portal link your provider emailed you.`
31781
- }
31782
- );
31783
- this.host = host ?? "";
31784
31854
  this.store = opts.store ?? new SessionStore({
31785
31855
  filePath: sessionFilePath(),
31786
31856
  keyOf: (session) => session.host,
31787
31857
  normalizeKey: (key) => key.toLowerCase()
31788
31858
  });
31789
31859
  }
31790
- /** Throws the deferred configuration error, if there is one. */
31860
+ /**
31861
+ * Which practice this server is talking to, and how it found out.
31862
+ *
31863
+ * Resolved per call rather than fixed at construction, because the practice
31864
+ * is usually not known when the process starts: it arrives with the sign-in
31865
+ * link. In order:
31866
+ *
31867
+ * 1. **link** — adopted at runtime from the emailed link (or named on the
31868
+ * tool call). The most recent explicit statement of intent, and the only
31869
+ * one that can be right when a token is minted for a different practice
31870
+ * than the environment names.
31871
+ * 2. **environment** — `SIMPLEPRACTICE_PRACTICE`, an explicit pin for
31872
+ * someone who wants this server bound to one practice.
31873
+ * 3. **session** — the practice of the most recent sign-in. This is what
31874
+ * makes the link route survive a restart: sign in once, and every later
31875
+ * process knows the practice with no configuration at all.
31876
+ */
31877
+ resolveHost() {
31878
+ if (this.adoptedHost) return { host: this.adoptedHost, source: "link" };
31879
+ const configured = readPortalHost();
31880
+ if (configured) return { host: configured, source: "environment" };
31881
+ const remembered = this.mostRecentSessionHost();
31882
+ return remembered ? { host: remembered, source: "session" } : null;
31883
+ }
31884
+ /**
31885
+ * The practice signed into most recently, by our own `createdAt` rather than
31886
+ * `SessionStore`'s active pointer.
31887
+ *
31888
+ * The two agree right up until a practice is signed into twice, and then
31889
+ * they disagree across a restart: `add()` on an existing key leaves the Map
31890
+ * entry in its ORIGINAL insertion position, so the in-memory pointer names
31891
+ * the practice just added, while a fresh process restores the pointer as the
31892
+ * LAST key on disk. Signing in to A, then B, then A again would leave the
31893
+ * next process quietly talking to B.
31894
+ *
31895
+ * `createdAt` is the fact this fallback actually means, and unlike the
31896
+ * pointer it survives the restart.
31897
+ */
31898
+ mostRecentSessionHost() {
31899
+ let newest = null;
31900
+ for (const session of this.store.list()) {
31901
+ if (!newest || session.createdAt > newest.createdAt) newest = session;
31902
+ }
31903
+ return newest?.host ?? null;
31904
+ }
31905
+ /** The practice host, or `null` when none is known yet. Never throws. */
31906
+ knownPortalHost() {
31907
+ return this.resolveHost()?.host ?? null;
31908
+ }
31909
+ /** How the practice was determined, or `null` when it has not been. */
31910
+ practiceSource() {
31911
+ return this.resolveHost()?.source ?? null;
31912
+ }
31913
+ /**
31914
+ * The host a practice address names, WITHOUT adopting it.
31915
+ *
31916
+ * Validated through the same `resolvePortalHost` the environment goes
31917
+ * through, so a link outside `*.clientsecure.me` cannot redirect a token.
31918
+ *
31919
+ * Separate from {@link adoptPracticeHost} so a caller that only wants to
31920
+ * *name* the practice — a dry run reporting what it would do — can do that
31921
+ * without the side effect. Answering a question should not move the server.
31922
+ */
31923
+ validatePracticeHost(raw) {
31924
+ const host = resolvePortalHost(raw);
31925
+ if (!host) {
31926
+ throw new McpToolError(`"${raw}" is not a SimplePractice Client Portal address.`, {
31927
+ hint: 'A portal address is a single practice under clientsecure.me \u2014 the slug ("achievebalancetherapy") or the whole host ("achievebalancetherapy.clientsecure.me").'
31928
+ });
31929
+ }
31930
+ return host;
31931
+ }
31932
+ /**
31933
+ * Point this server at a practice for the rest of the process — what the
31934
+ * sign-in link's own host feeds.
31935
+ */
31936
+ adoptPracticeHost(raw) {
31937
+ this.adoptedHost = this.validatePracticeHost(raw);
31938
+ return this.adoptedHost;
31939
+ }
31940
+ /**
31941
+ * Adopt `raw`'s practice for the duration of `fn`, and keep it only if `fn`
31942
+ * succeeds.
31943
+ *
31944
+ * Sign-in links are single-use, so a failed exchange is the ordinary case,
31945
+ * not the exception. Letting a failed attempt stick would leave someone who
31946
+ * pasted a stale link for practice B pointed at B for the life of the
31947
+ * process — and their intact session for practice A would report "Not signed
31948
+ * in" until a restart. A link only earns the practice by working.
31949
+ */
31950
+ async withPracticeHost(raw, fn) {
31951
+ const previous = this.adoptedHost;
31952
+ this.adoptPracticeHost(raw);
31953
+ try {
31954
+ return await fn();
31955
+ } catch (err) {
31956
+ this.adoptedHost = previous;
31957
+ throw err;
31958
+ }
31959
+ }
31960
+ /**
31961
+ * The practice host, or the deferred error explaining that none is known.
31962
+ *
31963
+ * Deferred rather than thrown at construction: the server must still boot
31964
+ * (and answer the host's install-time tools/list probe) knowing no practice,
31965
+ * which is now the ordinary first-run state rather than a misconfiguration.
31966
+ */
31791
31967
  requireConfig() {
31792
- if (this.configError) throw this.configError;
31793
- return this.host;
31968
+ const host = this.knownPortalHost();
31969
+ if (!host) {
31970
+ throw new McpToolError("I do not know which practice portal to talk to yet.", {
31971
+ hint: "Paste the sign-in link your provider emailed into simplepractice_verify_sign_in_token \u2014 its address names the practice, and this server remembers it. To ask for that link first, pass `practice` to simplepractice_request_sign_in_link, or set SIMPLEPRACTICE_PRACTICE to pin this server to one practice."
31972
+ });
31973
+ }
31974
+ return host;
31794
31975
  }
31795
31976
  portalHost() {
31796
31977
  return this.requireConfig();
31797
31978
  }
31798
31979
  getSession() {
31799
- if (this.configError) return null;
31800
- return this.store.get(this.host);
31980
+ const host = this.knownPortalHost();
31981
+ return host ? this.store.get(host) : null;
31801
31982
  }
31802
31983
  saveSession(cookie) {
31803
31984
  const host = this.requireConfig();
@@ -31806,14 +31987,14 @@ var SimplePracticeClient = class {
31806
31987
  return session;
31807
31988
  }
31808
31989
  clearSession() {
31809
- const host = this.requireConfig();
31810
- return this.store.remove(host);
31990
+ const host = this.knownPortalHost();
31991
+ return host ? this.store.remove(host) : false;
31811
31992
  }
31812
31993
  requireSession() {
31813
31994
  const session = this.getSession();
31814
31995
  if (!session) {
31815
31996
  throw new McpToolError("Not signed in to the SimplePractice Client Portal.", {
31816
- hint: 'Run simplepractice_request_sign_in_link to have SimplePractice email you a sign-in link, then pass the part of that link after the "#" to simplepractice_verify_sign_in_token.'
31997
+ hint: "Pass the sign-in link SimplePractice emailed to simplepractice_verify_sign_in_token \u2014 the whole link, which names the practice as well as carrying the token. Run simplepractice_request_sign_in_link first if you do not have one."
31817
31998
  });
31818
31999
  }
31819
32000
  return session;
@@ -31847,7 +32028,9 @@ var SimplePracticeClient = class {
31847
32028
  } catch (err) {
31848
32029
  throw new McpToolError(
31849
32030
  `Could not reach ${host}: ${truncateErrorMessage(messageOf(err))}`,
31850
- { hint: "Check the practice address in SIMPLEPRACTICE_PRACTICE and your network connection." }
32031
+ {
32032
+ hint: `Check your network connection, and that ${host} is really your practice's portal \u2014 simplepractice_session_status reports where that address came from.`
32033
+ }
31851
32034
  );
31852
32035
  }
31853
32036
  const raw = await response.text();
@@ -31959,11 +32142,17 @@ async function establishSession(client2, attributes) {
31959
32142
  hint: `Expected a ${SESSION_COOKIE} cookie on the response. If SimplePractice has renamed it, simplepractice-mcp needs updating.`
31960
32143
  });
31961
32144
  }
31962
- client2.saveSession(`${SESSION_COOKIE}=${cookie}`);
31963
- return { status, signedIn: true };
32145
+ const session = client2.saveSession(`${SESSION_COOKIE}=${cookie}`);
32146
+ return { status, signedIn: true, practiceHost: session.host };
31964
32147
  }
31965
- function verifySignInToken(client2, linkOrToken) {
31966
- return establishSession(client2, { type: "token", token: extractToken(linkOrToken) });
32148
+ async function verifySignInToken(client2, linkOrToken) {
32149
+ const attributes = { type: "token", token: extractToken(linkOrToken) };
32150
+ const fromLink = practiceHostFromLink(linkOrToken);
32151
+ if (fromLink) {
32152
+ return client2.withPracticeHost(fromLink, () => establishSession(client2, attributes));
32153
+ }
32154
+ client2.portalHost();
32155
+ return establishSession(client2, attributes);
31967
32156
  }
31968
32157
  function verifySignInPin(client2, email3, pin) {
31969
32158
  return establishSession(client2, { type: "pin", email: email3, pin });
@@ -31974,17 +32163,23 @@ function registerAuthTools(server, client2) {
31974
32163
  server.registerTool(
31975
32164
  "simplepractice_session_status",
31976
32165
  {
31977
- description: "Report whether this server holds a Client Portal session, and for which practice. Reads local state only \u2014 makes no network call.",
32166
+ description: "Report whether this server holds a Client Portal session, for which practice, and how that practice was determined (from a sign-in link, from SIMPLEPRACTICE_PRACTICE, or remembered from the stored session). Reads local state only \u2014 makes no network call.",
31978
32167
  annotations: toolAnnotations({ readOnly: true }),
31979
32168
  inputSchema: {}
31980
32169
  },
31981
32170
  async () => {
31982
- const host = client2.portalHost();
32171
+ const host = client2.knownPortalHost();
31983
32172
  const session = client2.getSession();
31984
- return textResult({
32173
+ return minifiedResult({
31985
32174
  practiceHost: host,
32175
+ // Not knowing the practice yet is a state to report, not an error:
32176
+ // it is what a first run looks like before anyone has pasted a link.
32177
+ practiceSource: client2.practiceSource(),
31986
32178
  signedIn: session !== null,
31987
- signedInAt: session?.createdAt ?? null
32179
+ signedInAt: session?.createdAt ?? null,
32180
+ ...host ? {} : {
32181
+ next: "Paste the sign-in link your provider emailed into simplepractice_verify_sign_in_token \u2014 its address names the practice. Or set SIMPLEPRACTICE_PRACTICE to pin this server to one."
32182
+ }
31988
32183
  });
31989
32184
  }
31990
32185
  );
@@ -31995,39 +32190,49 @@ function registerAuthTools(server, client2) {
31995
32190
  annotations: toolAnnotations({ readOnly: false, idempotent: false }),
31996
32191
  inputSchema: {
31997
32192
  email: external_exports.string().email().describe("The email address the Client Portal is registered to."),
32193
+ practice: external_exports.string().min(1).optional().describe(
32194
+ 'The practice whose portal to sign in to \u2014 the slug ("achievebalancetherapy"), the host, or the portal URL. Only needed when this server does not know the practice yet; signing in with an emailed link teaches it, and it then remembers.'
32195
+ ),
31998
32196
  confirm: schemaConfirm
31999
32197
  }
32000
32198
  },
32001
- async ({ email: email3, confirm }) => {
32199
+ async ({ email: email3, practice, confirm }) => {
32002
32200
  if (!confirm) {
32003
- return textResult({
32201
+ return minifiedResult({
32004
32202
  dryRun: true,
32005
32203
  wouldSend: "a Client Portal sign-in email",
32006
32204
  to: email3,
32007
- practiceHost: client2.portalHost(),
32205
+ // Named, not adopted. A dry run sends nothing, so it must not move
32206
+ // the server either — silently overriding a SIMPLEPRACTICE_PRACTICE
32207
+ // pin is not something an inert preview gets to do.
32208
+ practiceHost: practice ? client2.validatePracticeHost(practice) : client2.portalHost(),
32008
32209
  note: "Re-run with confirm:true to actually send it. Do not retry a failed send \u2014 SimplePractice locks out repeated sign-in requests."
32009
32210
  });
32010
32211
  }
32011
- const { expiresIn } = await requestSignInLink(client2, email3);
32012
- return textResult({
32013
- sent: true,
32014
- to: email3,
32015
- expiresIn,
32016
- next: 'Open the email, copy the sign-in link (or just the part after the "#"), and pass it to simplepractice_verify_sign_in_token.',
32017
- note: "This response is the same whether or not the address has an account."
32018
- });
32212
+ const send = async () => {
32213
+ const { expiresIn } = await requestSignInLink(client2, email3);
32214
+ return minifiedResult({
32215
+ sent: true,
32216
+ to: email3,
32217
+ practiceHost: client2.portalHost(),
32218
+ expiresIn,
32219
+ next: 'Open the email, copy the sign-in link (or just the part after the "#"), and pass it to simplepractice_verify_sign_in_token.',
32220
+ note: "This response is the same whether or not the address has an account."
32221
+ });
32222
+ };
32223
+ return practice ? client2.withPracticeHost(practice, send) : send();
32019
32224
  }
32020
32225
  );
32021
32226
  server.registerTool(
32022
32227
  "simplepractice_verify_sign_in_token",
32023
32228
  {
32024
- description: 'Exchange an emailed sign-in link (or the token in it) for a Client Portal session. Accepts the whole link or just the part after the "#". Tokens are single-use and last 24 hours.',
32229
+ description: 'Exchange an emailed sign-in link (or the token in it) for a Client Portal session. Accepts the whole link or just the part after the "#". Prefer passing the WHOLE link: its address names the practice, so no practice has to be configured, and this server remembers it afterwards. Tokens are single-use and last 24 hours.',
32025
32230
  annotations: toolAnnotations({ readOnly: false, idempotent: false }),
32026
32231
  inputSchema: {
32027
32232
  link: external_exports.string().min(1).describe('The sign-in link from the email, or just the token after the "#".')
32028
32233
  }
32029
32234
  },
32030
- async ({ link }) => textResult(await verifySignInToken(client2, link))
32235
+ async ({ link }) => minifiedResult(await verifySignInToken(client2, link))
32031
32236
  );
32032
32237
  server.registerTool(
32033
32238
  "simplepractice_verify_sign_in_pin",
@@ -32039,7 +32244,7 @@ function registerAuthTools(server, client2) {
32039
32244
  pin: external_exports.string().regex(/^\d{6}$/, "The PIN is exactly 6 digits.")
32040
32245
  }
32041
32246
  },
32042
- async ({ email: email3, pin }) => textResult(await verifySignInPin(client2, email3, pin))
32247
+ async ({ email: email3, pin }) => minifiedResult(await verifySignInPin(client2, email3, pin))
32043
32248
  );
32044
32249
  server.registerTool(
32045
32250
  "simplepractice_sign_out",
@@ -32048,7 +32253,7 @@ function registerAuthTools(server, client2) {
32048
32253
  annotations: toolAnnotations({ readOnly: false, idempotent: true }),
32049
32254
  inputSchema: {}
32050
32255
  },
32051
- async () => textResult({ signedOut: client2.clearSession() })
32256
+ async () => minifiedResult({ signedOut: client2.clearSession() })
32052
32257
  );
32053
32258
  }
32054
32259
 
@@ -32070,7 +32275,7 @@ function registerAccountTools(server, client2) {
32070
32275
  const currentClient = environment.currentClient;
32071
32276
  const options = environment.currentClientOptions ?? [];
32072
32277
  const name = (c) => [c.preferredName ?? c.firstName, c.lastName].filter(Boolean).join(" ");
32073
- return textResult({
32278
+ return minifiedResult({
32074
32279
  practice: practice && {
32075
32280
  id: practice.id,
32076
32281
  name: practice.fullName,
@@ -32103,6 +32308,18 @@ function registerAccountTools(server, client2) {
32103
32308
  );
32104
32309
  }
32105
32310
 
32311
+ // src/view.ts
32312
+ var SP_VIEWS = ["compact", "full"];
32313
+ var NOTE = `compact returns the slim projection where one exists and strips image URLs elsewhere; "full" returns SimplePractice's whole records.`;
32314
+ var viewArg = () => viewParam(SP_VIEWS, { note: NOTE });
32315
+ function isCompact(view) {
32316
+ const rung = resolveView(view, SP_VIEWS);
32317
+ return rung === "compact";
32318
+ }
32319
+ function viewResponse(view, data) {
32320
+ return minifiedResult(isCompact(view) ? stripMediaUrls(data) : data);
32321
+ }
32322
+
32106
32323
  // src/tools/appointments.ts
32107
32324
  var PAGE_SIZE_MAX = 50;
32108
32325
  function compactAppointment(a) {
@@ -32132,22 +32349,22 @@ function registerAppointmentTools(server, client2) {
32132
32349
  status: external_exports.enum(["scheduled", "requested"]).default("scheduled").describe("Which side of the pending-confirmation filter to read."),
32133
32350
  page: external_exports.number().int().positive().default(1),
32134
32351
  pageSize: external_exports.number().int().positive().max(PAGE_SIZE_MAX).default(PAGE_SIZE_MAX),
32135
- compact: external_exports.boolean().default(true).describe("Return a slim projection. Set false for the full records.")
32352
+ view: viewArg()
32136
32353
  }
32137
32354
  },
32138
- async ({ status, page, pageSize, compact }) => {
32355
+ async ({ status, page, pageSize, view }) => {
32139
32356
  const { records } = await client2.list("/appointments", {
32140
32357
  include: "clinician,office,client",
32141
32358
  filter: { hasPendingConfirmation: status === "requested" },
32142
32359
  page: { number: page, size: pageSize }
32143
32360
  });
32144
- return textResult({
32361
+ return minifiedResult({
32145
32362
  status,
32146
32363
  page,
32147
32364
  count: records.length,
32148
32365
  // The API sends no total; a short page is the last page.
32149
32366
  hasMore: records.length >= pageSize,
32150
- appointments: compact ? records.map(compactAppointment) : records
32367
+ appointments: isCompact(view) ? records.map(compactAppointment) : records
32151
32368
  });
32152
32369
  }
32153
32370
  );
@@ -32179,16 +32396,20 @@ function registerBillingTools(server, client2) {
32179
32396
  inputSchema: {
32180
32397
  kind: external_exports.enum(["invoice", "statement", "superbill", "receipt", "account-history"]).default("invoice"),
32181
32398
  before: external_exports.string().optional().describe("Cursor for the next page \u2014 the nextCursor from a previous call."),
32182
- pageSize: external_exports.number().int().positive().max(PAGE_SIZE_MAX2).default(PAGE_SIZE_MAX2)
32399
+ pageSize: external_exports.number().int().positive().max(PAGE_SIZE_MAX2).default(PAGE_SIZE_MAX2),
32400
+ view: viewArg()
32183
32401
  }
32184
32402
  },
32185
- async ({ kind, before, pageSize }) => {
32403
+ // `view` is destructured off, never forwarded: `client.list` turns whatever
32404
+ // it is handed into a JSON:API query string, and a stray `view=compact`
32405
+ // would reach SimplePractice as a filter it never defined.
32406
+ async ({ kind, before, pageSize, view }) => {
32186
32407
  const { records, meta: meta3 } = await client2.list("/billing-items", {
32187
32408
  filter: KINDS[kind],
32188
32409
  page: before ? { size: pageSize, before } : { size: pageSize }
32189
32410
  });
32190
32411
  const last = records[records.length - 1];
32191
- return textResult({
32412
+ return viewResponse(view, {
32192
32413
  kind,
32193
32414
  count: records.length,
32194
32415
  endBalance: meta3?.endBalance ?? null,
@@ -32203,11 +32424,11 @@ function registerBillingTools(server, client2) {
32203
32424
  {
32204
32425
  description: "Balance due and per-category counts for the Client Portal account. Cheaper than paging the billing collections just to find out whether anything is there.",
32205
32426
  annotations: toolAnnotations({ readOnly: true }),
32206
- inputSchema: {}
32427
+ inputSchema: { view: viewArg() }
32207
32428
  },
32208
- async () => {
32429
+ async ({ view }) => {
32209
32430
  const overview = await loadClientRelationship(client2, "clientBillingOverview");
32210
- return textResult(overview ?? { note: "No billing overview returned for this client." });
32431
+ return viewResponse(view, overview ?? { note: "No billing overview returned for this client." });
32211
32432
  }
32212
32433
  );
32213
32434
  server.registerTool(
@@ -32217,10 +32438,13 @@ function registerBillingTools(server, client2) {
32217
32438
  annotations: toolAnnotations({ readOnly: true }),
32218
32439
  inputSchema: {}
32219
32440
  },
32441
+ // No `view`: the response below IS a projection, hand-written down to five
32442
+ // fields with knowledge of what a card record holds. Running the blind rung
32443
+ // over it afterwards would let an un-grounded rule overrule a grounded one.
32220
32444
  async () => {
32221
32445
  const cards = await loadClientRelationship(client2, "cards");
32222
32446
  const list = Array.isArray(cards) ? cards : [];
32223
- return textResult({
32447
+ return minifiedResult({
32224
32448
  count: list.length,
32225
32449
  paymentMethods: list.map((c) => ({
32226
32450
  id: c.id,
@@ -32250,6 +32474,9 @@ function registerDocumentTools(server, client2) {
32250
32474
  includeBody: external_exports.boolean().default(false).describe("Include the full document body/questions. Off by default \u2014 these are long.")
32251
32475
  }
32252
32476
  },
32477
+ // No `view`: `items` below is a hand-written projection, and `includeBody`
32478
+ // is a field the caller explicitly asked for. A blind rung run over that
32479
+ // output could only take back something chosen on purpose.
32253
32480
  async ({ outstandingOnly, pageSize, includeBody }) => {
32254
32481
  const { records, meta: meta3 } = await client2.list("/document-requests", {
32255
32482
  page: { size: pageSize }
@@ -32275,7 +32502,7 @@ function registerDocumentTools(server, client2) {
32275
32502
  }
32276
32503
  return base;
32277
32504
  });
32278
- return textResult({
32505
+ return minifiedResult({
32279
32506
  count: items.length,
32280
32507
  outstanding: records.filter((r) => !SETTLED.has(String(r.status))).length,
32281
32508
  welcomeText: meta3?.welcomeText ?? null,
@@ -32288,13 +32515,18 @@ function registerDocumentTools(server, client2) {
32288
32515
  {
32289
32516
  description: "One document request in full, including its body or its questions and the answers already given.",
32290
32517
  annotations: toolAnnotations({ readOnly: true }),
32291
- inputSchema: { id: external_exports.string().min(1).describe("The document request id.") }
32518
+ inputSchema: {
32519
+ id: external_exports.string().min(1).describe("The document request id."),
32520
+ view: viewArg()
32521
+ }
32292
32522
  },
32293
- async ({ id }) => {
32523
+ // `view` is destructured off rather than passed on: the id is the only part
32524
+ // of this input that may reach the request path.
32525
+ async ({ id, view }) => {
32294
32526
  const { records } = await client2.list(`/document-requests/${encodeURIComponent(id)}`);
32295
32527
  const record2 = records[0];
32296
- if (!record2) return textResult({ found: false, id });
32297
- return textResult({
32528
+ if (!record2) return minifiedResult({ found: false, id });
32529
+ return viewResponse(view, {
32298
32530
  ...record2,
32299
32531
  hasDocumentPdf: asBoolean(record2.hasDocumentPdf) ?? false
32300
32532
  });
@@ -32309,9 +32541,14 @@ function registerDocumentTools(server, client2) {
32309
32541
  pageSize: external_exports.number().int().positive().max(PAGE_SIZE_MAX3).default(PAGE_SIZE_MAX3)
32310
32542
  }
32311
32543
  },
32544
+ // No `view`, and this one is the exception worth stating: the PRODUCT of
32545
+ // this tool is the file references themselves. A practice that shares a
32546
+ // scan shares it as a .jpg or .png, and the blind rung drops any string
32547
+ // whose path ends in an image extension — so compacting here would empty
32548
+ // exactly the rows a caller came for rather than shrink them.
32312
32549
  async ({ pageSize }) => {
32313
32550
  const { records } = await client2.list("/documents", { page: { size: pageSize } });
32314
- return textResult({ count: records.length, documents: records });
32551
+ return minifiedResult({ count: records.length, documents: records });
32315
32552
  }
32316
32553
  );
32317
32554
  server.registerTool(
@@ -32320,12 +32557,13 @@ function registerDocumentTools(server, client2) {
32320
32557
  description: "Announcements the practice has posted to the Client Portal. readAt is null on unread ones.",
32321
32558
  annotations: toolAnnotations({ readOnly: true }),
32322
32559
  inputSchema: {
32323
- pageSize: external_exports.number().int().positive().max(PAGE_SIZE_MAX3).default(PAGE_SIZE_MAX3)
32560
+ pageSize: external_exports.number().int().positive().max(PAGE_SIZE_MAX3).default(PAGE_SIZE_MAX3),
32561
+ view: viewArg()
32324
32562
  }
32325
32563
  },
32326
- async ({ pageSize }) => {
32564
+ async ({ pageSize, view }) => {
32327
32565
  const { records } = await client2.list("/announcements", { page: { size: pageSize } });
32328
- return textResult({
32566
+ return viewResponse(view, {
32329
32567
  count: records.length,
32330
32568
  unread: records.filter((r) => r.readAt === null || r.readAt === void 0).length,
32331
32569
  announcements: records
@@ -32334,6 +32572,211 @@ function registerDocumentTools(server, client2) {
32334
32572
  );
32335
32573
  }
32336
32574
 
32575
+ // node_modules/@chrischall/mcp-utils/dist/healthcheck/index.js
32576
+ function statusOf(err) {
32577
+ if (typeof err !== "object" || err === null)
32578
+ return void 0;
32579
+ const s = err.status ?? err.statusCode;
32580
+ return typeof s === "number" ? s : void 0;
32581
+ }
32582
+ var CREDENTIAL_ARMS = /* @__PURE__ */ new Set([
32583
+ "ok",
32584
+ "no_credential",
32585
+ "credential_rejected",
32586
+ "timeout",
32587
+ "http",
32588
+ "transport",
32589
+ "unknown"
32590
+ ]);
32591
+ function isArm(kind) {
32592
+ return kind !== void 0 && CREDENTIAL_ARMS.has(kind);
32593
+ }
32594
+ function credentialHint(arm, prefix, hostLabel, source) {
32595
+ switch (arm) {
32596
+ case "ok":
32597
+ return `Credential from '${source}' works: ${hostLabel} accepted an authenticated request. If a real tool still fails, the problem is that tool, not auth.`;
32598
+ case "no_credential":
32599
+ 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.`;
32600
+ case "credential_rejected":
32601
+ 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.`;
32602
+ case "timeout":
32603
+ 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.`;
32604
+ case "http":
32605
+ return `${hostLabel} answered with an error status that is not an auth rejection. That is USUALLY a ${hostLabel}-side problem rather than an auth one \u2014 but a 404 here more often means the probe path is wrong than that ${hostLabel} is broken, so check error.message and probe.url before concluding anything about the credential.`;
32606
+ case "transport":
32607
+ return `Could not reach ${hostLabel} at all. Check network egress; the credential itself was never judged.`;
32608
+ default:
32609
+ return `Unexpected failure \u2014 see error.message.`;
32610
+ }
32611
+ }
32612
+ function registerCredentialHealthcheckTool(args) {
32613
+ const { server, prefix, hostLabel, probePath, resolveCredential, probeFn, classifyThrown, hints } = args;
32614
+ const probeUrl = probePath ? `https://${hostLabel}${probePath}` : void 0;
32615
+ server.registerTool(`${prefix}_healthcheck`, {
32616
+ title: "Verify credentials and upstream reachability",
32617
+ 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.`,
32618
+ annotations: {
32619
+ title: "Verify credentials and upstream reachability",
32620
+ readOnlyHint: true,
32621
+ idempotentHint: true,
32622
+ openWorldHint: true
32623
+ },
32624
+ inputSchema: {}
32625
+ }, async () => {
32626
+ let probeStarted = 0;
32627
+ let state;
32628
+ try {
32629
+ state = await resolveCredential();
32630
+ } catch (e) {
32631
+ const classified = classifyThrown?.(e);
32632
+ const result2 = {
32633
+ ok: false,
32634
+ // Still false, and still no source: a classification explains WHY
32635
+ // nothing resolved, it does not invent a credential that did.
32636
+ credential: { source: null, resolved: false },
32637
+ // No `url`: nothing was probed, and naming one implies it was tried.
32638
+ probe: { elapsed_ms: 0 },
32639
+ error: {
32640
+ kind: classified?.kind ?? "no_credential",
32641
+ message: truncateErrorMessage(messageOf(e)),
32642
+ ...classified?.detail !== void 0 ? { detail: classified.detail } : {}
32643
+ },
32644
+ // The hint must follow the KIND beside it. Falling back to
32645
+ // `no_credential`'s copy under a classified kind would state a cause
32646
+ // the kind contradicts — the same disagreement this path exists to
32647
+ // remove. So: an inline hint wins; else the classified arm's own
32648
+ // copy (consumer override first); else, for a kind this module has
32649
+ // no copy for, the neutral `unknown` text rather than one that
32650
+ // asserts a cause; else the unclassified `no_credential` default.
32651
+ 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))
32652
+ };
32653
+ return { content: [{ type: "text", text: JSON.stringify(result2, null, 2) }] };
32654
+ }
32655
+ const credential = {
32656
+ source: state.source,
32657
+ resolved: state.source !== null,
32658
+ ...state.detail !== void 0 ? { detail: state.detail } : {}
32659
+ };
32660
+ if (!credential.resolved) {
32661
+ const result2 = {
32662
+ ok: false,
32663
+ credential,
32664
+ probe: { elapsed_ms: 0 },
32665
+ error: { kind: "no_credential", message: "no credential source resolved" },
32666
+ hint: hints?.no_credential ?? credentialHint("no_credential", prefix, hostLabel, null)
32667
+ };
32668
+ return { content: [{ type: "text", text: JSON.stringify(result2, null, 2) }] };
32669
+ }
32670
+ let arm = "ok";
32671
+ let error51;
32672
+ let status;
32673
+ let customHint;
32674
+ probeStarted = Date.now();
32675
+ try {
32676
+ await probeFn();
32677
+ } catch (e) {
32678
+ status = statusOf(e);
32679
+ const aborted2 = e instanceof Error && e.name === "AbortError";
32680
+ 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";
32681
+ let kind = arm;
32682
+ let detail;
32683
+ const custom2 = classifyThrown?.(e);
32684
+ if (custom2) {
32685
+ kind = custom2.kind;
32686
+ customHint = custom2.hint;
32687
+ detail = custom2.detail;
32688
+ }
32689
+ error51 = {
32690
+ kind,
32691
+ // Redacted AND bounded before it reaches the result: an upstream
32692
+ // failure routinely quotes what it was sent, and a healthcheck is
32693
+ // the tool people paste into a chat when something is broken.
32694
+ message: truncateErrorMessage(messageOf(e)),
32695
+ ...detail !== void 0 ? { detail } : {}
32696
+ };
32697
+ }
32698
+ const result = {
32699
+ ok: error51 === void 0,
32700
+ credential,
32701
+ probe: {
32702
+ ...probeUrl ? { url: probeUrl } : {},
32703
+ elapsed_ms: Date.now() - probeStarted,
32704
+ ...status !== void 0 ? { status } : {}
32705
+ },
32706
+ ...error51 ? { error: error51 } : {},
32707
+ hint: customHint ?? hints?.[arm] ?? credentialHint(arm, prefix, hostLabel, state.source)
32708
+ };
32709
+ return { content: [{ type: "text", text: JSON.stringify(result, null, 2) }] };
32710
+ });
32711
+ }
32712
+
32713
+ // src/tools/health.ts
32714
+ var CLIENT_ERROR_TEXT = {
32715
+ /** From client.ts `requireConfig()` — thrown by `portalHost()`. */
32716
+ noPractice: "I do not know which practice portal to talk to yet",
32717
+ /** From client.ts `throwForStatus()` 401/403, on the HINT — not the message. */
32718
+ sessionExpired: "The portal session has expired",
32719
+ /** From client.ts `requireSession()`, on the MESSAGE. */
32720
+ notSignedIn: "Not signed in to the SimplePractice Client Portal",
32721
+ /** From client.ts `throwForStatus()` 429, on the HINT. */
32722
+ rateLimited: "SimplePractice rate-limits sign-in requests"
32723
+ };
32724
+ function classifySimplePracticeError(err) {
32725
+ const message = err instanceof Error ? err.message : String(err);
32726
+ const hint = typeof err?.hint === "string" ? err.hint : "";
32727
+ const text = `${message}
32728
+ ${hint}`;
32729
+ if (text.includes(CLIENT_ERROR_TEXT.noPractice)) {
32730
+ return {
32731
+ kind: "no_practice_host",
32732
+ hint: "No practice known yet. Paste the sign-in link your provider emailed into simplepractice_verify_sign_in_token \u2014 its address names the practice, and this server remembers it afterwards. SIMPLEPRACTICE_PRACTICE is optional, and only pins the server to one practice."
32733
+ };
32734
+ }
32735
+ if (text.includes(CLIENT_ERROR_TEXT.rateLimited)) {
32736
+ return {
32737
+ kind: "rate_limited",
32738
+ hint: "SimplePractice rate-limits sign-in requests per email and per IP. The session is not necessarily bad \u2014 do NOT retry, and wait before requesting another link."
32739
+ };
32740
+ }
32741
+ if (text.includes(CLIENT_ERROR_TEXT.sessionExpired) || text.includes(CLIENT_ERROR_TEXT.notSignedIn)) {
32742
+ return {
32743
+ kind: "session_expired",
32744
+ hint: 'The portal rejected the stored session. There is no refresh token, so it cannot be renewed silently: run simplepractice_request_sign_in_link, then pass the part of the emailed link after the "#" to simplepractice_verify_sign_in_token.'
32745
+ };
32746
+ }
32747
+ return void 0;
32748
+ }
32749
+ function registerHealthcheckTools(server, client2) {
32750
+ registerCredentialHealthcheckTool({
32751
+ server,
32752
+ prefix: "simplepractice",
32753
+ hostLabel: "clientsecure.me",
32754
+ probePath: "/environment",
32755
+ resolveCredential: async () => {
32756
+ const session = client2.getSession();
32757
+ return {
32758
+ source: session ? "portal_session" : null,
32759
+ detail: {
32760
+ // `knownPortalHost`, not `portalHost`: the latter throws, and not
32761
+ // knowing the practice is the ordinary state before anyone has
32762
+ // pasted a sign-in link. A healthcheck that throws where it should
32763
+ // report `practice_host: null` fails at the one job it has — saying
32764
+ // which hop is broken.
32765
+ practice_host: client2.knownPortalHost(),
32766
+ // When the session was minted — the fact that explains a connector
32767
+ // that worked yesterday and does not today. Never the cookie.
32768
+ signed_in_at: session?.createdAt ?? null
32769
+ }
32770
+ };
32771
+ },
32772
+ // The cheapest authenticated read in the portal, and the one the client
32773
+ // already uses to resolve the current client id. It changes nothing: no
32774
+ // appointment booked, no document touched.
32775
+ probeFn: () => client2.list("/environment", { include: "currentClient" }),
32776
+ classifyThrown: classifySimplePracticeError
32777
+ });
32778
+ }
32779
+
32337
32780
  // src/index.ts
32338
32781
  var client = new SimplePracticeClient();
32339
32782
  await runMcp({
@@ -32346,6 +32789,7 @@ await runMcp({
32346
32789
  registerAccountTools,
32347
32790
  registerAppointmentTools,
32348
32791
  registerBillingTools,
32349
- registerDocumentTools
32792
+ registerDocumentTools,
32793
+ registerHealthcheckTools
32350
32794
  ]
32351
32795
  });