simplepractice-mcp 0.0.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 ADDED
@@ -0,0 +1,177 @@
1
+ import { McpToolError, messageOf, truncateErrorMessage } from '@chrischall/mcp-utils';
2
+ import { SessionStore } from '@chrischall/mcp-utils/session';
3
+ import { API_NAMESPACE, API_VERSION, APPLICATION_BUILD_VERSION, APPLICATION_PLATFORM, readPortalHost, sessionFilePath, } from './config.js';
4
+ import { flattenDocument, formatJsonApiErrors, } from './jsonapi.js';
5
+ const JSON_API_MEDIA_TYPE = 'application/vnd.api+json';
6
+ export function buildQuery(params) {
7
+ const pairs = [];
8
+ const push = (key, value) => pairs.push(`${encodeURIComponent(key)}=${encodeURIComponent(String(value))}`);
9
+ for (const [key, value] of Object.entries(params)) {
10
+ if (value === undefined)
11
+ continue;
12
+ if (value !== null && typeof value === 'object') {
13
+ for (const [sub, subValue] of Object.entries(value))
14
+ push(`${key}[${sub}]`, subValue);
15
+ }
16
+ else {
17
+ push(key, value);
18
+ }
19
+ }
20
+ return pairs.join('&');
21
+ }
22
+ export class SimplePracticeClient {
23
+ store;
24
+ configError;
25
+ host;
26
+ fetchImpl;
27
+ constructor(opts = {}) {
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
+ this.store =
40
+ opts.store ??
41
+ new SessionStore({
42
+ filePath: sessionFilePath(),
43
+ keyOf: (session) => session.host,
44
+ normalizeKey: (key) => key.toLowerCase(),
45
+ });
46
+ }
47
+ /** Throws the deferred configuration error, if there is one. */
48
+ requireConfig() {
49
+ if (this.configError)
50
+ throw this.configError;
51
+ return this.host;
52
+ }
53
+ portalHost() {
54
+ return this.requireConfig();
55
+ }
56
+ getSession() {
57
+ if (this.configError)
58
+ return null;
59
+ return this.store.get(this.host);
60
+ }
61
+ saveSession(cookie) {
62
+ const host = this.requireConfig();
63
+ const session = { host, cookie, createdAt: new Date().toISOString() };
64
+ this.store.add(session);
65
+ return session;
66
+ }
67
+ clearSession() {
68
+ const host = this.requireConfig();
69
+ return this.store.remove(host);
70
+ }
71
+ requireSession() {
72
+ const session = this.getSession();
73
+ if (!session) {
74
+ // McpToolError rather than SessionNotAuthenticatedError: that subclass's
75
+ // constructor is (service, signInHost) and composes its own message, and
76
+ // the two-step remediation below is worth more here than the class name —
77
+ // nothing in this server discriminates on the type.
78
+ 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.',
80
+ });
81
+ }
82
+ return session;
83
+ }
84
+ headers(session, hasBody) {
85
+ const headers = {
86
+ 'Api-Version': API_VERSION,
87
+ // Omitting this is a hard 400 from the API, not a soft default.
88
+ 'Application-Build-Version': APPLICATION_BUILD_VERSION,
89
+ 'Application-Platform': APPLICATION_PLATFORM,
90
+ Accept: JSON_API_MEDIA_TYPE,
91
+ };
92
+ if (hasBody)
93
+ headers['Content-Type'] = JSON_API_MEDIA_TYPE;
94
+ if (session)
95
+ headers.Cookie = session.cookie;
96
+ return headers;
97
+ }
98
+ /** One central place every request goes through. Returns the raw document. */
99
+ async request(path, options = {}) {
100
+ const host = this.requireConfig();
101
+ const session = options.anonymous ? null : this.requireSession();
102
+ const query = options.query ? buildQuery(options.query) : '';
103
+ const url = `https://${host}/${API_NAMESPACE}${path}${query ? `?${query}` : ''}`;
104
+ let response;
105
+ try {
106
+ response = await this.fetchImpl(url, {
107
+ method: options.method ?? 'GET',
108
+ headers: this.headers(session, options.body !== undefined),
109
+ body: options.body === undefined ? undefined : JSON.stringify(options.body),
110
+ redirect: 'manual',
111
+ });
112
+ }
113
+ 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.' });
115
+ }
116
+ const raw = await response.text();
117
+ let document = null;
118
+ try {
119
+ document = raw ? JSON.parse(raw) : {};
120
+ }
121
+ catch {
122
+ document = null;
123
+ }
124
+ if (!response.ok)
125
+ this.throwForStatus(response.status, document);
126
+ if (document === null) {
127
+ // The portal's SPA catch-all answers 200 text/html for ANY path the API
128
+ // does not define, so this is as often a wrong path as a dead session —
129
+ // saying only "sign in again" sent a real investigation down the wrong
130
+ // road once already.
131
+ throw new McpToolError(`SimplePractice returned HTML rather than JSON for ${path}.`, {
132
+ hint: 'Either the session expired (sign in again), or that path is not an API endpoint — the portal serves its app shell with HTTP 200 for unknown paths.',
133
+ });
134
+ }
135
+ return { document, setCookie: readSetCookie(response) };
136
+ }
137
+ /**
138
+ * The id of the client whose data the portal is currently showing. Needed
139
+ * because billing overview and saved cards are relationships ON the client
140
+ * record, not collections of their own — `/client-billing-overviews` and
141
+ * `/cards` are not API paths at all (they fall through to the SPA shell).
142
+ */
143
+ async currentClientId() {
144
+ const { records } = await this.list('/environment', { include: 'currentClient' });
145
+ const current = records[0]?.currentClient;
146
+ return current?.id ?? null;
147
+ }
148
+ /** GET returning flattened records plus the document `meta`. */
149
+ async list(path, query) {
150
+ const { document } = await this.request(path, { query });
151
+ return flattenDocument(document);
152
+ }
153
+ throwForStatus(status, document) {
154
+ const message = formatJsonApiErrors(document, status);
155
+ if (status === 401 || status === 403) {
156
+ 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.',
158
+ });
159
+ }
160
+ if (status === 429) {
161
+ // The two titles are distinct limits and both are punishing; a retry loop
162
+ // here would lock the account out of the only auth path it has.
163
+ throw new McpToolError(message, {
164
+ hint: 'SimplePractice rate-limits sign-in requests per email and per IP. Do not retry — wait before asking for another link.',
165
+ });
166
+ }
167
+ throw new McpToolError(message);
168
+ }
169
+ }
170
+ /** `getSetCookie()` where available, falling back to the joined header. */
171
+ export function readSetCookie(response) {
172
+ const headers = response.headers;
173
+ if (typeof headers.getSetCookie === 'function')
174
+ return headers.getSetCookie();
175
+ const joined = headers.get('set-cookie');
176
+ return joined ? [joined] : [];
177
+ }
package/dist/config.js ADDED
@@ -0,0 +1,58 @@
1
+ import { homedir } from 'node:os';
2
+ import { join } from 'node:path';
3
+ import { expandPath, readEnvVar } from '@chrischall/mcp-utils';
4
+ /** The API contract version the Client Portal app currently sends. */
5
+ export const API_VERSION = '2026-05-25';
6
+ /**
7
+ * The portal app reports itself as build `0.0.0`. The API only checks that the
8
+ * header is PRESENT — it answers
9
+ * `400 {"title":"Application build version is missing"}` when omitted — so this
10
+ * is a required constant rather than anything version-like of ours.
11
+ */
12
+ export const APPLICATION_BUILD_VERSION = '0.0.0';
13
+ export const APPLICATION_PLATFORM = 'web';
14
+ /** Every Client Portal API path hangs off this namespace. */
15
+ export const API_NAMESPACE = 'client-portal-api';
16
+ const PORTAL_DOMAIN = 'clientsecure.me';
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.
22
+ *
23
+ * Returns `null` rather than throwing so the server still boots without
24
+ * configuration and reports the problem on the first tool call.
25
+ */
26
+ export function resolvePortalHost(raw) {
27
+ if (!raw)
28
+ return null;
29
+ let value = raw.trim().toLowerCase();
30
+ if (!value)
31
+ return null;
32
+ value = value.replace(/^https?:\/\//, '').replace(/\/.*$/, '');
33
+ if (!value)
34
+ return null;
35
+ if (!value.includes('.'))
36
+ value = `${value}.${PORTAL_DOMAIN}`;
37
+ if (!value.endsWith(`.${PORTAL_DOMAIN}`))
38
+ return null;
39
+ // Reject anything that is not a single practice label under the apex.
40
+ const label = value.slice(0, -(PORTAL_DOMAIN.length + 1));
41
+ if (!/^[a-z0-9][a-z0-9-]*$/.test(label))
42
+ return null;
43
+ return value;
44
+ }
45
+ export function readPortalHost() {
46
+ return resolvePortalHost(readEnvVar('SIMPLEPRACTICE_PRACTICE'));
47
+ }
48
+ /**
49
+ * Where the session cookie is persisted. Deliberately NOT the path the
50
+ * `simplepractice-fpx` skill uses (`~/.simplepractice-cookies`): the two hold
51
+ * different formats, and sharing a path would have each corrupt the other.
52
+ */
53
+ export function sessionFilePath() {
54
+ const override = readEnvVar('SIMPLEPRACTICE_SESSION_FILE');
55
+ if (override)
56
+ return expandPath(override);
57
+ return join(homedir(), '.simplepractice-mcp', 'session.json');
58
+ }
package/dist/index.js ADDED
@@ -0,0 +1,26 @@
1
+ #!/usr/bin/env node
2
+ import { runMcp } from '@chrischall/mcp-utils';
3
+ import { VERSION } from './version.js';
4
+ import { SimplePracticeClient } from './client.js';
5
+ import { registerAuthTools } from './tools/auth.js';
6
+ import { registerAccountTools } from './tools/account.js';
7
+ import { registerAppointmentTools } from './tools/appointments.js';
8
+ import { registerBillingTools } from './tools/billing.js';
9
+ import { registerDocumentTools } from './tools/documents.js';
10
+ // 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.
13
+ const client = new SimplePracticeClient();
14
+ await runMcp({
15
+ name: 'simplepractice-mcp',
16
+ version: VERSION,
17
+ banner: '[simplepractice-mcp] This project was developed and is maintained by AI. Use at your own discretion.',
18
+ deps: client,
19
+ tools: [
20
+ registerAuthTools,
21
+ registerAccountTools,
22
+ registerAppointmentTools,
23
+ registerBillingTools,
24
+ registerDocumentTools,
25
+ ],
26
+ });
@@ -0,0 +1,91 @@
1
+ /**
2
+ * JSON:API helpers.
3
+ *
4
+ * `flattenJsonApi` in @chrischall/mcp-utils merges `attributes` into the record
5
+ * but does not resolve `included[]`, and the Client Portal leans on `include=`
6
+ * for everything worth reading (an appointment without its clinician and office
7
+ * is not useful). So relationship resolution lives here.
8
+ */
9
+ /**
10
+ * Parse a field the API sends as a JSON *string* rather than an object — the
11
+ * client's `permissions` blob is one (`'{"messaging":true,…}'`). Returns null
12
+ * rather than throwing: a portal that starts sending a real object, or none at
13
+ * all, must not break the account tool.
14
+ */
15
+ export function parseJsonString(value) {
16
+ if (value !== null && typeof value === 'object')
17
+ return value;
18
+ if (typeof value !== 'string')
19
+ return null;
20
+ try {
21
+ const parsed = JSON.parse(value);
22
+ return parsed !== null && typeof parsed === 'object'
23
+ ? parsed
24
+ : null;
25
+ }
26
+ catch {
27
+ return null;
28
+ }
29
+ }
30
+ /** `"true"`/`"false"` arrive as STRINGS on the wire — see `hasDocumentPdf`. */
31
+ export function asBoolean(value) {
32
+ if (typeof value === 'boolean')
33
+ return value;
34
+ if (value === 'true')
35
+ return true;
36
+ if (value === 'false')
37
+ return false;
38
+ return undefined;
39
+ }
40
+ function flattenOne(resource) {
41
+ return { id: resource.id, type: resource.type, ...(resource.attributes ?? {}) };
42
+ }
43
+ function indexIncluded(included) {
44
+ const index = new Map();
45
+ for (const resource of included) {
46
+ if (resource.type && resource.id)
47
+ index.set(`${resource.type}:${resource.id}`, resource);
48
+ }
49
+ return index;
50
+ }
51
+ function resolveRef(ref, index) {
52
+ if (!ref || typeof ref !== 'object')
53
+ return null;
54
+ const { type, id } = ref;
55
+ if (!type || !id)
56
+ return null;
57
+ const hit = index.get(`${type}:${id}`);
58
+ // A relationship whose record was not asked for via `include=` still tells us
59
+ // the id — surfacing that beats dropping the field entirely.
60
+ return hit ? flattenOne(hit) : { id, type };
61
+ }
62
+ /**
63
+ * Flatten a JSON:API document into plain records, splicing each `include`d
64
+ * relationship in beside the attributes under its relationship name.
65
+ */
66
+ export function flattenDocument(doc) {
67
+ const index = indexIncluded(doc.included ?? []);
68
+ const list = Array.isArray(doc.data) ? doc.data : doc.data ? [doc.data] : [];
69
+ const records = list.map((resource) => {
70
+ const flat = flattenOne(resource);
71
+ for (const [name, rel] of Object.entries(resource.relationships ?? {})) {
72
+ const ref = rel?.data;
73
+ if (ref === undefined)
74
+ continue;
75
+ flat[name] = Array.isArray(ref)
76
+ ? ref.map((r) => resolveRef(r, index)).filter((r) => r !== null)
77
+ : resolveRef(ref, index);
78
+ }
79
+ return flat;
80
+ });
81
+ return doc.meta ? { records, meta: doc.meta } : { records };
82
+ }
83
+ /** Render `.errors[]` into one line, for an McpToolError message. */
84
+ export function formatJsonApiErrors(doc, status) {
85
+ const errors = doc?.errors;
86
+ if (!errors?.length)
87
+ return `SimplePractice returned HTTP ${status}`;
88
+ return errors
89
+ .map((e) => [e.title, e.detail].filter(Boolean).join(': ') || `HTTP ${e.status ?? status}`)
90
+ .join('; ');
91
+ }
@@ -0,0 +1,48 @@
1
+ import { textResult, toolAnnotations } from '@chrischall/mcp-utils';
2
+ import { parseJsonString } from '../jsonapi.js';
3
+ export function registerAccountTools(server, client) {
4
+ server.registerTool('simplepractice_get_account', {
5
+ 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.',
6
+ annotations: toolAnnotations({ readOnly: true }),
7
+ inputSchema: {},
8
+ }, async () => {
9
+ const { records } = await client.list('/environment', {
10
+ include: 'currentPractice,currentClient,currentClientOptions,currentClientAccess',
11
+ });
12
+ const environment = records[0] ?? {};
13
+ const practice = environment.currentPractice;
14
+ const currentClient = environment.currentClient;
15
+ const options = environment.currentClientOptions ?? [];
16
+ // Only ever called with a resolved client record, so no undefined guard.
17
+ const name = (c) => [c.preferredName ?? c.firstName, c.lastName].filter(Boolean).join(' ');
18
+ return textResult({
19
+ practice: practice && {
20
+ id: practice.id,
21
+ name: practice.fullName,
22
+ timeZone: practice.timeZone,
23
+ phone: practice.phoneNumber,
24
+ currency: practice.currency,
25
+ isGroupPractice: practice.isGroupPractice,
26
+ telehealthEnabled: practice.telehealthEnabled,
27
+ selfSchedulingEnabled: practice.selfSchedulingEnabled,
28
+ // The practice's actual cancellation policy — read this before
29
+ // telling anyone an appointment can be cancelled.
30
+ clientMayCancelAppointments: practice.isClientAllowedToCancelAppt,
31
+ clientMayConfirmAppointments: practice.isClientAllowedToConfirmAppt,
32
+ cancellationNoticeHours: practice.clientCancellableHrs,
33
+ },
34
+ currentClient: currentClient && {
35
+ id: currentClient.id,
36
+ name: name(currentClient),
37
+ status: currentClient.status,
38
+ hasIncompleteDocument: currentClient.hasIncompleteDocument,
39
+ hasNewAnnouncements: currentClient.hasNewAnnouncements,
40
+ // Sent as a JSON *string*, not an object — and it decides which
41
+ // portal features this client actually has, so a caller that reads
42
+ // it raw gets a string of characters instead of the flags.
43
+ permissions: parseJsonString(currentClient.permissions),
44
+ },
45
+ clients: options.map((c) => ({ id: c.id, name: name(c) })),
46
+ });
47
+ });
48
+ }
@@ -0,0 +1,54 @@
1
+ import { z } from 'zod';
2
+ import { textResult, toolAnnotations } from '@chrischall/mcp-utils';
3
+ const PAGE_SIZE_MAX = 50;
4
+ function compactAppointment(a) {
5
+ const clinician = a.clinician;
6
+ const office = a.office;
7
+ return {
8
+ id: a.id,
9
+ startTime: a.startTime,
10
+ endTime: a.endTime,
11
+ service: a.serviceDescription,
12
+ clinician: clinician && [clinician.firstName, clinician.lastName].filter(Boolean).join(' '),
13
+ location: office?.isVideo
14
+ ? 'telehealth'
15
+ : office && [office.name, office.city, office.state].filter(Boolean).join(', '),
16
+ videoRoomUrl: a.videoRoomUrl,
17
+ confirmationStatus: a.confirmationStatus,
18
+ clientConfirmationStatus: a.clientConfirmationStatus,
19
+ isCancellable: a.isCancellable,
20
+ fee: a.fee,
21
+ };
22
+ }
23
+ export function registerAppointmentTools(server, client) {
24
+ server.registerTool('simplepractice_list_appointments', {
25
+ description: 'Appointments from the Client Portal. status "scheduled" returns confirmed/upcoming ones; "requested" returns those still awaiting the practice\'s confirmation. Pages by number.',
26
+ annotations: toolAnnotations({ readOnly: true }),
27
+ inputSchema: {
28
+ status: z
29
+ .enum(['scheduled', 'requested'])
30
+ .default('scheduled')
31
+ .describe('Which side of the pending-confirmation filter to read.'),
32
+ page: z.number().int().positive().default(1),
33
+ pageSize: z.number().int().positive().max(PAGE_SIZE_MAX).default(PAGE_SIZE_MAX),
34
+ compact: z
35
+ .boolean()
36
+ .default(true)
37
+ .describe('Return a slim projection. Set false for the full records.'),
38
+ },
39
+ }, async ({ status, page, pageSize, compact }) => {
40
+ const { records } = await client.list('/appointments', {
41
+ include: 'clinician,office,client',
42
+ filter: { hasPendingConfirmation: status === 'requested' },
43
+ page: { number: page, size: pageSize },
44
+ });
45
+ return textResult({
46
+ status,
47
+ page,
48
+ count: records.length,
49
+ // The API sends no total; a short page is the last page.
50
+ hasMore: records.length >= pageSize,
51
+ appointments: compact ? records.map(compactAppointment) : records,
52
+ });
53
+ });
54
+ }
@@ -0,0 +1,67 @@
1
+ import { z } from 'zod';
2
+ import { textResult, toolAnnotations, schemaConfirm } from '@chrischall/mcp-utils';
3
+ import { requestSignInLink, verifySignInPin, verifySignInToken } from '../auth.js';
4
+ export function registerAuthTools(server, client) {
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.',
7
+ annotations: toolAnnotations({ readOnly: true }),
8
+ inputSchema: {},
9
+ }, async () => {
10
+ const host = client.portalHost();
11
+ const session = client.getSession();
12
+ return textResult({
13
+ practiceHost: host,
14
+ signedIn: session !== null,
15
+ signedInAt: session?.createdAt ?? null,
16
+ });
17
+ });
18
+ server.registerTool('simplepractice_request_sign_in_link', {
19
+ description: 'Ask SimplePractice to email a sign-in link to a Client Portal address. The portal has no password — this is how you sign in. Sends a real email and is rate-limited per email address AND per IP, so it requires confirm:true. A success does not prove the address has an account: the API answers identically for unknown addresses by design.',
20
+ annotations: toolAnnotations({ readOnly: false, idempotent: false }),
21
+ inputSchema: {
22
+ email: z.string().email().describe('The email address the Client Portal is registered to.'),
23
+ confirm: schemaConfirm,
24
+ },
25
+ }, async ({ email, confirm }) => {
26
+ if (!confirm) {
27
+ return textResult({
28
+ dryRun: true,
29
+ wouldSend: 'a Client Portal sign-in email',
30
+ to: email,
31
+ practiceHost: client.portalHost(),
32
+ note: 'Re-run with confirm:true to actually send it. Do not retry a failed send — SimplePractice locks out repeated sign-in requests.',
33
+ });
34
+ }
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
+ });
43
+ });
44
+ 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.',
46
+ annotations: toolAnnotations({ readOnly: false, idempotent: false }),
47
+ inputSchema: {
48
+ link: z
49
+ .string()
50
+ .min(1)
51
+ .describe('The sign-in link from the email, or just the token after the "#".'),
52
+ },
53
+ }, async ({ link }) => textResult(await verifySignInToken(client, link)));
54
+ server.registerTool('simplepractice_verify_sign_in_pin', {
55
+ 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
+ annotations: toolAnnotations({ readOnly: false, idempotent: false }),
57
+ inputSchema: {
58
+ email: z.string().email().describe('The address the PIN was sent to.'),
59
+ pin: z.string().regex(/^\d{6}$/, 'The PIN is exactly 6 digits.'),
60
+ },
61
+ }, async ({ email, pin }) => textResult(await verifySignInPin(client, email, pin)));
62
+ server.registerTool('simplepractice_sign_out', {
63
+ description: 'Discard the stored Client Portal session from local state.',
64
+ annotations: toolAnnotations({ readOnly: false, idempotent: true }),
65
+ inputSchema: {},
66
+ }, async () => textResult({ signedOut: client.clearSession() }));
67
+ }
@@ -0,0 +1,88 @@
1
+ import { z } from 'zod';
2
+ import { textResult, toolAnnotations } from '@chrischall/mcp-utils';
3
+ import { asBoolean } from '../jsonapi.js';
4
+ const PAGE_SIZE_MAX = 50;
5
+ /**
6
+ * `billing-items` is one polymorphic collection switched by `filter[thisType]`.
7
+ * Account history is the odd one out: two types at once, plus a condition.
8
+ */
9
+ const KINDS = {
10
+ invoice: { thisType: 'invoice' },
11
+ statement: { thisType: 'statement' },
12
+ superbill: { thisType: 'superbill' },
13
+ receipt: { thisType: 'receipt' },
14
+ 'account-history': { thisType: 'billable-item,payment', thisTypeCondition: 'unallocated' },
15
+ };
16
+ /**
17
+ * Read one relationship off the current client record.
18
+ *
19
+ * Billing overview and saved cards hang off `/clients/<id>` via `include=`;
20
+ * there is no `/client-billing-overviews` or `/cards` collection. Asking for
21
+ * one gets HTTP 200 and the Ember app shell, which reads like success.
22
+ */
23
+ async function loadClientRelationship(client, relationship) {
24
+ const id = await client.currentClientId();
25
+ if (id === null)
26
+ return null;
27
+ const { records } = await client.list(`/clients/${encodeURIComponent(id)}`, {
28
+ include: relationship,
29
+ });
30
+ return records[0]?.[relationship] ?? null;
31
+ }
32
+ export function registerBillingTools(server, client) {
33
+ server.registerTool('simplepractice_list_billing_items', {
34
+ description: 'Invoices, statements, superbills, receipts, or account history from the Client Portal. An empty list is a real answer — many practices bill entirely outside the portal. Pages by cursor: pass the returned nextCursor as "before".',
35
+ annotations: toolAnnotations({ readOnly: true }),
36
+ inputSchema: {
37
+ kind: z
38
+ .enum(['invoice', 'statement', 'superbill', 'receipt', 'account-history'])
39
+ .default('invoice'),
40
+ before: z
41
+ .string()
42
+ .optional()
43
+ .describe('Cursor for the next page — the nextCursor from a previous call.'),
44
+ pageSize: z.number().int().positive().max(PAGE_SIZE_MAX).default(PAGE_SIZE_MAX),
45
+ },
46
+ }, async ({ kind, before, pageSize }) => {
47
+ const { records, meta } = await client.list('/billing-items', {
48
+ filter: KINDS[kind],
49
+ page: before ? { size: pageSize, before } : { size: pageSize },
50
+ });
51
+ const last = records[records.length - 1];
52
+ return textResult({
53
+ kind,
54
+ count: records.length,
55
+ endBalance: meta?.endBalance ?? null,
56
+ // The cursor is the row's cursorId, NOT its id.
57
+ nextCursor: records.length >= pageSize ? (last?.cursorId ?? null) : null,
58
+ items: records,
59
+ });
60
+ });
61
+ server.registerTool('simplepractice_get_billing_overview', {
62
+ 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
+ annotations: toolAnnotations({ readOnly: true }),
64
+ inputSchema: {},
65
+ }, async () => {
66
+ const overview = await loadClientRelationship(client, 'clientBillingOverview');
67
+ return textResult(overview ?? { note: 'No billing overview returned for this client.' });
68
+ });
69
+ server.registerTool('simplepractice_list_payment_methods', {
70
+ description: 'Payment methods saved to the Client Portal — brand, last four digits, and expiry. No full card numbers.',
71
+ annotations: toolAnnotations({ readOnly: true }),
72
+ inputSchema: {},
73
+ }, async () => {
74
+ const cards = await loadClientRelationship(client, 'cards');
75
+ const list = Array.isArray(cards) ? cards : [];
76
+ return textResult({
77
+ count: list.length,
78
+ paymentMethods: list.map((c) => ({
79
+ id: c.id,
80
+ brand: c.brand,
81
+ last4: c.last4,
82
+ expiry: c.expiry ?? [c.expMonth, c.expYear].filter(Boolean).join('/'),
83
+ // Another stringly-typed boolean, like hasDocumentPdf.
84
+ isDefault: asBoolean(c.isDefault) ?? false,
85
+ })),
86
+ });
87
+ });
88
+ }