simplepractice-mcp 0.1.0 → 0.3.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
  }
@@ -31530,11 +31530,11 @@ function toolAnnotations(opts = {}) {
31530
31530
  }
31531
31531
 
31532
31532
  // src/version.ts
31533
- var VERSION = "0.1.0";
31533
+ var VERSION = "0.3.0";
31534
31534
 
31535
31535
  // 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";
31536
+ import { existsSync, readFileSync, writeFileSync, mkdirSync, chmodSync, renameSync, unlinkSync } from "node:fs";
31537
+ import { dirname, join as join2, resolve as resolve2 } from "node:path";
31538
31538
  function normalizeOrigin(input) {
31539
31539
  try {
31540
31540
  return new URL(input).origin.replace(/\/$/, "");
@@ -31670,7 +31670,7 @@ var TOKEN_REFRESH_SKEW_MS = 5 * 60 * 1e3;
31670
31670
 
31671
31671
  // src/config.ts
31672
31672
  import { homedir as homedir2 } from "node:os";
31673
- import { join as join2 } from "node:path";
31673
+ import { join as join3 } from "node:path";
31674
31674
  var API_VERSION = "2026-05-25";
31675
31675
  var APPLICATION_BUILD_VERSION = "0.0.0";
31676
31676
  var APPLICATION_PLATFORM = "web";
@@ -31688,13 +31688,21 @@ function resolvePortalHost(raw) {
31688
31688
  if (!/^[a-z0-9][a-z0-9-]*$/.test(label)) return null;
31689
31689
  return value;
31690
31690
  }
31691
+ function practiceHostFromLink(raw) {
31692
+ if (!raw) return null;
31693
+ const hash2 = raw.indexOf("#");
31694
+ if (hash2 < 0) return null;
31695
+ const prefix = raw.slice(0, hash2).trim();
31696
+ if (!prefix.includes(".")) return null;
31697
+ return resolvePortalHost(prefix);
31698
+ }
31691
31699
  function readPortalHost() {
31692
31700
  return resolvePortalHost(readEnvVar("SIMPLEPRACTICE_PRACTICE"));
31693
31701
  }
31694
31702
  function sessionFilePath() {
31695
31703
  const override = readEnvVar("SIMPLEPRACTICE_SESSION_FILE");
31696
31704
  if (override) return expandPath(override);
31697
- return join2(homedir2(), ".simplepractice-mcp", "session.json");
31705
+ return join3(homedir2(), ".simplepractice-mcp", "session.json");
31698
31706
  }
31699
31707
 
31700
31708
  // src/jsonapi.ts
@@ -31768,36 +31776,139 @@ function buildQuery(params) {
31768
31776
  }
31769
31777
  var SimplePracticeClient = class {
31770
31778
  store;
31771
- configError;
31772
- host;
31773
31779
  fetchImpl;
31780
+ /** A practice learned at runtime — from a sign-in link, or named on a tool call. */
31781
+ adoptedHost = null;
31774
31782
  constructor(opts = {}) {
31775
31783
  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
31784
  this.store = opts.store ?? new SessionStore({
31785
31785
  filePath: sessionFilePath(),
31786
31786
  keyOf: (session) => session.host,
31787
31787
  normalizeKey: (key) => key.toLowerCase()
31788
31788
  });
31789
31789
  }
31790
- /** Throws the deferred configuration error, if there is one. */
31790
+ /**
31791
+ * Which practice this server is talking to, and how it found out.
31792
+ *
31793
+ * Resolved per call rather than fixed at construction, because the practice
31794
+ * is usually not known when the process starts: it arrives with the sign-in
31795
+ * link. In order:
31796
+ *
31797
+ * 1. **link** — adopted at runtime from the emailed link (or named on the
31798
+ * tool call). The most recent explicit statement of intent, and the only
31799
+ * one that can be right when a token is minted for a different practice
31800
+ * than the environment names.
31801
+ * 2. **environment** — `SIMPLEPRACTICE_PRACTICE`, an explicit pin for
31802
+ * someone who wants this server bound to one practice.
31803
+ * 3. **session** — the practice of the most recent sign-in. This is what
31804
+ * makes the link route survive a restart: sign in once, and every later
31805
+ * process knows the practice with no configuration at all.
31806
+ */
31807
+ resolveHost() {
31808
+ if (this.adoptedHost) return { host: this.adoptedHost, source: "link" };
31809
+ const configured = readPortalHost();
31810
+ if (configured) return { host: configured, source: "environment" };
31811
+ const remembered = this.mostRecentSessionHost();
31812
+ return remembered ? { host: remembered, source: "session" } : null;
31813
+ }
31814
+ /**
31815
+ * The practice signed into most recently, by our own `createdAt` rather than
31816
+ * `SessionStore`'s active pointer.
31817
+ *
31818
+ * The two agree right up until a practice is signed into twice, and then
31819
+ * they disagree across a restart: `add()` on an existing key leaves the Map
31820
+ * entry in its ORIGINAL insertion position, so the in-memory pointer names
31821
+ * the practice just added, while a fresh process restores the pointer as the
31822
+ * LAST key on disk. Signing in to A, then B, then A again would leave the
31823
+ * next process quietly talking to B.
31824
+ *
31825
+ * `createdAt` is the fact this fallback actually means, and unlike the
31826
+ * pointer it survives the restart.
31827
+ */
31828
+ mostRecentSessionHost() {
31829
+ let newest = null;
31830
+ for (const session of this.store.list()) {
31831
+ if (!newest || session.createdAt > newest.createdAt) newest = session;
31832
+ }
31833
+ return newest?.host ?? null;
31834
+ }
31835
+ /** The practice host, or `null` when none is known yet. Never throws. */
31836
+ knownPortalHost() {
31837
+ return this.resolveHost()?.host ?? null;
31838
+ }
31839
+ /** How the practice was determined, or `null` when it has not been. */
31840
+ practiceSource() {
31841
+ return this.resolveHost()?.source ?? null;
31842
+ }
31843
+ /**
31844
+ * The host a practice address names, WITHOUT adopting it.
31845
+ *
31846
+ * Validated through the same `resolvePortalHost` the environment goes
31847
+ * through, so a link outside `*.clientsecure.me` cannot redirect a token.
31848
+ *
31849
+ * Separate from {@link adoptPracticeHost} so a caller that only wants to
31850
+ * *name* the practice — a dry run reporting what it would do — can do that
31851
+ * without the side effect. Answering a question should not move the server.
31852
+ */
31853
+ validatePracticeHost(raw) {
31854
+ const host = resolvePortalHost(raw);
31855
+ if (!host) {
31856
+ throw new McpToolError(`"${raw}" is not a SimplePractice Client Portal address.`, {
31857
+ hint: 'A portal address is a single practice under clientsecure.me \u2014 the slug ("achievebalancetherapy") or the whole host ("achievebalancetherapy.clientsecure.me").'
31858
+ });
31859
+ }
31860
+ return host;
31861
+ }
31862
+ /**
31863
+ * Point this server at a practice for the rest of the process — what the
31864
+ * sign-in link's own host feeds.
31865
+ */
31866
+ adoptPracticeHost(raw) {
31867
+ this.adoptedHost = this.validatePracticeHost(raw);
31868
+ return this.adoptedHost;
31869
+ }
31870
+ /**
31871
+ * Adopt `raw`'s practice for the duration of `fn`, and keep it only if `fn`
31872
+ * succeeds.
31873
+ *
31874
+ * Sign-in links are single-use, so a failed exchange is the ordinary case,
31875
+ * not the exception. Letting a failed attempt stick would leave someone who
31876
+ * pasted a stale link for practice B pointed at B for the life of the
31877
+ * process — and their intact session for practice A would report "Not signed
31878
+ * in" until a restart. A link only earns the practice by working.
31879
+ */
31880
+ async withPracticeHost(raw, fn) {
31881
+ const previous = this.adoptedHost;
31882
+ this.adoptPracticeHost(raw);
31883
+ try {
31884
+ return await fn();
31885
+ } catch (err) {
31886
+ this.adoptedHost = previous;
31887
+ throw err;
31888
+ }
31889
+ }
31890
+ /**
31891
+ * The practice host, or the deferred error explaining that none is known.
31892
+ *
31893
+ * Deferred rather than thrown at construction: the server must still boot
31894
+ * (and answer the host's install-time tools/list probe) knowing no practice,
31895
+ * which is now the ordinary first-run state rather than a misconfiguration.
31896
+ */
31791
31897
  requireConfig() {
31792
- if (this.configError) throw this.configError;
31793
- return this.host;
31898
+ const host = this.knownPortalHost();
31899
+ if (!host) {
31900
+ throw new McpToolError("I do not know which practice portal to talk to yet.", {
31901
+ 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."
31902
+ });
31903
+ }
31904
+ return host;
31794
31905
  }
31795
31906
  portalHost() {
31796
31907
  return this.requireConfig();
31797
31908
  }
31798
31909
  getSession() {
31799
- if (this.configError) return null;
31800
- return this.store.get(this.host);
31910
+ const host = this.knownPortalHost();
31911
+ return host ? this.store.get(host) : null;
31801
31912
  }
31802
31913
  saveSession(cookie) {
31803
31914
  const host = this.requireConfig();
@@ -31806,14 +31917,14 @@ var SimplePracticeClient = class {
31806
31917
  return session;
31807
31918
  }
31808
31919
  clearSession() {
31809
- const host = this.requireConfig();
31810
- return this.store.remove(host);
31920
+ const host = this.knownPortalHost();
31921
+ return host ? this.store.remove(host) : false;
31811
31922
  }
31812
31923
  requireSession() {
31813
31924
  const session = this.getSession();
31814
31925
  if (!session) {
31815
31926
  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.'
31927
+ 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
31928
  });
31818
31929
  }
31819
31930
  return session;
@@ -31847,7 +31958,9 @@ var SimplePracticeClient = class {
31847
31958
  } catch (err) {
31848
31959
  throw new McpToolError(
31849
31960
  `Could not reach ${host}: ${truncateErrorMessage(messageOf(err))}`,
31850
- { hint: "Check the practice address in SIMPLEPRACTICE_PRACTICE and your network connection." }
31961
+ {
31962
+ hint: `Check your network connection, and that ${host} is really your practice's portal \u2014 simplepractice_session_status reports where that address came from.`
31963
+ }
31851
31964
  );
31852
31965
  }
31853
31966
  const raw = await response.text();
@@ -31857,7 +31970,7 @@ var SimplePracticeClient = class {
31857
31970
  } catch {
31858
31971
  document = null;
31859
31972
  }
31860
- if (!response.ok) this.throwForStatus(response.status, document);
31973
+ if (!response.ok) this.throwForStatus(response.status, document, path);
31861
31974
  if (document === null) {
31862
31975
  throw new McpToolError(
31863
31976
  `SimplePractice returned HTML rather than JSON for ${path}.`,
@@ -31884,11 +31997,12 @@ var SimplePracticeClient = class {
31884
31997
  const { document } = await this.request(path, { query });
31885
31998
  return flattenDocument(document);
31886
31999
  }
31887
- throwForStatus(status, document) {
32000
+ throwForStatus(status, document, path) {
31888
32001
  const message = formatJsonApiErrors(document, status);
32002
+ const isSignIn = path.startsWith("/sessions/") || path.startsWith("/sign-in-tokens");
31889
32003
  if (status === 401 || status === 403) {
31890
32004
  throw new McpToolError(message, {
31891
- hint: "The portal session has expired \u2014 there is no refresh token, so sign in again with simplepractice_request_sign_in_link."
32005
+ hint: isSignIn ? "Sign-in links and PINs are single-use and last 24 hours. Request a fresh one with simplepractice_request_sign_in_link." : "The portal session has expired \u2014 there is no refresh token, so sign in again with simplepractice_request_sign_in_link."
31892
32006
  });
31893
32007
  }
31894
32008
  if (status === 429) {
@@ -31958,11 +32072,17 @@ async function establishSession(client2, attributes) {
31958
32072
  hint: `Expected a ${SESSION_COOKIE} cookie on the response. If SimplePractice has renamed it, simplepractice-mcp needs updating.`
31959
32073
  });
31960
32074
  }
31961
- client2.saveSession(`${SESSION_COOKIE}=${cookie}`);
31962
- return { status, signedIn: true };
32075
+ const session = client2.saveSession(`${SESSION_COOKIE}=${cookie}`);
32076
+ return { status, signedIn: true, practiceHost: session.host };
31963
32077
  }
31964
- function verifySignInToken(client2, linkOrToken) {
31965
- return establishSession(client2, { type: "token", token: extractToken(linkOrToken) });
32078
+ async function verifySignInToken(client2, linkOrToken) {
32079
+ const attributes = { type: "token", token: extractToken(linkOrToken) };
32080
+ const fromLink = practiceHostFromLink(linkOrToken);
32081
+ if (fromLink) {
32082
+ return client2.withPracticeHost(fromLink, () => establishSession(client2, attributes));
32083
+ }
32084
+ client2.portalHost();
32085
+ return establishSession(client2, attributes);
31966
32086
  }
31967
32087
  function verifySignInPin(client2, email3, pin) {
31968
32088
  return establishSession(client2, { type: "pin", email: email3, pin });
@@ -31973,17 +32093,23 @@ function registerAuthTools(server, client2) {
31973
32093
  server.registerTool(
31974
32094
  "simplepractice_session_status",
31975
32095
  {
31976
- description: "Report whether this server holds a Client Portal session, and for which practice. Reads local state only \u2014 makes no network call.",
32096
+ 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.",
31977
32097
  annotations: toolAnnotations({ readOnly: true }),
31978
32098
  inputSchema: {}
31979
32099
  },
31980
32100
  async () => {
31981
- const host = client2.portalHost();
32101
+ const host = client2.knownPortalHost();
31982
32102
  const session = client2.getSession();
31983
32103
  return textResult({
31984
32104
  practiceHost: host,
32105
+ // Not knowing the practice yet is a state to report, not an error:
32106
+ // it is what a first run looks like before anyone has pasted a link.
32107
+ practiceSource: client2.practiceSource(),
31985
32108
  signedIn: session !== null,
31986
- signedInAt: session?.createdAt ?? null
32109
+ signedInAt: session?.createdAt ?? null,
32110
+ ...host ? {} : {
32111
+ 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."
32112
+ }
31987
32113
  });
31988
32114
  }
31989
32115
  );
@@ -31994,33 +32120,43 @@ function registerAuthTools(server, client2) {
31994
32120
  annotations: toolAnnotations({ readOnly: false, idempotent: false }),
31995
32121
  inputSchema: {
31996
32122
  email: external_exports.string().email().describe("The email address the Client Portal is registered to."),
32123
+ practice: external_exports.string().min(1).optional().describe(
32124
+ '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.'
32125
+ ),
31997
32126
  confirm: schemaConfirm
31998
32127
  }
31999
32128
  },
32000
- async ({ email: email3, confirm }) => {
32129
+ async ({ email: email3, practice, confirm }) => {
32001
32130
  if (!confirm) {
32002
32131
  return textResult({
32003
32132
  dryRun: true,
32004
32133
  wouldSend: "a Client Portal sign-in email",
32005
32134
  to: email3,
32006
- practiceHost: client2.portalHost(),
32135
+ // Named, not adopted. A dry run sends nothing, so it must not move
32136
+ // the server either — silently overriding a SIMPLEPRACTICE_PRACTICE
32137
+ // pin is not something an inert preview gets to do.
32138
+ practiceHost: practice ? client2.validatePracticeHost(practice) : client2.portalHost(),
32007
32139
  note: "Re-run with confirm:true to actually send it. Do not retry a failed send \u2014 SimplePractice locks out repeated sign-in requests."
32008
32140
  });
32009
32141
  }
32010
- const { expiresIn } = await requestSignInLink(client2, email3);
32011
- return textResult({
32012
- sent: true,
32013
- to: email3,
32014
- expiresIn,
32015
- next: 'Open the email, copy the sign-in link (or just the part after the "#"), and pass it to simplepractice_verify_sign_in_token.',
32016
- note: "This response is the same whether or not the address has an account."
32017
- });
32142
+ const send = async () => {
32143
+ const { expiresIn } = await requestSignInLink(client2, email3);
32144
+ return textResult({
32145
+ sent: true,
32146
+ to: email3,
32147
+ practiceHost: client2.portalHost(),
32148
+ expiresIn,
32149
+ next: 'Open the email, copy the sign-in link (or just the part after the "#"), and pass it to simplepractice_verify_sign_in_token.',
32150
+ note: "This response is the same whether or not the address has an account."
32151
+ });
32152
+ };
32153
+ return practice ? client2.withPracticeHost(practice, send) : send();
32018
32154
  }
32019
32155
  );
32020
32156
  server.registerTool(
32021
32157
  "simplepractice_verify_sign_in_token",
32022
32158
  {
32023
- 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.',
32159
+ 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.',
32024
32160
  annotations: toolAnnotations({ readOnly: false, idempotent: false }),
32025
32161
  inputSchema: {
32026
32162
  link: external_exports.string().min(1).describe('The sign-in link from the email, or just the token after the "#".')
@@ -32333,6 +32469,211 @@ function registerDocumentTools(server, client2) {
32333
32469
  );
32334
32470
  }
32335
32471
 
32472
+ // node_modules/@chrischall/mcp-utils/dist/healthcheck/index.js
32473
+ function statusOf(err) {
32474
+ if (typeof err !== "object" || err === null)
32475
+ return void 0;
32476
+ const s = err.status ?? err.statusCode;
32477
+ return typeof s === "number" ? s : void 0;
32478
+ }
32479
+ var CREDENTIAL_ARMS = /* @__PURE__ */ new Set([
32480
+ "ok",
32481
+ "no_credential",
32482
+ "credential_rejected",
32483
+ "timeout",
32484
+ "http",
32485
+ "transport",
32486
+ "unknown"
32487
+ ]);
32488
+ function isArm(kind) {
32489
+ return kind !== void 0 && CREDENTIAL_ARMS.has(kind);
32490
+ }
32491
+ function credentialHint(arm, prefix, hostLabel, source) {
32492
+ switch (arm) {
32493
+ case "ok":
32494
+ return `Credential from '${source}' works: ${hostLabel} accepted an authenticated request. If a real tool still fails, the problem is that tool, not auth.`;
32495
+ case "no_credential":
32496
+ 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.`;
32497
+ case "credential_rejected":
32498
+ 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.`;
32499
+ case "timeout":
32500
+ 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.`;
32501
+ case "http":
32502
+ 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.`;
32503
+ case "transport":
32504
+ return `Could not reach ${hostLabel} at all. Check network egress; the credential itself was never judged.`;
32505
+ default:
32506
+ return `Unexpected failure \u2014 see error.message.`;
32507
+ }
32508
+ }
32509
+ function registerCredentialHealthcheckTool(args) {
32510
+ const { server, prefix, hostLabel, probePath, resolveCredential, probeFn, classifyThrown, hints } = args;
32511
+ const probeUrl = probePath ? `https://${hostLabel}${probePath}` : void 0;
32512
+ server.registerTool(`${prefix}_healthcheck`, {
32513
+ title: "Verify credentials and upstream reachability",
32514
+ 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.`,
32515
+ annotations: {
32516
+ title: "Verify credentials and upstream reachability",
32517
+ readOnlyHint: true,
32518
+ idempotentHint: true,
32519
+ openWorldHint: true
32520
+ },
32521
+ inputSchema: {}
32522
+ }, async () => {
32523
+ let probeStarted = 0;
32524
+ let state;
32525
+ try {
32526
+ state = await resolveCredential();
32527
+ } catch (e) {
32528
+ const classified = classifyThrown?.(e);
32529
+ const result2 = {
32530
+ ok: false,
32531
+ // Still false, and still no source: a classification explains WHY
32532
+ // nothing resolved, it does not invent a credential that did.
32533
+ credential: { source: null, resolved: false },
32534
+ // No `url`: nothing was probed, and naming one implies it was tried.
32535
+ probe: { elapsed_ms: 0 },
32536
+ error: {
32537
+ kind: classified?.kind ?? "no_credential",
32538
+ message: truncateErrorMessage(messageOf(e)),
32539
+ ...classified?.detail !== void 0 ? { detail: classified.detail } : {}
32540
+ },
32541
+ // The hint must follow the KIND beside it. Falling back to
32542
+ // `no_credential`'s copy under a classified kind would state a cause
32543
+ // the kind contradicts — the same disagreement this path exists to
32544
+ // remove. So: an inline hint wins; else the classified arm's own
32545
+ // copy (consumer override first); else, for a kind this module has
32546
+ // no copy for, the neutral `unknown` text rather than one that
32547
+ // asserts a cause; else the unclassified `no_credential` default.
32548
+ 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))
32549
+ };
32550
+ return { content: [{ type: "text", text: JSON.stringify(result2, null, 2) }] };
32551
+ }
32552
+ const credential = {
32553
+ source: state.source,
32554
+ resolved: state.source !== null,
32555
+ ...state.detail !== void 0 ? { detail: state.detail } : {}
32556
+ };
32557
+ if (!credential.resolved) {
32558
+ const result2 = {
32559
+ ok: false,
32560
+ credential,
32561
+ probe: { elapsed_ms: 0 },
32562
+ error: { kind: "no_credential", message: "no credential source resolved" },
32563
+ hint: hints?.no_credential ?? credentialHint("no_credential", prefix, hostLabel, null)
32564
+ };
32565
+ return { content: [{ type: "text", text: JSON.stringify(result2, null, 2) }] };
32566
+ }
32567
+ let arm = "ok";
32568
+ let error51;
32569
+ let status;
32570
+ let customHint;
32571
+ probeStarted = Date.now();
32572
+ try {
32573
+ await probeFn();
32574
+ } catch (e) {
32575
+ status = statusOf(e);
32576
+ const aborted2 = e instanceof Error && e.name === "AbortError";
32577
+ 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";
32578
+ let kind = arm;
32579
+ let detail;
32580
+ const custom2 = classifyThrown?.(e);
32581
+ if (custom2) {
32582
+ kind = custom2.kind;
32583
+ customHint = custom2.hint;
32584
+ detail = custom2.detail;
32585
+ }
32586
+ error51 = {
32587
+ kind,
32588
+ // Redacted AND bounded before it reaches the result: an upstream
32589
+ // failure routinely quotes what it was sent, and a healthcheck is
32590
+ // the tool people paste into a chat when something is broken.
32591
+ message: truncateErrorMessage(messageOf(e)),
32592
+ ...detail !== void 0 ? { detail } : {}
32593
+ };
32594
+ }
32595
+ const result = {
32596
+ ok: error51 === void 0,
32597
+ credential,
32598
+ probe: {
32599
+ ...probeUrl ? { url: probeUrl } : {},
32600
+ elapsed_ms: Date.now() - probeStarted,
32601
+ ...status !== void 0 ? { status } : {}
32602
+ },
32603
+ ...error51 ? { error: error51 } : {},
32604
+ hint: customHint ?? hints?.[arm] ?? credentialHint(arm, prefix, hostLabel, state.source)
32605
+ };
32606
+ return { content: [{ type: "text", text: JSON.stringify(result, null, 2) }] };
32607
+ });
32608
+ }
32609
+
32610
+ // src/tools/health.ts
32611
+ var CLIENT_ERROR_TEXT = {
32612
+ /** From client.ts `requireConfig()` — thrown by `portalHost()`. */
32613
+ noPractice: "I do not know which practice portal to talk to yet",
32614
+ /** From client.ts `throwForStatus()` 401/403, on the HINT — not the message. */
32615
+ sessionExpired: "The portal session has expired",
32616
+ /** From client.ts `requireSession()`, on the MESSAGE. */
32617
+ notSignedIn: "Not signed in to the SimplePractice Client Portal",
32618
+ /** From client.ts `throwForStatus()` 429, on the HINT. */
32619
+ rateLimited: "SimplePractice rate-limits sign-in requests"
32620
+ };
32621
+ function classifySimplePracticeError(err) {
32622
+ const message = err instanceof Error ? err.message : String(err);
32623
+ const hint = typeof err?.hint === "string" ? err.hint : "";
32624
+ const text = `${message}
32625
+ ${hint}`;
32626
+ if (text.includes(CLIENT_ERROR_TEXT.noPractice)) {
32627
+ return {
32628
+ kind: "no_practice_host",
32629
+ 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."
32630
+ };
32631
+ }
32632
+ if (text.includes(CLIENT_ERROR_TEXT.rateLimited)) {
32633
+ return {
32634
+ kind: "rate_limited",
32635
+ 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."
32636
+ };
32637
+ }
32638
+ if (text.includes(CLIENT_ERROR_TEXT.sessionExpired) || text.includes(CLIENT_ERROR_TEXT.notSignedIn)) {
32639
+ return {
32640
+ kind: "session_expired",
32641
+ 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.'
32642
+ };
32643
+ }
32644
+ return void 0;
32645
+ }
32646
+ function registerHealthcheckTools(server, client2) {
32647
+ registerCredentialHealthcheckTool({
32648
+ server,
32649
+ prefix: "simplepractice",
32650
+ hostLabel: "clientsecure.me",
32651
+ probePath: "/environment",
32652
+ resolveCredential: async () => {
32653
+ const session = client2.getSession();
32654
+ return {
32655
+ source: session ? "portal_session" : null,
32656
+ detail: {
32657
+ // `knownPortalHost`, not `portalHost`: the latter throws, and not
32658
+ // knowing the practice is the ordinary state before anyone has
32659
+ // pasted a sign-in link. A healthcheck that throws where it should
32660
+ // report `practice_host: null` fails at the one job it has — saying
32661
+ // which hop is broken.
32662
+ practice_host: client2.knownPortalHost(),
32663
+ // When the session was minted — the fact that explains a connector
32664
+ // that worked yesterday and does not today. Never the cookie.
32665
+ signed_in_at: session?.createdAt ?? null
32666
+ }
32667
+ };
32668
+ },
32669
+ // The cheapest authenticated read in the portal, and the one the client
32670
+ // already uses to resolve the current client id. It changes nothing: no
32671
+ // appointment booked, no document touched.
32672
+ probeFn: () => client2.list("/environment", { include: "currentClient" }),
32673
+ classifyThrown: classifySimplePracticeError
32674
+ });
32675
+ }
32676
+
32336
32677
  // src/index.ts
32337
32678
  var client = new SimplePracticeClient();
32338
32679
  await runMcp({
@@ -32345,6 +32686,7 @@ await runMcp({
32345
32686
  registerAccountTools,
32346
32687
  registerAppointmentTools,
32347
32688
  registerBillingTools,
32348
- registerDocumentTools
32689
+ registerDocumentTools,
32690
+ registerHealthcheckTools
32349
32691
  ]
32350
32692
  });