simplepractice-mcp 0.1.0 → 0.2.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 +9 -4
- package/dist/bundle.js +5 -4
- package/dist/client.js +10 -3
- package/dist/version.js +1 -1
- package/mint.yaml +70 -0
- package/package.json +5 -3
- package/server.json +2 -2
- package/skills/simplepractice-fpx/SKILL.md +13 -5
- package/skills/simplepractice-fpx/references/requests.md +18 -11
|
@@ -6,14 +6,14 @@
|
|
|
6
6
|
},
|
|
7
7
|
"metadata": {
|
|
8
8
|
"description": "SimplePractice Client Portal tools for Claude",
|
|
9
|
-
"version": "0.
|
|
9
|
+
"version": "0.2.0"
|
|
10
10
|
},
|
|
11
11
|
"plugins": [
|
|
12
12
|
{
|
|
13
13
|
"name": "simplepractice",
|
|
14
14
|
"source": "./",
|
|
15
15
|
"description": "Read a SimplePractice Client Portal — appointments, invoices and superbills, documents to sign, and practice announcements. Signs in with the portal's own passwordless emailed link; requests go straight to the portal's JSON:API over your own session.",
|
|
16
|
-
"version": "0.
|
|
16
|
+
"version": "0.2.0",
|
|
17
17
|
"author": {
|
|
18
18
|
"name": "Chris Chall",
|
|
19
19
|
"url": "https://github.com/chrischall"
|
package/README.md
CHANGED
|
@@ -48,10 +48,15 @@ The Client Portal has **no password**. SimplePractice emails a one-time link
|
|
|
48
48
|
3. `simplepractice_verify_sign_in_token { link }` — pass the whole link; the
|
|
49
49
|
token is its `#` fragment and the tool extracts it.
|
|
50
50
|
|
|
51
|
-
Links are single-use
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
51
|
+
Links are single-use — replaying one answers
|
|
52
|
+
`401 "Authorization has already been used or expired"` — and last 24 hours. The
|
|
53
|
+
request endpoint is rate-limited per address **and** per IP, which is why
|
|
54
|
+
sending is confirm-gated: a retry loop locks you out of the only way in. There
|
|
55
|
+
is no refresh token; when the session lapses, you sign in again.
|
|
56
|
+
|
|
57
|
+
The whole chain is verified end to end against a live portal — request, the
|
|
58
|
+
emailed link, the exchange returning `verified` plus a session cookie, and an
|
|
59
|
+
authenticated read with that new session.
|
|
55
60
|
|
|
56
61
|
Because that flow needs nothing but HTTP and your inbox, this server has no
|
|
57
62
|
browser dependency and can run anywhere.
|
package/dist/bundle.js
CHANGED
|
@@ -31530,7 +31530,7 @@ function toolAnnotations(opts = {}) {
|
|
|
31530
31530
|
}
|
|
31531
31531
|
|
|
31532
31532
|
// src/version.ts
|
|
31533
|
-
var VERSION = "0.
|
|
31533
|
+
var VERSION = "0.2.0";
|
|
31534
31534
|
|
|
31535
31535
|
// node_modules/@chrischall/mcp-utils/dist/session/index.js
|
|
31536
31536
|
import { existsSync, readFileSync, writeFileSync, mkdirSync, chmodSync, renameSync } from "node:fs";
|
|
@@ -31857,7 +31857,7 @@ var SimplePracticeClient = class {
|
|
|
31857
31857
|
} catch {
|
|
31858
31858
|
document = null;
|
|
31859
31859
|
}
|
|
31860
|
-
if (!response.ok) this.throwForStatus(response.status, document);
|
|
31860
|
+
if (!response.ok) this.throwForStatus(response.status, document, path);
|
|
31861
31861
|
if (document === null) {
|
|
31862
31862
|
throw new McpToolError(
|
|
31863
31863
|
`SimplePractice returned HTML rather than JSON for ${path}.`,
|
|
@@ -31884,11 +31884,12 @@ var SimplePracticeClient = class {
|
|
|
31884
31884
|
const { document } = await this.request(path, { query });
|
|
31885
31885
|
return flattenDocument(document);
|
|
31886
31886
|
}
|
|
31887
|
-
throwForStatus(status, document) {
|
|
31887
|
+
throwForStatus(status, document, path) {
|
|
31888
31888
|
const message = formatJsonApiErrors(document, status);
|
|
31889
|
+
const isSignIn = path.startsWith("/sessions/") || path.startsWith("/sign-in-tokens");
|
|
31889
31890
|
if (status === 401 || status === 403) {
|
|
31890
31891
|
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."
|
|
31892
|
+
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
31893
|
});
|
|
31893
31894
|
}
|
|
31894
31895
|
if (status === 429) {
|
package/dist/client.js
CHANGED
|
@@ -122,7 +122,7 @@ export class SimplePracticeClient {
|
|
|
122
122
|
document = null;
|
|
123
123
|
}
|
|
124
124
|
if (!response.ok)
|
|
125
|
-
this.throwForStatus(response.status, document);
|
|
125
|
+
this.throwForStatus(response.status, document, path);
|
|
126
126
|
if (document === null) {
|
|
127
127
|
// The portal's SPA catch-all answers 200 text/html for ANY path the API
|
|
128
128
|
// does not define, so this is as often a wrong path as a dead session —
|
|
@@ -150,11 +150,18 @@ export class SimplePracticeClient {
|
|
|
150
150
|
const { document } = await this.request(path, { query });
|
|
151
151
|
return flattenDocument(document);
|
|
152
152
|
}
|
|
153
|
-
throwForStatus(status, document) {
|
|
153
|
+
throwForStatus(status, document, path) {
|
|
154
154
|
const message = formatJsonApiErrors(document, status);
|
|
155
|
+
// A 401 on the sign-in endpoints means the TOKEN was bad — most often
|
|
156
|
+
// already used, since they are single-use. Telling the caller their
|
|
157
|
+
// session expired there is simply the wrong diagnosis: they have no
|
|
158
|
+
// session yet, which is why they are signing in.
|
|
159
|
+
const isSignIn = path.startsWith('/sessions/') || path.startsWith('/sign-in-tokens');
|
|
155
160
|
if (status === 401 || status === 403) {
|
|
156
161
|
throw new McpToolError(message, {
|
|
157
|
-
hint:
|
|
162
|
+
hint: isSignIn
|
|
163
|
+
? 'Sign-in links and PINs are single-use and last 24 hours. Request a fresh one with simplepractice_request_sign_in_link.'
|
|
164
|
+
: 'The portal session has expired — there is no refresh token, so sign in again with simplepractice_request_sign_in_link.',
|
|
158
165
|
});
|
|
159
166
|
}
|
|
160
167
|
if (status === 429) {
|
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.2.0'; // x-release-please-version
|
package/mint.yaml
ADDED
|
@@ -0,0 +1,70 @@
|
|
|
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
|
+
required: true
|
|
26
|
+
# No default: this names one specific practice's portal, so any value here
|
|
27
|
+
# would be wrong for everyone but its author.
|
|
28
|
+
help: >-
|
|
29
|
+
Your practice's Client Portal address — either the slug
|
|
30
|
+
("achievebalancetherapy") or the full host
|
|
31
|
+
("achievebalancetherapy.clientsecure.me"). It is the host in the portal
|
|
32
|
+
link your provider emailed you.
|
|
33
|
+
|
|
34
|
+
- name: SIMPLEPRACTICE_SESSION_FILE
|
|
35
|
+
secret: false
|
|
36
|
+
required: false
|
|
37
|
+
# No default: the built-in one already resolves under $HOME, which the
|
|
38
|
+
# data dir below persists. Naming a path here would only risk pointing the
|
|
39
|
+
# session outside it.
|
|
40
|
+
help: >-
|
|
41
|
+
Where the signed-in portal session is kept (written 0600). Defaults to
|
|
42
|
+
$HOME/.simplepractice-mcp/session.json; leave unset unless you need it
|
|
43
|
+
somewhere specific.
|
|
44
|
+
|
|
45
|
+
state:
|
|
46
|
+
dataDir: true
|
|
47
|
+
reason: >-
|
|
48
|
+
The portal session cookie is the only credential this server has, and
|
|
49
|
+
SimplePractice issues no refresh token. It is persisted under
|
|
50
|
+
$HOME/.simplepractice-mcp/session.json. Without a data dir every cold start
|
|
51
|
+
loses it, and recovering means a human requesting a fresh magic-link email
|
|
52
|
+
and pasting the link back in — so on a scale-to-zero machine the connector
|
|
53
|
+
would demand an inbox round-trip after nearly every idle period.
|
|
54
|
+
|
|
55
|
+
identity:
|
|
56
|
+
# True because this server authenticates as its CALLER, not as the
|
|
57
|
+
# registration: there is no credential in the environment at all, and each
|
|
58
|
+
# caller signs in through the tools with a magic link mailed to their own
|
|
59
|
+
# address. One shared child would leave the first caller's portal session in
|
|
60
|
+
# place for every later caller to read — someone else's medical record. The
|
|
61
|
+
# uid and state dir per caller are worth that.
|
|
62
|
+
perUserChild: true
|
|
63
|
+
|
|
64
|
+
egress:
|
|
65
|
+
# Every request goes to the practice's own portal host; the client builds
|
|
66
|
+
# exactly one URL template and there is no analytics, no CDN and no browser
|
|
67
|
+
# bridge. The bare apex is never called — only the practice subdomain.
|
|
68
|
+
# Honoured on the isolated tier, a proposal elsewhere.
|
|
69
|
+
allow:
|
|
70
|
+
- "*.clientsecure.me"
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "simplepractice-mcp",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.2.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,7 +21,8 @@
|
|
|
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",
|
|
@@ -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,12 +6,12 @@
|
|
|
6
6
|
"url": "https://github.com/chrischall/simplepractice-mcp",
|
|
7
7
|
"source": "github"
|
|
8
8
|
},
|
|
9
|
-
"version": "0.
|
|
9
|
+
"version": "0.2.0",
|
|
10
10
|
"packages": [
|
|
11
11
|
{
|
|
12
12
|
"registryType": "npm",
|
|
13
13
|
"identifier": "simplepractice-mcp",
|
|
14
|
-
"version": "0.
|
|
14
|
+
"version": "0.2.0",
|
|
15
15
|
"transport": {
|
|
16
16
|
"type": "stdio"
|
|
17
17
|
},
|
|
@@ -82,13 +82,21 @@ hammering it locks you out of the only auth path there is. Wait it out.
|
|
|
82
82
|
**b. Take the token out of the emailed link.** The link looks like
|
|
83
83
|
|
|
84
84
|
```
|
|
85
|
-
https://<practice>.clientsecure.me/sign-in/token
|
|
85
|
+
https://<practice>.clientsecure.me/sign-in/token#<TOKEN>
|
|
86
86
|
```
|
|
87
87
|
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
88
|
+
SimplePractice also mails a mobile-app variant on the bare apex,
|
|
89
|
+
`https://clientsecure.me/client-portal-api/sign-in/token#<TOKEN>`. Either
|
|
90
|
+
works — the path is irrelevant, only the fragment matters.
|
|
91
|
+
|
|
92
|
+
The token is the **URL fragment**, after the `#` (about 300 characters).
|
|
93
|
+
Because it is a fragment it is never sent to the server by a browser
|
|
94
|
+
navigation — the app reads it in JS and posts it. So you must copy it
|
|
95
|
+
yourself; following the link with `curl` does nothing.
|
|
96
|
+
|
|
97
|
+
If you are pulling the link out of a raw message rather than clicking it, note
|
|
98
|
+
the mail is **quoted-printable**: the URL is wrapped across lines with trailing
|
|
99
|
+
`=`, and a naive regex will hand you a silently truncated token. Decode first.
|
|
92
100
|
|
|
93
101
|
**c. Trade it for a session cookie.**
|
|
94
102
|
|
|
@@ -60,10 +60,20 @@ sp -X POST "$SP_API/sign-in-tokens" \
|
|
|
60
60
|
|
|
61
61
|
### 1.2 Exchange the token — `POST /sessions/token`
|
|
62
62
|
|
|
63
|
-
The emailed link is
|
|
64
|
-
|
|
65
|
-
the
|
|
66
|
-
|
|
63
|
+
The emailed link is
|
|
64
|
+
**`https://<practice>.clientsecure.me/sign-in/token#<TOKEN>`** — `/sign-in/token`,
|
|
65
|
+
*not* the `sign-in/token/verify` the app's route tree implies. A second variant,
|
|
66
|
+
sent for the mobile app, points at the bare apex under the API namespace:
|
|
67
|
+
`https://clientsecure.me/client-portal-api/sign-in/token#<TOKEN>`. Either works —
|
|
68
|
+
take the fragment, ignore the path.
|
|
69
|
+
|
|
70
|
+
The token is the **fragment** (303–317 characters observed). A browser never
|
|
71
|
+
sends a fragment to the server; the app reads `location.hash` and posts it.
|
|
72
|
+
Fetching the link with `curl` accomplishes nothing — copy the part after `#`.
|
|
73
|
+
|
|
74
|
+
Both emails are quoted-printable, so the URL is **wrapped across lines with
|
|
75
|
+
trailing `=`**. Pulling it out of a raw message with a naive regex silently
|
|
76
|
+
truncates the token — decode the quoted-printable first.
|
|
67
77
|
|
|
68
78
|
```sh
|
|
69
79
|
sp -X POST "$SP_API/sessions/token" \
|
|
@@ -80,13 +90,10 @@ Success sets the `simplepractice-session` cookie (Rails/Devise) and returns
|
|
|
80
90
|
| `expired` | older than 24h — request a new link |
|
|
81
91
|
| `merged` | the account was merged into another; sign in from the new portal |
|
|
82
92
|
|
|
83
|
-
Tokens are single-use
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
> `routes/sign-in/token/verify.js` + `services/sign-in.js`, but reading the
|
|
88
|
-
> emailed token was out of scope for the agent that wrote this. Treat
|
|
89
|
-
> `meta.status` handling as source-derived until you've run it once.
|
|
93
|
+
Tokens are single-use — confirmed by replay, which answers
|
|
94
|
+
`401 {"title":"Authorization has already been used or expired"}`. That is a 401
|
|
95
|
+
on a sign-in endpoint, where you have no session yet; it means *get a new
|
|
96
|
+
link*, not *your session expired*.
|
|
90
97
|
|
|
91
98
|
### 1.3 PIN variant — `POST /sessions/pin`
|
|
92
99
|
|