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/.claude-plugin/marketplace.json +2 -2
- package/.claude-plugin/plugin.json +1 -1
- package/README.md +38 -13
- package/dist/auth.js +31 -4
- package/dist/bundle.js +401 -59
- package/dist/client.js +142 -27
- package/dist/config.js +41 -6
- package/dist/index.js +5 -2
- package/dist/tools/auth.js +35 -13
- package/dist/tools/health.js +103 -0
- package/dist/version.js +1 -1
- package/mint.yaml +74 -0
- package/package.json +8 -6
- package/server.json +4 -4
- package/skills/simplepractice/SKILL.md +17 -8
- package/skills/simplepractice-fpx/SKILL.md +13 -5
- package/skills/simplepractice-fpx/references/requests.md +18 -11
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
|
}
|
|
@@ -31530,11 +31530,11 @@ function toolAnnotations(opts = {}) {
|
|
|
31530
31530
|
}
|
|
31531
31531
|
|
|
31532
31532
|
// src/version.ts
|
|
31533
|
-
var VERSION = "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
|
|
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
|
|
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
|
-
/**
|
|
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
|
-
|
|
31793
|
-
|
|
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
|
-
|
|
31800
|
-
return this.store.get(
|
|
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.
|
|
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:
|
|
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
|
-
{
|
|
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
|
-
|
|
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,
|
|
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.
|
|
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
|
-
|
|
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
|
|
32011
|
-
|
|
32012
|
-
|
|
32013
|
-
|
|
32014
|
-
|
|
32015
|
-
|
|
32016
|
-
|
|
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
|
});
|