simplepractice-mcp 0.0.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.
@@ -6,21 +6,27 @@
6
6
  },
7
7
  "metadata": {
8
8
  "description": "SimplePractice Client Portal tools for Claude",
9
- "version": "0.0.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.0.0",
16
+ "version": "0.2.0",
17
17
  "author": {
18
18
  "name": "Chris Chall",
19
19
  "url": "https://github.com/chrischall"
20
20
  },
21
21
  "homepage": "https://github.com/chrischall/simplepractice-mcp",
22
22
  "license": "MIT",
23
- "keywords": ["simplepractice", "client-portal", "therapy", "appointments", "billing"]
23
+ "keywords": [
24
+ "simplepractice",
25
+ "client-portal",
26
+ "therapy",
27
+ "appointments",
28
+ "billing"
29
+ ]
24
30
  }
25
31
  ]
26
32
  }
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "simplepractice",
3
- "version": "0.0.0",
3
+ "version": "0.2.0",
4
4
  "description": "SimplePractice Client Portal — appointments, billing, documents, and announcements",
5
5
  "author": {
6
6
  "name": "Chris Chall",
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 and last 24 hours. The request endpoint is rate-limited
52
- per address **and** per IP, which is why sending is confirm-gated — a retry
53
- loop locks you out of the only way in. There is no refresh token: when the
54
- session lapses, you sign in again.
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.0.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: 'The portal session has expired — there is no refresh token, so sign in again with simplepractice_request_sign_in_link.',
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.0.0'; // x-release-please-version
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.0.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.0.0",
9
+ "version": "0.2.0",
10
10
  "packages": [
11
11
  {
12
12
  "registryType": "npm",
13
13
  "identifier": "simplepractice-mcp",
14
- "version": "0.0.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/verify#<TOKEN>
85
+ https://<practice>.clientsecure.me/sign-in/token#<TOKEN>
86
86
  ```
87
87
 
88
- The token is the **URL fragment**, after the `#`. Because it is a fragment it
89
- is never sent to the server by a browser navigation — the app reads it in JS
90
- and posts it. So you must copy it yourself; following the link with `curl`
91
- does nothing.
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 `https://<practice>.clientsecure.me/sign-in/token/verify#<TOKEN>`.
64
- The token is the **fragment**. A browser never sends a fragment to the server;
65
- the Ember app reads `location.hash` and posts it. Fetching the link with `curl`
66
- accomplishes nothing — copy the part after `#`.
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; replaying one gives `401`/`422`.
84
-
85
- > Not exercised live during this build: the exchange call itself. §1.1 was
86
- > confirmed against a real account (`202`), and the shape above comes from
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