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/.claude-plugin/marketplace.json +2 -2
- package/.claude-plugin/plugin.json +1 -1
- package/README.md +37 -9
- package/dist/auth.js +31 -4
- package/dist/bundle.js +531 -87
- package/dist/client.js +132 -24
- package/dist/config.js +41 -6
- package/dist/index.js +5 -2
- package/dist/tools/account.js +12 -2
- package/dist/tools/appointments.js +6 -8
- package/dist/tools/auth.js +50 -19
- package/dist/tools/billing.js +29 -8
- package/dist/tools/documents.js +39 -11
- package/dist/tools/health.js +103 -0
- package/dist/version.js +1 -1
- package/dist/view.js +37 -0
- package/mint.yaml +9 -5
- package/package.json +4 -4
- package/server.json +4 -4
- package/skills/simplepractice/SKILL.md +17 -8
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 =
|
|
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
|
|
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
|
|
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:
|
|
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((
|
|
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((
|
|
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
|
-
|
|
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((
|
|
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(
|
|
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((
|
|
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((
|
|
31265
|
+
return new Promise((resolve3) => {
|
|
31266
31266
|
const json2 = serializeMessage(message);
|
|
31267
31267
|
if (this._stdout.write(json2)) {
|
|
31268
|
-
|
|
31268
|
+
resolve3();
|
|
31269
31269
|
} else {
|
|
31270
|
-
this._stdout.once("drain",
|
|
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/
|
|
31330
|
-
|
|
31331
|
-
|
|
31332
|
-
|
|
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.
|
|
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
|
|
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
|
|
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
|
-
/**
|
|
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
|
-
|
|
31793
|
-
|
|
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
|
-
|
|
31800
|
-
return this.store.get(
|
|
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.
|
|
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:
|
|
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
|
-
{
|
|
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
|
-
|
|
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,
|
|
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.
|
|
32171
|
+
const host = client2.knownPortalHost();
|
|
31983
32172
|
const session = client2.getSession();
|
|
31984
|
-
return
|
|
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
|
|
32201
|
+
return minifiedResult({
|
|
32004
32202
|
dryRun: true,
|
|
32005
32203
|
wouldSend: "a Client Portal sign-in email",
|
|
32006
32204
|
to: email3,
|
|
32007
|
-
|
|
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
|
|
32012
|
-
|
|
32013
|
-
|
|
32014
|
-
|
|
32015
|
-
|
|
32016
|
-
|
|
32017
|
-
|
|
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 }) =>
|
|
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 }) =>
|
|
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 () =>
|
|
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
|
|
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
|
-
|
|
32352
|
+
view: viewArg()
|
|
32136
32353
|
}
|
|
32137
32354
|
},
|
|
32138
|
-
async ({ status, page, pageSize,
|
|
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
|
|
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:
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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: {
|
|
32518
|
+
inputSchema: {
|
|
32519
|
+
id: external_exports.string().min(1).describe("The document request id."),
|
|
32520
|
+
view: viewArg()
|
|
32521
|
+
}
|
|
32292
32522
|
},
|
|
32293
|
-
|
|
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
|
|
32297
|
-
return
|
|
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
|
|
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
|
|
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
|
});
|