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
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
|
-
/**
|
|
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
|
-
|
|
50
|
-
|
|
51
|
-
|
|
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
|
-
|
|
58
|
-
|
|
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.
|
|
69
|
-
|
|
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: '
|
|
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))}`, {
|
|
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;
|
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
|
|
19
|
-
*
|
|
20
|
-
*
|
|
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
|
-
*
|
|
24
|
-
*
|
|
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,
|
|
12
|
-
//
|
|
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
|
});
|
package/dist/tools/account.js
CHANGED
|
@@ -1,5 +1,15 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { minifiedResult, toolAnnotations } from '@chrischall/mcp-utils';
|
|
2
2
|
import { parseJsonString } from '../jsonapi.js';
|
|
3
|
+
/**
|
|
4
|
+
* No `view` here, deliberately.
|
|
5
|
+
*
|
|
6
|
+
* `simplepractice_get_account` returns a hand-written projection: every field
|
|
7
|
+
* on it is picked by name out of `/environment`, chosen WITH knowledge of the
|
|
8
|
+
* payload (which is why `clientMayCancelAppointments` and the parsed
|
|
9
|
+
* `permissions` string are on it at all). There is no un-projected upstream
|
|
10
|
+
* shape left for a blind media-strip to act on, so a `view` parameter here
|
|
11
|
+
* would be one that changes nothing — worse than none.
|
|
12
|
+
*/
|
|
3
13
|
export function registerAccountTools(server, client) {
|
|
4
14
|
server.registerTool('simplepractice_get_account', {
|
|
5
15
|
description: 'The practice, the signed-in client, and every client this login can see. One portal login is a "client access" and may cover more than one client — a parent seeing two children, say — so clients is always a list.',
|
|
@@ -15,7 +25,7 @@ export function registerAccountTools(server, client) {
|
|
|
15
25
|
const options = environment.currentClientOptions ?? [];
|
|
16
26
|
// Only ever called with a resolved client record, so no undefined guard.
|
|
17
27
|
const name = (c) => [c.preferredName ?? c.firstName, c.lastName].filter(Boolean).join(' ');
|
|
18
|
-
return
|
|
28
|
+
return minifiedResult({
|
|
19
29
|
practice: practice && {
|
|
20
30
|
id: practice.id,
|
|
21
31
|
name: practice.fullName,
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { z } from 'zod';
|
|
2
|
-
import {
|
|
2
|
+
import { isCompact, viewArg } from '../view.js';
|
|
3
|
+
import { minifiedResult, toolAnnotations } from '@chrischall/mcp-utils';
|
|
3
4
|
const PAGE_SIZE_MAX = 50;
|
|
4
5
|
function compactAppointment(a) {
|
|
5
6
|
const clinician = a.clinician;
|
|
@@ -31,24 +32,21 @@ export function registerAppointmentTools(server, client) {
|
|
|
31
32
|
.describe('Which side of the pending-confirmation filter to read.'),
|
|
32
33
|
page: z.number().int().positive().default(1),
|
|
33
34
|
pageSize: z.number().int().positive().max(PAGE_SIZE_MAX).default(PAGE_SIZE_MAX),
|
|
34
|
-
|
|
35
|
-
.boolean()
|
|
36
|
-
.default(true)
|
|
37
|
-
.describe('Return a slim projection. Set false for the full records.'),
|
|
35
|
+
view: viewArg(),
|
|
38
36
|
},
|
|
39
|
-
}, async ({ status, page, pageSize,
|
|
37
|
+
}, async ({ status, page, pageSize, view }) => {
|
|
40
38
|
const { records } = await client.list('/appointments', {
|
|
41
39
|
include: 'clinician,office,client',
|
|
42
40
|
filter: { hasPendingConfirmation: status === 'requested' },
|
|
43
41
|
page: { number: page, size: pageSize },
|
|
44
42
|
});
|
|
45
|
-
return
|
|
43
|
+
return minifiedResult({
|
|
46
44
|
status,
|
|
47
45
|
page,
|
|
48
46
|
count: records.length,
|
|
49
47
|
// The API sends no total; a short page is the last page.
|
|
50
48
|
hasMore: records.length >= pageSize,
|
|
51
|
-
appointments:
|
|
49
|
+
appointments: isCompact(view) ? records.map(compactAppointment) : records,
|
|
52
50
|
});
|
|
53
51
|
});
|
|
54
52
|
}
|
package/dist/tools/auth.js
CHANGED
|
@@ -1,18 +1,35 @@
|
|
|
1
1
|
import { z } from 'zod';
|
|
2
|
-
import {
|
|
2
|
+
import { minifiedResult, schemaConfirm, toolAnnotations } from '@chrischall/mcp-utils';
|
|
3
3
|
import { requestSignInLink, verifySignInPin, verifySignInToken } from '../auth.js';
|
|
4
|
+
/**
|
|
5
|
+
* No `view` here, deliberately.
|
|
6
|
+
*
|
|
7
|
+
* Nothing in this file answers with a SimplePractice record: every response is
|
|
8
|
+
* a small object this server builds — local session state, a dry-run preview,
|
|
9
|
+
* the result of a sign-in exchange. There is no upstream payload to project or
|
|
10
|
+
* strip, and none of these are reads a caller pages through, so the rung would
|
|
11
|
+
* have nothing to switch between.
|
|
12
|
+
*/
|
|
4
13
|
export function registerAuthTools(server, client) {
|
|
5
14
|
server.registerTool('simplepractice_session_status', {
|
|
6
|
-
description: 'Report whether this server holds a Client Portal session,
|
|
15
|
+
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
16
|
annotations: toolAnnotations({ readOnly: true }),
|
|
8
17
|
inputSchema: {},
|
|
9
18
|
}, async () => {
|
|
10
|
-
const host = client.
|
|
19
|
+
const host = client.knownPortalHost();
|
|
11
20
|
const session = client.getSession();
|
|
12
|
-
return
|
|
21
|
+
return minifiedResult({
|
|
13
22
|
practiceHost: host,
|
|
23
|
+
// Not knowing the practice yet is a state to report, not an error:
|
|
24
|
+
// it is what a first run looks like before anyone has pasted a link.
|
|
25
|
+
practiceSource: client.practiceSource(),
|
|
14
26
|
signedIn: session !== null,
|
|
15
27
|
signedInAt: session?.createdAt ?? null,
|
|
28
|
+
...(host
|
|
29
|
+
? {}
|
|
30
|
+
: {
|
|
31
|
+
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.',
|
|
32
|
+
}),
|
|
16
33
|
});
|
|
17
34
|
});
|
|
18
35
|
server.registerTool('simplepractice_request_sign_in_link', {
|
|
@@ -20,29 +37,43 @@ export function registerAuthTools(server, client) {
|
|
|
20
37
|
annotations: toolAnnotations({ readOnly: false, idempotent: false }),
|
|
21
38
|
inputSchema: {
|
|
22
39
|
email: z.string().email().describe('The email address the Client Portal is registered to.'),
|
|
40
|
+
practice: z
|
|
41
|
+
.string()
|
|
42
|
+
.min(1)
|
|
43
|
+
.optional()
|
|
44
|
+
.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
45
|
confirm: schemaConfirm,
|
|
24
46
|
},
|
|
25
|
-
}, async ({ email, confirm }) => {
|
|
47
|
+
}, async ({ email, practice, confirm }) => {
|
|
26
48
|
if (!confirm) {
|
|
27
|
-
return
|
|
49
|
+
return minifiedResult({
|
|
28
50
|
dryRun: true,
|
|
29
51
|
wouldSend: 'a Client Portal sign-in email',
|
|
30
52
|
to: email,
|
|
31
|
-
|
|
53
|
+
// Named, not adopted. A dry run sends nothing, so it must not move
|
|
54
|
+
// the server either — silently overriding a SIMPLEPRACTICE_PRACTICE
|
|
55
|
+
// pin is not something an inert preview gets to do.
|
|
56
|
+
practiceHost: practice ? client.validatePracticeHost(practice) : client.portalHost(),
|
|
32
57
|
note: 'Re-run with confirm:true to actually send it. Do not retry a failed send — SimplePractice locks out repeated sign-in requests.',
|
|
33
58
|
});
|
|
34
59
|
}
|
|
35
|
-
const
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
60
|
+
const send = async () => {
|
|
61
|
+
const { expiresIn } = await requestSignInLink(client, email);
|
|
62
|
+
return minifiedResult({
|
|
63
|
+
sent: true,
|
|
64
|
+
to: email,
|
|
65
|
+
practiceHost: client.portalHost(),
|
|
66
|
+
expiresIn,
|
|
67
|
+
next: 'Open the email, copy the sign-in link (or just the part after the "#"), and pass it to simplepractice_verify_sign_in_token.',
|
|
68
|
+
note: 'This response is the same whether or not the address has an account.',
|
|
69
|
+
});
|
|
70
|
+
};
|
|
71
|
+
// Scoped exactly as the sign-in exchange is: the practice sticks only if
|
|
72
|
+
// the send works, so a rejected send leaves the previous one standing.
|
|
73
|
+
return practice ? client.withPracticeHost(practice, send) : send();
|
|
43
74
|
});
|
|
44
75
|
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.',
|
|
76
|
+
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
77
|
annotations: toolAnnotations({ readOnly: false, idempotent: false }),
|
|
47
78
|
inputSchema: {
|
|
48
79
|
link: z
|
|
@@ -50,7 +81,7 @@ export function registerAuthTools(server, client) {
|
|
|
50
81
|
.min(1)
|
|
51
82
|
.describe('The sign-in link from the email, or just the token after the "#".'),
|
|
52
83
|
},
|
|
53
|
-
}, async ({ link }) =>
|
|
84
|
+
}, async ({ link }) => minifiedResult(await verifySignInToken(client, link)));
|
|
54
85
|
server.registerTool('simplepractice_verify_sign_in_pin', {
|
|
55
86
|
description: 'Exchange a 6-digit Client Portal sign-in PIN for a session, for practices that email a code instead of a link. Single-use.',
|
|
56
87
|
annotations: toolAnnotations({ readOnly: false, idempotent: false }),
|
|
@@ -58,10 +89,10 @@ export function registerAuthTools(server, client) {
|
|
|
58
89
|
email: z.string().email().describe('The address the PIN was sent to.'),
|
|
59
90
|
pin: z.string().regex(/^\d{6}$/, 'The PIN is exactly 6 digits.'),
|
|
60
91
|
},
|
|
61
|
-
}, async ({ email, pin }) =>
|
|
92
|
+
}, async ({ email, pin }) => minifiedResult(await verifySignInPin(client, email, pin)));
|
|
62
93
|
server.registerTool('simplepractice_sign_out', {
|
|
63
94
|
description: 'Discard the stored Client Portal session from local state.',
|
|
64
95
|
annotations: toolAnnotations({ readOnly: false, idempotent: true }),
|
|
65
96
|
inputSchema: {},
|
|
66
|
-
}, async () =>
|
|
97
|
+
}, async () => minifiedResult({ signedOut: client.clearSession() }));
|
|
67
98
|
}
|
package/dist/tools/billing.js
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { z } from 'zod';
|
|
2
|
-
import {
|
|
2
|
+
import { viewArg, viewResponse } from '../view.js';
|
|
3
|
+
import { minifiedResult, toolAnnotations } from '@chrischall/mcp-utils';
|
|
3
4
|
import { asBoolean } from '../jsonapi.js';
|
|
4
5
|
const PAGE_SIZE_MAX = 50;
|
|
5
6
|
/**
|
|
@@ -42,14 +43,26 @@ export function registerBillingTools(server, client) {
|
|
|
42
43
|
.optional()
|
|
43
44
|
.describe('Cursor for the next page — the nextCursor from a previous call.'),
|
|
44
45
|
pageSize: z.number().int().positive().max(PAGE_SIZE_MAX).default(PAGE_SIZE_MAX),
|
|
46
|
+
view: viewArg(),
|
|
45
47
|
},
|
|
46
|
-
},
|
|
48
|
+
},
|
|
49
|
+
// `view` is destructured off, never forwarded: `client.list` turns whatever
|
|
50
|
+
// it is handed into a JSON:API query string, and a stray `view=compact`
|
|
51
|
+
// would reach SimplePractice as a filter it never defined.
|
|
52
|
+
async ({ kind, before, pageSize, view }) => {
|
|
47
53
|
const { records, meta } = await client.list('/billing-items', {
|
|
48
54
|
filter: KINDS[kind],
|
|
49
55
|
page: before ? { size: pageSize, before } : { size: pageSize },
|
|
50
56
|
});
|
|
51
57
|
const last = records[records.length - 1];
|
|
52
|
-
|
|
58
|
+
// `items` is the upstream billing-item record verbatim — this tool has no
|
|
59
|
+
// projection, because `billing-items` is polymorphic (five `thisType`
|
|
60
|
+
// switches) and a field list picked for an invoice would quietly drop
|
|
61
|
+
// half of a superbill. That is exactly the payload the blind rung is for:
|
|
62
|
+
// compact strips the practice logo and provider avatars an invoice row
|
|
63
|
+
// carries, and touches nothing whose key names an amount, a date, or a
|
|
64
|
+
// document link.
|
|
65
|
+
return viewResponse(view, {
|
|
53
66
|
kind,
|
|
54
67
|
count: records.length,
|
|
55
68
|
endBalance: meta?.endBalance ?? null,
|
|
@@ -61,19 +74,27 @@ export function registerBillingTools(server, client) {
|
|
|
61
74
|
server.registerTool('simplepractice_get_billing_overview', {
|
|
62
75
|
description: 'Balance due and per-category counts for the Client Portal account. Cheaper than paging the billing collections just to find out whether anything is there.',
|
|
63
76
|
annotations: toolAnnotations({ readOnly: true }),
|
|
64
|
-
inputSchema: {},
|
|
65
|
-
}, async () => {
|
|
77
|
+
inputSchema: { view: viewArg() },
|
|
78
|
+
}, async ({ view }) => {
|
|
66
79
|
const overview = await loadClientRelationship(client, 'clientBillingOverview');
|
|
67
|
-
|
|
80
|
+
// Also un-projected: the overview is whatever `clientBillingOverview`
|
|
81
|
+
// hangs off the client record, and its per-category counts vary by what
|
|
82
|
+
// the practice bills for. Stripping media is the only shrink available
|
|
83
|
+
// that cannot drop a balance.
|
|
84
|
+
return viewResponse(view, overview ?? { note: 'No billing overview returned for this client.' });
|
|
68
85
|
});
|
|
69
86
|
server.registerTool('simplepractice_list_payment_methods', {
|
|
70
87
|
description: 'Payment methods saved to the Client Portal — brand, last four digits, and expiry. No full card numbers.',
|
|
71
88
|
annotations: toolAnnotations({ readOnly: true }),
|
|
72
89
|
inputSchema: {},
|
|
73
|
-
},
|
|
90
|
+
},
|
|
91
|
+
// No `view`: the response below IS a projection, hand-written down to five
|
|
92
|
+
// fields with knowledge of what a card record holds. Running the blind rung
|
|
93
|
+
// over it afterwards would let an un-grounded rule overrule a grounded one.
|
|
94
|
+
async () => {
|
|
74
95
|
const cards = await loadClientRelationship(client, 'cards');
|
|
75
96
|
const list = Array.isArray(cards) ? cards : [];
|
|
76
|
-
return
|
|
97
|
+
return minifiedResult({
|
|
77
98
|
count: list.length,
|
|
78
99
|
paymentMethods: list.map((c) => ({
|
|
79
100
|
id: c.id,
|
package/dist/tools/documents.js
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { z } from 'zod';
|
|
2
|
-
import {
|
|
2
|
+
import { viewArg, viewResponse } from '../view.js';
|
|
3
|
+
import { minifiedResult, toolAnnotations } from '@chrischall/mcp-utils';
|
|
3
4
|
import { asBoolean } from '../jsonapi.js';
|
|
4
5
|
const PAGE_SIZE_MAX = 50;
|
|
5
6
|
/** Statuses that mean the client has nothing left to do. */
|
|
@@ -19,7 +20,11 @@ export function registerDocumentTools(server, client) {
|
|
|
19
20
|
.default(false)
|
|
20
21
|
.describe('Include the full document body/questions. Off by default — these are long.'),
|
|
21
22
|
},
|
|
22
|
-
},
|
|
23
|
+
},
|
|
24
|
+
// No `view`: `items` below is a hand-written projection, and `includeBody`
|
|
25
|
+
// is a field the caller explicitly asked for. A blind rung run over that
|
|
26
|
+
// output could only take back something chosen on purpose.
|
|
27
|
+
async ({ outstandingOnly, pageSize, includeBody }) => {
|
|
23
28
|
const { records, meta } = await client.list('/document-requests', {
|
|
24
29
|
page: { size: pageSize },
|
|
25
30
|
});
|
|
@@ -46,7 +51,7 @@ export function registerDocumentTools(server, client) {
|
|
|
46
51
|
}
|
|
47
52
|
return base;
|
|
48
53
|
});
|
|
49
|
-
return
|
|
54
|
+
return minifiedResult({
|
|
50
55
|
count: items.length,
|
|
51
56
|
outstanding: records.filter((r) => !SETTLED.has(String(r.status))).length,
|
|
52
57
|
welcomeText: meta?.welcomeText ?? null,
|
|
@@ -56,13 +61,24 @@ export function registerDocumentTools(server, client) {
|
|
|
56
61
|
server.registerTool('simplepractice_get_document_request', {
|
|
57
62
|
description: 'One document request in full, including its body or its questions and the answers already given.',
|
|
58
63
|
annotations: toolAnnotations({ readOnly: true }),
|
|
59
|
-
inputSchema: {
|
|
60
|
-
|
|
64
|
+
inputSchema: {
|
|
65
|
+
id: z.string().min(1).describe('The document request id.'),
|
|
66
|
+
view: viewArg(),
|
|
67
|
+
},
|
|
68
|
+
},
|
|
69
|
+
// `view` is destructured off rather than passed on: the id is the only part
|
|
70
|
+
// of this input that may reach the request path.
|
|
71
|
+
async ({ id, view }) => {
|
|
61
72
|
const { records } = await client.list(`/document-requests/${encodeURIComponent(id)}`);
|
|
62
73
|
const record = records[0];
|
|
63
74
|
if (!record)
|
|
64
|
-
return
|
|
65
|
-
|
|
75
|
+
return minifiedResult({ found: false, id });
|
|
76
|
+
// The record goes out verbatim — a consent form, a questionnaire and a
|
|
77
|
+
// Good Faith Estimate are different shapes under one endpoint, so there
|
|
78
|
+
// is no field list to pick. Compact strips the practice logo and
|
|
79
|
+
// clinician avatars these carry; `hasDocumentPdf` is a fact about the
|
|
80
|
+
// document, not a media key, and survives.
|
|
81
|
+
return viewResponse(view, {
|
|
66
82
|
...record,
|
|
67
83
|
hasDocumentPdf: asBoolean(record.hasDocumentPdf) ?? false,
|
|
68
84
|
});
|
|
@@ -73,19 +89,31 @@ export function registerDocumentTools(server, client) {
|
|
|
73
89
|
inputSchema: {
|
|
74
90
|
pageSize: z.number().int().positive().max(PAGE_SIZE_MAX).default(PAGE_SIZE_MAX),
|
|
75
91
|
},
|
|
76
|
-
},
|
|
92
|
+
},
|
|
93
|
+
// No `view`, and this one is the exception worth stating: the PRODUCT of
|
|
94
|
+
// this tool is the file references themselves. A practice that shares a
|
|
95
|
+
// scan shares it as a .jpg or .png, and the blind rung drops any string
|
|
96
|
+
// whose path ends in an image extension — so compacting here would empty
|
|
97
|
+
// exactly the rows a caller came for rather than shrink them.
|
|
98
|
+
async ({ pageSize }) => {
|
|
77
99
|
const { records } = await client.list('/documents', { page: { size: pageSize } });
|
|
78
|
-
return
|
|
100
|
+
return minifiedResult({ count: records.length, documents: records });
|
|
79
101
|
});
|
|
80
102
|
server.registerTool('simplepractice_list_announcements', {
|
|
81
103
|
description: 'Announcements the practice has posted to the Client Portal. readAt is null on unread ones.',
|
|
82
104
|
annotations: toolAnnotations({ readOnly: true }),
|
|
83
105
|
inputSchema: {
|
|
84
106
|
pageSize: z.number().int().positive().max(PAGE_SIZE_MAX).default(PAGE_SIZE_MAX),
|
|
107
|
+
view: viewArg(),
|
|
85
108
|
},
|
|
86
|
-
}, async ({ pageSize }) => {
|
|
109
|
+
}, async ({ pageSize, view }) => {
|
|
87
110
|
const { records } = await client.list('/announcements', { page: { size: pageSize } });
|
|
88
|
-
|
|
111
|
+
// Verbatim upstream records again. An announcement is text the practice
|
|
112
|
+
// posted, so its banner and author avatar are decoration a model cannot
|
|
113
|
+
// see — and `readAt: null` is data, which this rung leaves alone (it
|
|
114
|
+
// drops media keys, never nulls), so the unread count above stays
|
|
115
|
+
// reconcilable against the rows below it.
|
|
116
|
+
return viewResponse(view, {
|
|
89
117
|
count: records.length,
|
|
90
118
|
unread: records.filter((r) => r.readAt === null || r.readAt === undefined).length,
|
|
91
119
|
announcements: records,
|