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/client.js CHANGED
@@ -1,6 +1,6 @@
1
1
  import { McpToolError, messageOf, truncateErrorMessage } from '@chrischall/mcp-utils';
2
2
  import { SessionStore } from '@chrischall/mcp-utils/session';
3
- import { API_NAMESPACE, API_VERSION, APPLICATION_BUILD_VERSION, APPLICATION_PLATFORM, readPortalHost, sessionFilePath, } from './config.js';
3
+ import { API_NAMESPACE, API_VERSION, APPLICATION_BUILD_VERSION, APPLICATION_PLATFORM, readPortalHost, resolvePortalHost, sessionFilePath, } from './config.js';
4
4
  import { flattenDocument, formatJsonApiErrors, } from './jsonapi.js';
5
5
  const JSON_API_MEDIA_TYPE = 'application/vnd.api+json';
6
6
  export function buildQuery(params) {
@@ -21,21 +21,11 @@ export function buildQuery(params) {
21
21
  }
22
22
  export class SimplePracticeClient {
23
23
  store;
24
- configError;
25
- host;
26
24
  fetchImpl;
25
+ /** A practice learned at runtime — from a sign-in link, or named on a tool call. */
26
+ adoptedHost = null;
27
27
  constructor(opts = {}) {
28
28
  this.fetchImpl = opts.fetchImpl ?? globalThis.fetch;
29
- const host = readPortalHost();
30
- // Deferred-config-error: the server must still boot (and answer the host's
31
- // install-time tools/list probe) with no configuration; the error surfaces
32
- // on the first tool call instead.
33
- this.configError = host
34
- ? null
35
- : new McpToolError('SIMPLEPRACTICE_PRACTICE is not set, or is not a valid Client Portal address.', {
36
- hint: 'Set SIMPLEPRACTICE_PRACTICE to your practice\'s portal address — either the slug ("achievebalancetherapy") or the full host ("achievebalancetherapy.clientsecure.me"). It is the host in the portal link your provider emailed you.',
37
- });
38
- this.host = host ?? '';
39
29
  this.store =
40
30
  opts.store ??
41
31
  new SessionStore({
@@ -44,19 +34,132 @@ export class SimplePracticeClient {
44
34
  normalizeKey: (key) => key.toLowerCase(),
45
35
  });
46
36
  }
47
- /** Throws the deferred configuration error, if there is one. */
37
+ /**
38
+ * Which practice this server is talking to, and how it found out.
39
+ *
40
+ * Resolved per call rather than fixed at construction, because the practice
41
+ * is usually not known when the process starts: it arrives with the sign-in
42
+ * link. In order:
43
+ *
44
+ * 1. **link** — adopted at runtime from the emailed link (or named on the
45
+ * tool call). The most recent explicit statement of intent, and the only
46
+ * one that can be right when a token is minted for a different practice
47
+ * than the environment names.
48
+ * 2. **environment** — `SIMPLEPRACTICE_PRACTICE`, an explicit pin for
49
+ * someone who wants this server bound to one practice.
50
+ * 3. **session** — the practice of the most recent sign-in. This is what
51
+ * makes the link route survive a restart: sign in once, and every later
52
+ * process knows the practice with no configuration at all.
53
+ */
54
+ resolveHost() {
55
+ if (this.adoptedHost)
56
+ return { host: this.adoptedHost, source: 'link' };
57
+ const configured = readPortalHost();
58
+ if (configured)
59
+ return { host: configured, source: 'environment' };
60
+ const remembered = this.mostRecentSessionHost();
61
+ return remembered ? { host: remembered, source: 'session' } : null;
62
+ }
63
+ /**
64
+ * The practice signed into most recently, by our own `createdAt` rather than
65
+ * `SessionStore`'s active pointer.
66
+ *
67
+ * The two agree right up until a practice is signed into twice, and then
68
+ * they disagree across a restart: `add()` on an existing key leaves the Map
69
+ * entry in its ORIGINAL insertion position, so the in-memory pointer names
70
+ * the practice just added, while a fresh process restores the pointer as the
71
+ * LAST key on disk. Signing in to A, then B, then A again would leave the
72
+ * next process quietly talking to B.
73
+ *
74
+ * `createdAt` is the fact this fallback actually means, and unlike the
75
+ * pointer it survives the restart.
76
+ */
77
+ mostRecentSessionHost() {
78
+ let newest = null;
79
+ for (const session of this.store.list()) {
80
+ if (!newest || session.createdAt > newest.createdAt)
81
+ newest = session;
82
+ }
83
+ return newest?.host ?? null;
84
+ }
85
+ /** The practice host, or `null` when none is known yet. Never throws. */
86
+ knownPortalHost() {
87
+ return this.resolveHost()?.host ?? null;
88
+ }
89
+ /** How the practice was determined, or `null` when it has not been. */
90
+ practiceSource() {
91
+ return this.resolveHost()?.source ?? null;
92
+ }
93
+ /**
94
+ * The host a practice address names, WITHOUT adopting it.
95
+ *
96
+ * Validated through the same `resolvePortalHost` the environment goes
97
+ * through, so a link outside `*.clientsecure.me` cannot redirect a token.
98
+ *
99
+ * Separate from {@link adoptPracticeHost} so a caller that only wants to
100
+ * *name* the practice — a dry run reporting what it would do — can do that
101
+ * without the side effect. Answering a question should not move the server.
102
+ */
103
+ validatePracticeHost(raw) {
104
+ const host = resolvePortalHost(raw);
105
+ if (!host) {
106
+ throw new McpToolError(`"${raw}" is not a SimplePractice Client Portal address.`, {
107
+ hint: 'A portal address is a single practice under clientsecure.me — the slug ("achievebalancetherapy") or the whole host ("achievebalancetherapy.clientsecure.me").',
108
+ });
109
+ }
110
+ return host;
111
+ }
112
+ /**
113
+ * Point this server at a practice for the rest of the process — what the
114
+ * sign-in link's own host feeds.
115
+ */
116
+ adoptPracticeHost(raw) {
117
+ this.adoptedHost = this.validatePracticeHost(raw);
118
+ return this.adoptedHost;
119
+ }
120
+ /**
121
+ * Adopt `raw`'s practice for the duration of `fn`, and keep it only if `fn`
122
+ * succeeds.
123
+ *
124
+ * Sign-in links are single-use, so a failed exchange is the ordinary case,
125
+ * not the exception. Letting a failed attempt stick would leave someone who
126
+ * pasted a stale link for practice B pointed at B for the life of the
127
+ * process — and their intact session for practice A would report "Not signed
128
+ * in" until a restart. A link only earns the practice by working.
129
+ */
130
+ async withPracticeHost(raw, fn) {
131
+ const previous = this.adoptedHost;
132
+ this.adoptPracticeHost(raw);
133
+ try {
134
+ return await fn();
135
+ }
136
+ catch (err) {
137
+ this.adoptedHost = previous;
138
+ throw err;
139
+ }
140
+ }
141
+ /**
142
+ * The practice host, or the deferred error explaining that none is known.
143
+ *
144
+ * Deferred rather than thrown at construction: the server must still boot
145
+ * (and answer the host's install-time tools/list probe) knowing no practice,
146
+ * which is now the ordinary first-run state rather than a misconfiguration.
147
+ */
48
148
  requireConfig() {
49
- if (this.configError)
50
- throw this.configError;
51
- return this.host;
149
+ const host = this.knownPortalHost();
150
+ if (!host) {
151
+ throw new McpToolError('I do not know which practice portal to talk to yet.', {
152
+ hint: 'Paste the sign-in link your provider emailed into simplepractice_verify_sign_in_token — 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.',
153
+ });
154
+ }
155
+ return host;
52
156
  }
53
157
  portalHost() {
54
158
  return this.requireConfig();
55
159
  }
56
160
  getSession() {
57
- if (this.configError)
58
- return null;
59
- return this.store.get(this.host);
161
+ const host = this.knownPortalHost();
162
+ return host ? this.store.get(host) : null;
60
163
  }
61
164
  saveSession(cookie) {
62
165
  const host = this.requireConfig();
@@ -65,8 +168,11 @@ export class SimplePracticeClient {
65
168
  return session;
66
169
  }
67
170
  clearSession() {
68
- const host = this.requireConfig();
69
- return this.store.remove(host);
171
+ const host = this.knownPortalHost();
172
+ // Not knowing the practice is the same outcome as having no session for
173
+ // it: nothing to sign out of. Throwing would make sign-out the one tool
174
+ // that fails when it has nothing to do.
175
+ return host ? this.store.remove(host) : false;
70
176
  }
71
177
  requireSession() {
72
178
  const session = this.getSession();
@@ -76,7 +182,7 @@ export class SimplePracticeClient {
76
182
  // the two-step remediation below is worth more here than the class name —
77
183
  // nothing in this server discriminates on the type.
78
184
  throw new McpToolError('Not signed in to the SimplePractice Client Portal.', {
79
- 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.',
185
+ hint: 'Pass the sign-in link SimplePractice emailed to simplepractice_verify_sign_in_token — 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.',
80
186
  });
81
187
  }
82
188
  return session;
@@ -111,7 +217,9 @@ export class SimplePracticeClient {
111
217
  });
112
218
  }
113
219
  catch (err) {
114
- throw new McpToolError(`Could not reach ${host}: ${truncateErrorMessage(messageOf(err))}`, { hint: 'Check the practice address in SIMPLEPRACTICE_PRACTICE and your network connection.' });
220
+ throw new McpToolError(`Could not reach ${host}: ${truncateErrorMessage(messageOf(err))}`, {
221
+ hint: `Check your network connection, and that ${host} is really your practice's portal — simplepractice_session_status reports where that address came from.`,
222
+ });
115
223
  }
116
224
  const raw = await response.text();
117
225
  let document = null;
@@ -122,7 +230,7 @@ export class SimplePracticeClient {
122
230
  document = null;
123
231
  }
124
232
  if (!response.ok)
125
- this.throwForStatus(response.status, document);
233
+ this.throwForStatus(response.status, document, path);
126
234
  if (document === null) {
127
235
  // The portal's SPA catch-all answers 200 text/html for ANY path the API
128
236
  // does not define, so this is as often a wrong path as a dead session —
@@ -150,11 +258,18 @@ export class SimplePracticeClient {
150
258
  const { document } = await this.request(path, { query });
151
259
  return flattenDocument(document);
152
260
  }
153
- throwForStatus(status, document) {
261
+ throwForStatus(status, document, path) {
154
262
  const message = formatJsonApiErrors(document, status);
263
+ // A 401 on the sign-in endpoints means the TOKEN was bad — most often
264
+ // already used, since they are single-use. Telling the caller their
265
+ // session expired there is simply the wrong diagnosis: they have no
266
+ // session yet, which is why they are signing in.
267
+ const isSignIn = path.startsWith('/sessions/') || path.startsWith('/sign-in-tokens');
155
268
  if (status === 401 || status === 403) {
156
269
  throw new McpToolError(message, {
157
- hint: 'The portal session has expired — there is no refresh token, so sign in again with simplepractice_request_sign_in_link.',
270
+ hint: isSignIn
271
+ ? 'Sign-in links and PINs are single-use and last 24 hours. Request a fresh one with simplepractice_request_sign_in_link.'
272
+ : 'The portal session has expired — there is no refresh token, so sign in again with simplepractice_request_sign_in_link.',
158
273
  });
159
274
  }
160
275
  if (status === 429) {
package/dist/config.js CHANGED
@@ -15,13 +15,17 @@ export const APPLICATION_PLATFORM = 'web';
15
15
  export const API_NAMESPACE = 'client-portal-api';
16
16
  const PORTAL_DOMAIN = 'clientsecure.me';
17
17
  /**
18
- * Resolve the practice's portal host from `SIMPLEPRACTICE_PRACTICE`, which
19
- * accepts either the bare slug (`achievebalancetherapy`) or the full host
20
- * (`achievebalancetherapy.clientsecure.me`) — users copy whichever half of the
21
- * link they happen to have.
18
+ * Resolve a practice's portal host from anything a user might hand over: the
19
+ * bare slug (`achievebalancetherapy`), the full host, or a pasted URL — they
20
+ * copy whichever half of the link they happen to have.
22
21
  *
23
- * Returns `null` rather than throwing so the server still boots without
24
- * configuration and reports the problem on the first tool call.
22
+ * The single gate on which hosts this server will talk to, so both routes in
23
+ * (`SIMPLEPRACTICE_PRACTICE` and {@link practiceHostFromLink}) go through it:
24
+ * a value outside `*.clientsecure.me`, or a nested subdomain under it, would
25
+ * otherwise be enough to aim a session cookie at a stranger's domain.
26
+ *
27
+ * Returns `null` rather than throwing so the server still boots knowing no
28
+ * practice — the ordinary first-run state — and reports it on the first call.
25
29
  */
26
30
  export function resolvePortalHost(raw) {
27
31
  if (!raw)
@@ -42,6 +46,37 @@ export function resolvePortalHost(raw) {
42
46
  return null;
43
47
  return value;
44
48
  }
49
+ /**
50
+ * The practice named by an emailed sign-in link.
51
+ *
52
+ * The link is `https://<practice>.clientsecure.me/sign-in/token#<TOKEN>`, so
53
+ * the practice is already in the user's hands the moment they have a link to
54
+ * paste — which is why `SIMPLEPRACTICE_PRACTICE` is an override rather than a
55
+ * requirement.
56
+ *
57
+ * Returns `null` when the link names no practice, which is not an error:
58
+ * SimplePractice's mobile variant points at the bare apex
59
+ * (`https://clientsecure.me/client-portal-api/sign-in/token#<TOKEN>`), and the
60
+ * caller may equally have pasted a bare token.
61
+ *
62
+ * Only text with a `#` is considered — that is the shape of a link, and a bare
63
+ * TOKEN must never be read as a host: `resolvePortalHost` slug-expands, so
64
+ * `abc123` would otherwise resolve to `abc123.clientsecure.me` and the sign-in
65
+ * POST would carry the token to a stranger's subdomain. The same reasoning
66
+ * rules out slug-expanding the text in front of the fragment, so a link has to
67
+ * spell out a host that is already under the portal apex.
68
+ */
69
+ export function practiceHostFromLink(raw) {
70
+ if (!raw)
71
+ return null;
72
+ const hash = raw.indexOf('#');
73
+ if (hash < 0)
74
+ return null;
75
+ const prefix = raw.slice(0, hash).trim();
76
+ if (!prefix.includes('.'))
77
+ return null;
78
+ return resolvePortalHost(prefix);
79
+ }
45
80
  export function readPortalHost() {
46
81
  return resolvePortalHost(readEnvVar('SIMPLEPRACTICE_PRACTICE'));
47
82
  }
package/dist/index.js CHANGED
@@ -7,9 +7,11 @@ import { registerAccountTools } from './tools/account.js';
7
7
  import { registerAppointmentTools } from './tools/appointments.js';
8
8
  import { registerBillingTools } from './tools/billing.js';
9
9
  import { registerDocumentTools } from './tools/documents.js';
10
+ import { registerHealthcheckTools } from './tools/health.js';
10
11
  // Built in the caller so the deferred-config-error pattern holds: the server
11
- // still boots, and answers the host's install-time tools/list probe, with no
12
- // SIMPLEPRACTICE_PRACTICE set. The error surfaces on the first tool call.
12
+ // still boots, and answers the host's install-time tools/list probe, knowing no
13
+ // practice — which is the ordinary first-run state, since the practice arrives
14
+ // with the sign-in link rather than from the environment.
13
15
  const client = new SimplePracticeClient();
14
16
  await runMcp({
15
17
  name: 'simplepractice-mcp',
@@ -22,5 +24,6 @@ await runMcp({
22
24
  registerAppointmentTools,
23
25
  registerBillingTools,
24
26
  registerDocumentTools,
27
+ registerHealthcheckTools,
25
28
  ],
26
29
  });
@@ -3,16 +3,24 @@ import { textResult, toolAnnotations, schemaConfirm } from '@chrischall/mcp-util
3
3
  import { requestSignInLink, verifySignInPin, verifySignInToken } from '../auth.js';
4
4
  export function registerAuthTools(server, client) {
5
5
  server.registerTool('simplepractice_session_status', {
6
- description: 'Report whether this server holds a Client Portal session, and for which practice. Reads local state only — makes no network call.',
6
+ 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 — makes no network call.',
7
7
  annotations: toolAnnotations({ readOnly: true }),
8
8
  inputSchema: {},
9
9
  }, async () => {
10
- const host = client.portalHost();
10
+ const host = client.knownPortalHost();
11
11
  const session = client.getSession();
12
12
  return textResult({
13
13
  practiceHost: host,
14
+ // Not knowing the practice yet is a state to report, not an error:
15
+ // it is what a first run looks like before anyone has pasted a link.
16
+ practiceSource: client.practiceSource(),
14
17
  signedIn: session !== null,
15
18
  signedInAt: session?.createdAt ?? null,
19
+ ...(host
20
+ ? {}
21
+ : {
22
+ next: 'Paste the sign-in link your provider emailed into simplepractice_verify_sign_in_token — its address names the practice. Or set SIMPLEPRACTICE_PRACTICE to pin this server to one.',
23
+ }),
16
24
  });
17
25
  });
18
26
  server.registerTool('simplepractice_request_sign_in_link', {
@@ -20,29 +28,43 @@ export function registerAuthTools(server, client) {
20
28
  annotations: toolAnnotations({ readOnly: false, idempotent: false }),
21
29
  inputSchema: {
22
30
  email: z.string().email().describe('The email address the Client Portal is registered to.'),
31
+ practice: z
32
+ .string()
33
+ .min(1)
34
+ .optional()
35
+ .describe('The practice whose portal to sign in to — 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.'),
23
36
  confirm: schemaConfirm,
24
37
  },
25
- }, async ({ email, confirm }) => {
38
+ }, async ({ email, practice, confirm }) => {
26
39
  if (!confirm) {
27
40
  return textResult({
28
41
  dryRun: true,
29
42
  wouldSend: 'a Client Portal sign-in email',
30
43
  to: email,
31
- practiceHost: client.portalHost(),
44
+ // Named, not adopted. A dry run sends nothing, so it must not move
45
+ // the server either — silently overriding a SIMPLEPRACTICE_PRACTICE
46
+ // pin is not something an inert preview gets to do.
47
+ practiceHost: practice ? client.validatePracticeHost(practice) : client.portalHost(),
32
48
  note: 'Re-run with confirm:true to actually send it. Do not retry a failed send — SimplePractice locks out repeated sign-in requests.',
33
49
  });
34
50
  }
35
- const { expiresIn } = await requestSignInLink(client, email);
36
- return textResult({
37
- sent: true,
38
- to: email,
39
- expiresIn,
40
- next: 'Open the email, copy the sign-in link (or just the part after the "#"), and pass it to simplepractice_verify_sign_in_token.',
41
- note: 'This response is the same whether or not the address has an account.',
42
- });
51
+ const send = async () => {
52
+ const { expiresIn } = await requestSignInLink(client, email);
53
+ return textResult({
54
+ sent: true,
55
+ to: email,
56
+ practiceHost: client.portalHost(),
57
+ expiresIn,
58
+ next: 'Open the email, copy the sign-in link (or just the part after the "#"), and pass it to simplepractice_verify_sign_in_token.',
59
+ note: 'This response is the same whether or not the address has an account.',
60
+ });
61
+ };
62
+ // Scoped exactly as the sign-in exchange is: the practice sticks only if
63
+ // the send works, so a rejected send leaves the previous one standing.
64
+ return practice ? client.withPracticeHost(practice, send) : send();
43
65
  });
44
66
  server.registerTool('simplepractice_verify_sign_in_token', {
45
- 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.',
67
+ 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.',
46
68
  annotations: toolAnnotations({ readOnly: false, idempotent: false }),
47
69
  inputSchema: {
48
70
  link: z
@@ -0,0 +1,103 @@
1
+ import { registerCredentialHealthcheckTool } from '@chrischall/mcp-utils/healthcheck';
2
+ /**
3
+ * `simplepractice_healthcheck` — the one call that answers "is this connector
4
+ * working?", and the only tool here that reports a failure as DATA rather
5
+ * than throwing.
6
+ *
7
+ * `simplepractice_session_status` is NOT this, and the difference is the
8
+ * reason this exists: its own description says it "reads local state only —
9
+ * makes no network call". So it reports `signedIn: true` for a session the
10
+ * portal has already killed. That is the worst shape of health signal — a
11
+ * confident yes that is wrong precisely when someone is asking because
12
+ * something is broken.
13
+ *
14
+ * This makes one authenticated round-trip, so `ok: true` means the portal
15
+ * accepted the session just now, not that a cookie exists on disk.
16
+ */
17
+ /**
18
+ * Strings this classifier matches, kept as named constants because they are a
19
+ * CONTRACT WITH client.ts, not free text. `tests/health.test.ts` asserts each
20
+ * one still appears in that file: the first version of this classifier matched
21
+ * invented text that no code path ever produced, and every unit test passed
22
+ * because the tests fabricated errors to match the classifier instead of the
23
+ * client.
24
+ */
25
+ export const CLIENT_ERROR_TEXT = {
26
+ /** From client.ts `requireConfig()` — thrown by `portalHost()`. */
27
+ noPractice: 'I do not know which practice portal to talk to yet',
28
+ /** From client.ts `throwForStatus()` 401/403, on the HINT — not the message. */
29
+ sessionExpired: 'The portal session has expired',
30
+ /** From client.ts `requireSession()`, on the MESSAGE. */
31
+ notSignedIn: 'Not signed in to the SimplePractice Client Portal',
32
+ /** From client.ts `throwForStatus()` 429, on the HINT. */
33
+ rateLimited: 'SimplePractice rate-limits sign-in requests',
34
+ };
35
+ export function classifySimplePracticeError(err) {
36
+ // The client raises McpToolError, which carries its remediation on `.hint`
37
+ // and a formatted JSON:API summary on `.message`. A 401 says nothing useful
38
+ // in the message, so BOTH must be searched — matching only `.message` is
39
+ // exactly the bug the auto-review on #13 caught.
40
+ const message = err instanceof Error ? err.message : String(err);
41
+ const hint = typeof err?.hint === 'string' ? err.hint : '';
42
+ const text = `${message}\n${hint}`;
43
+ if (text.includes(CLIENT_ERROR_TEXT.noPractice)) {
44
+ return {
45
+ kind: 'no_practice_host',
46
+ hint: 'No practice known yet. Paste the sign-in link your provider emailed into ' +
47
+ 'simplepractice_verify_sign_in_token — its address names the practice, and this server remembers ' +
48
+ 'it afterwards. SIMPLEPRACTICE_PRACTICE is optional, and only pins the server to one practice.',
49
+ };
50
+ }
51
+ // Rate limiting is checked BEFORE the session arms: a 429 is the far side
52
+ // working correctly, and this one is punishing — retrying can lock the
53
+ // account out of the only auth path it has.
54
+ if (text.includes(CLIENT_ERROR_TEXT.rateLimited)) {
55
+ return {
56
+ kind: 'rate_limited',
57
+ hint: 'SimplePractice rate-limits sign-in requests per email and per IP. The session is not necessarily bad — ' +
58
+ 'do NOT retry, and wait before requesting another link.',
59
+ };
60
+ }
61
+ if (text.includes(CLIENT_ERROR_TEXT.sessionExpired) || text.includes(CLIENT_ERROR_TEXT.notSignedIn)) {
62
+ return {
63
+ kind: 'session_expired',
64
+ hint: 'The portal rejected the stored session. There is no refresh token, so it cannot be renewed silently: ' +
65
+ 'run simplepractice_request_sign_in_link, then pass the part of the emailed link after the "#" to ' +
66
+ 'simplepractice_verify_sign_in_token.',
67
+ };
68
+ }
69
+ return undefined;
70
+ }
71
+ export function registerHealthcheckTools(server, client) {
72
+ registerCredentialHealthcheckTool({
73
+ server,
74
+ prefix: 'simplepractice',
75
+ hostLabel: 'clientsecure.me',
76
+ probePath: '/environment',
77
+ resolveCredential: async () => {
78
+ const session = client.getSession();
79
+ // `source: null` short-circuits the probe. Without a session there is
80
+ // nothing to test, and probing anyway returns a failure that reads like
81
+ // a rejected session rather than an absent one.
82
+ return {
83
+ source: session ? 'portal_session' : null,
84
+ detail: {
85
+ // `knownPortalHost`, not `portalHost`: the latter throws, and not
86
+ // knowing the practice is the ordinary state before anyone has
87
+ // pasted a sign-in link. A healthcheck that throws where it should
88
+ // report `practice_host: null` fails at the one job it has — saying
89
+ // which hop is broken.
90
+ practice_host: client.knownPortalHost(),
91
+ // When the session was minted — the fact that explains a connector
92
+ // that worked yesterday and does not today. Never the cookie.
93
+ signed_in_at: session?.createdAt ?? null,
94
+ },
95
+ };
96
+ },
97
+ // The cheapest authenticated read in the portal, and the one the client
98
+ // already uses to resolve the current client id. It changes nothing: no
99
+ // appointment booked, no document touched.
100
+ probeFn: () => client.list('/environment', { include: 'currentClient' }),
101
+ classifyThrown: classifySimplePracticeError,
102
+ });
103
+ }
package/dist/version.js CHANGED
@@ -2,4 +2,4 @@
2
2
  * Single source of truth for the server version. release-please rewrites the
3
3
  * literal below; every other file imports VERSION rather than repeating it.
4
4
  */
5
- export const VERSION = '0.1.0'; // x-release-please-version
5
+ export const VERSION = '0.3.0'; // x-release-please-version
package/mint.yaml ADDED
@@ -0,0 +1,74 @@
1
+ # How this MCP wants to be hosted on mcp-host (see mcp-host/docs/HOSTING-MANIFEST.md).
2
+ #
3
+ # The FILENAME is load-bearing: mcp-host matches it exactly, against
4
+ # MINT_MANIFEST_FILE in packages/core — `mint.yaml`, with the earlier
5
+ # `mcp-host.yaml` still read as a deprecated fallback. A name the reader does
6
+ # not expect is not an error; it is simply never found, and the registration
7
+ # preview falls back to inference with nothing to notice. If this file ever
8
+ # looks ignored, check that constant first rather than this comment.
9
+ #
10
+ # This file is a PROPOSAL, never an application. It is read at the resolved pin
11
+ # and rendered for a human; nothing here changes a registration until an owner
12
+ # saves it through the ordinary write path. It is NOT covered by the tarball
13
+ # integrity check, so it carries no secrets and nothing that decides what runs.
14
+ version: 1
15
+
16
+ name: SimplePractice
17
+ slug: simplepractice
18
+ summary: >-
19
+ Read a SimplePractice Client Portal as one of a practice's clients —
20
+ appointments, billing, documents to sign, and announcements.
21
+
22
+ env:
23
+ - name: SIMPLEPRACTICE_PRACTICE
24
+ secret: false
25
+ # Optional: the sign-in link a provider emails is
26
+ # https://<practice>.clientsecure.me/sign-in/token#<TOKEN>, so pasting it
27
+ # into simplepractice_verify_sign_in_token tells the server the practice —
28
+ # and the stored session remembers it. This only pins the server to one.
29
+ required: false
30
+ # No default: this names one specific practice's portal, so any value here
31
+ # would be wrong for everyone but its author.
32
+ help: >-
33
+ Optional. Pins this server to one practice's Client Portal — either the
34
+ slug ("achievebalancetherapy") or the full host
35
+ ("achievebalancetherapy.clientsecure.me"). Leave it unset and the
36
+ practice is taken from the sign-in link your provider emailed you.
37
+
38
+ - name: SIMPLEPRACTICE_SESSION_FILE
39
+ secret: false
40
+ required: false
41
+ # No default: the built-in one already resolves under $HOME, which the
42
+ # data dir below persists. Naming a path here would only risk pointing the
43
+ # session outside it.
44
+ help: >-
45
+ Where the signed-in portal session is kept (written 0600). Defaults to
46
+ $HOME/.simplepractice-mcp/session.json; leave unset unless you need it
47
+ somewhere specific.
48
+
49
+ state:
50
+ dataDir: true
51
+ reason: >-
52
+ The portal session cookie is the only credential this server has, and
53
+ SimplePractice issues no refresh token. It is persisted under
54
+ $HOME/.simplepractice-mcp/session.json. Without a data dir every cold start
55
+ loses it, and recovering means a human requesting a fresh magic-link email
56
+ and pasting the link back in — so on a scale-to-zero machine the connector
57
+ would demand an inbox round-trip after nearly every idle period.
58
+
59
+ identity:
60
+ # True because this server authenticates as its CALLER, not as the
61
+ # registration: there is no credential in the environment at all, and each
62
+ # caller signs in through the tools with a magic link mailed to their own
63
+ # address. One shared child would leave the first caller's portal session in
64
+ # place for every later caller to read — someone else's medical record. The
65
+ # uid and state dir per caller are worth that.
66
+ perUserChild: true
67
+
68
+ egress:
69
+ # Every request goes to the practice's own portal host; the client builds
70
+ # exactly one URL template and there is no analytics, no CDN and no browser
71
+ # bridge. The bare apex is never called — only the practice subdomain.
72
+ # Honoured on the isolated tier, a proposal elsewhere.
73
+ allow:
74
+ - "*.clientsecure.me"
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "simplepractice-mcp",
3
- "version": "0.1.0",
3
+ "version": "0.3.0",
4
4
  "license": "MIT",
5
5
  "mcpName": "io.github.chrischall/simplepractice-mcp",
6
6
  "description": "SimplePractice Client Portal MCP server for Claude — developed and maintained by AI (Claude Code)",
@@ -21,19 +21,20 @@
21
21
  ".claude-plugin",
22
22
  "skills",
23
23
  ".mcp.json",
24
- "server.json"
24
+ "server.json",
25
+ "mint.yaml"
25
26
  ],
26
27
  "scripts": {
27
28
  "build": "tsc && npm run bundle",
28
29
  "bundle": "esbuild src/index.ts --bundle --platform=node --format=esm --external:dotenv --banner:js='import { createRequire as __createRequire } from \"module\"; const require = __createRequire(import.meta.url);' --outfile=dist/bundle.js",
29
30
  "dev": "node --env-file=.env dist/index.js",
30
31
  "typecheck": "tsc --noEmit",
31
- "test": "vitest run",
32
- "test:coverage": "vitest run --coverage",
32
+ "test": "npm run typecheck && vitest run",
33
+ "test:coverage": "npm run typecheck && vitest run --coverage",
33
34
  "test:watch": "vitest"
34
35
  },
35
36
  "dependencies": {
36
- "@chrischall/mcp-utils": "^0.15.0",
37
+ "@chrischall/mcp-utils": "^0.19.3",
37
38
  "@modelcontextprotocol/sdk": "^1.29.0",
38
39
  "dotenv": "^17.4.2",
39
40
  "zod": "^4.4.3"
@@ -43,6 +44,7 @@
43
44
  "@vitest/coverage-v8": "^4.1.7",
44
45
  "esbuild": "^0.28.0",
45
46
  "typescript": "^7.0.2",
46
- "vitest": "^4.1.7"
47
+ "vitest": "^4.1.7",
48
+ "yaml": "^2.9.0"
47
49
  }
48
50
  }
package/server.json CHANGED
@@ -6,20 +6,20 @@
6
6
  "url": "https://github.com/chrischall/simplepractice-mcp",
7
7
  "source": "github"
8
8
  },
9
- "version": "0.1.0",
9
+ "version": "0.3.0",
10
10
  "packages": [
11
11
  {
12
12
  "registryType": "npm",
13
13
  "identifier": "simplepractice-mcp",
14
- "version": "0.1.0",
14
+ "version": "0.3.0",
15
15
  "transport": {
16
16
  "type": "stdio"
17
17
  },
18
18
  "environmentVariables": [
19
19
  {
20
20
  "name": "SIMPLEPRACTICE_PRACTICE",
21
- "description": "Your practice's Client Portal address — the slug (\"achievebalancetherapy\") or the full host (\"achievebalancetherapy.clientsecure.me\").",
22
- "isRequired": true,
21
+ "description": "Optional. Pins the server to one practice — the slug (\"achievebalancetherapy\") or the full host. Unset, the practice comes from your emailed sign-in link.",
22
+ "isRequired": false,
23
23
  "format": "string"
24
24
  },
25
25
  {