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
|
@@ -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.
|
|
5
|
+
export const VERSION = '0.4.0'; // x-release-please-version
|
package/dist/view.js
ADDED
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import { minifiedResult, resolveView, stripMediaUrls, viewParam } from '@chrischall/mcp-utils';
|
|
2
|
+
/**
|
|
3
|
+
* The rungs this server honours (`@chrischall/mcp-utils`' `view` vocabulary;
|
|
4
|
+
* `chrischall/workflows` `docs/fleet-conventions.md`, "Response shape").
|
|
5
|
+
*
|
|
6
|
+
* A GROUNDED repo: it already had a field projection, and it was opt-in —
|
|
7
|
+
* `compact: false`, so the caller had to know the slim rung existed and ask
|
|
8
|
+
* for it. An efficiency that has to be requested is one that usually is not,
|
|
9
|
+
* and the caller paying for it is the one least able to know.
|
|
10
|
+
*
|
|
11
|
+
* `compact` is the default now. SimplePractice payloads that have no projection get
|
|
12
|
+
* media stripping instead, which needs no knowledge of the shape.
|
|
13
|
+
*
|
|
14
|
+
* A hand-written projection is NOT then media-stripped. Its field choices were
|
|
15
|
+
* made WITH knowledge of the API; running a blind subtractive rule over its
|
|
16
|
+
* output would let an un-grounded rule overrule a grounded one — which bit
|
|
17
|
+
* viator-mcp, where the projection deliberately keeps a cover image.
|
|
18
|
+
*
|
|
19
|
+
* No `raw` rung: `full` already returns the untouched upstream payload.
|
|
20
|
+
*/
|
|
21
|
+
export const SP_VIEWS = ['compact', 'full'];
|
|
22
|
+
const NOTE = 'compact returns the slim projection where one exists and strips image URLs elsewhere; ' +
|
|
23
|
+
'"full" returns SimplePractice\'s whole records.';
|
|
24
|
+
/** The `view` parameter every read tool in this server takes. */
|
|
25
|
+
export const viewArg = () => viewParam(SP_VIEWS, { note: NOTE });
|
|
26
|
+
/** Is this call asking for the slim rung? Replaces the old `compact` boolean. */
|
|
27
|
+
export function isCompact(view) {
|
|
28
|
+
const rung = resolveView(view, SP_VIEWS);
|
|
29
|
+
return rung === 'compact';
|
|
30
|
+
}
|
|
31
|
+
/**
|
|
32
|
+
* Answer a payload that has NO hand-written projection: compact strips media,
|
|
33
|
+
* full passes through.
|
|
34
|
+
*/
|
|
35
|
+
export function viewResponse(view, data) {
|
|
36
|
+
return minifiedResult(isCompact(view) ? stripMediaUrls(data) : data);
|
|
37
|
+
}
|
package/mint.yaml
CHANGED
|
@@ -22,14 +22,18 @@ summary: >-
|
|
|
22
22
|
env:
|
|
23
23
|
- name: SIMPLEPRACTICE_PRACTICE
|
|
24
24
|
secret: false
|
|
25
|
-
|
|
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
|
|
26
30
|
# No default: this names one specific practice's portal, so any value here
|
|
27
31
|
# would be wrong for everyone but its author.
|
|
28
32
|
help: >-
|
|
29
|
-
|
|
30
|
-
("achievebalancetherapy") or the full host
|
|
31
|
-
("achievebalancetherapy.clientsecure.me").
|
|
32
|
-
link your provider emailed you.
|
|
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.
|
|
33
37
|
|
|
34
38
|
- name: SIMPLEPRACTICE_SESSION_FILE
|
|
35
39
|
secret: false
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "simplepractice-mcp",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.4.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)",
|
|
@@ -29,12 +29,12 @@
|
|
|
29
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",
|
|
30
30
|
"dev": "node --env-file=.env dist/index.js",
|
|
31
31
|
"typecheck": "tsc --noEmit",
|
|
32
|
-
"test": "vitest run",
|
|
33
|
-
"test:coverage": "vitest run --coverage",
|
|
32
|
+
"test": "npm run typecheck && vitest run",
|
|
33
|
+
"test:coverage": "npm run typecheck && vitest run --coverage",
|
|
34
34
|
"test:watch": "vitest"
|
|
35
35
|
},
|
|
36
36
|
"dependencies": {
|
|
37
|
-
"@chrischall/mcp-utils": "^0.
|
|
37
|
+
"@chrischall/mcp-utils": "^0.23.0",
|
|
38
38
|
"@modelcontextprotocol/sdk": "^1.29.0",
|
|
39
39
|
"dotenv": "^17.4.2",
|
|
40
40
|
"zod": "^4.4.3"
|
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.
|
|
9
|
+
"version": "0.4.0",
|
|
10
10
|
"packages": [
|
|
11
11
|
{
|
|
12
12
|
"registryType": "npm",
|
|
13
13
|
"identifier": "simplepractice-mcp",
|
|
14
|
-
"version": "0.
|
|
14
|
+
"version": "0.4.0",
|
|
15
15
|
"transport": {
|
|
16
16
|
"type": "stdio"
|
|
17
17
|
},
|
|
18
18
|
"environmentVariables": [
|
|
19
19
|
{
|
|
20
20
|
"name": "SIMPLEPRACTICE_PRACTICE",
|
|
21
|
-
"description": "
|
|
22
|
-
"isRequired":
|
|
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
|
{
|
|
@@ -21,18 +21,27 @@ The portal has **no password**. SimplePractice emails a one-time link (or a
|
|
|
21
21
|
6-digit PIN), and that is the only way in.
|
|
22
22
|
|
|
23
23
|
1. `simplepractice_session_status` — check first; a session persists between
|
|
24
|
-
runs, so most of the time there is nothing to do.
|
|
25
|
-
|
|
24
|
+
runs, so most of the time there is nothing to do. It also reports which
|
|
25
|
+
practice is in play, and whether that came from a link, the environment, or
|
|
26
|
+
the saved session.
|
|
27
|
+
2. If the user already has the email, skip straight to step 4 — asking for a
|
|
28
|
+
second link when one is in their inbox spends a rate limit for nothing.
|
|
29
|
+
3. `simplepractice_request_sign_in_link` with the user's portal email. It is
|
|
26
30
|
confirm-gated because it sends a real email and the endpoint is rate-limited
|
|
27
31
|
**per address and per IP** — a retry loop locks the user out of the only
|
|
28
|
-
auth path there is. Ask before sending, and never send twice.
|
|
29
|
-
|
|
32
|
+
auth path there is. Ask before sending, and never send twice. If the server
|
|
33
|
+
does not know the practice yet, pass `practice` (the slug, host, or portal
|
|
34
|
+
URL) — otherwise it has no portal to ask.
|
|
35
|
+
4. The user opens the email and gives you the link. Pass it **whole** to
|
|
30
36
|
`simplepractice_verify_sign_in_token` — it takes the token out of the
|
|
31
|
-
fragment
|
|
37
|
+
fragment *and* the practice out of the host, which is why the whole link is
|
|
38
|
+
worth more than the token alone. Tokens are single-use and last 24 hours.
|
|
32
39
|
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
40
|
+
Nothing has to be configured: the practice comes from the link, and the stored
|
|
41
|
+
session remembers it. `SIMPLEPRACTICE_PRACTICE` only pins the server to one
|
|
42
|
+
practice. Two link shapes name no practice and need one already known — the
|
|
43
|
+
mobile variant on the bare `clientsecure.me` apex, and a bare token pasted
|
|
44
|
+
without its link.
|
|
36
45
|
|
|
37
46
|
There is no refresh token. When a session lapses the tools say to sign in
|
|
38
47
|
again; that means another email.
|