simplepractice-mcp 0.3.0 → 0.4.1
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 +8 -0
- package/dist/bundle.js +136 -33
- package/dist/tools/account.js +12 -2
- package/dist/tools/appointments.js +6 -8
- package/dist/tools/auth.js +16 -7
- package/dist/tools/billing.js +29 -8
- package/dist/tools/documents.js +39 -11
- package/dist/version.js +1 -1
- package/dist/view.js +37 -0
- package/package.json +2 -2
- package/server.json +2 -2
- package/skills/simplepractice/SKILL.md +74 -3
|
@@ -6,14 +6,14 @@
|
|
|
6
6
|
},
|
|
7
7
|
"metadata": {
|
|
8
8
|
"description": "SimplePractice Client Portal tools for Claude",
|
|
9
|
-
"version": "0.
|
|
9
|
+
"version": "0.4.1"
|
|
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.4.1",
|
|
17
17
|
"author": {
|
|
18
18
|
"name": "Chris Chall",
|
|
19
19
|
"url": "https://github.com/chrischall"
|
package/README.md
CHANGED
|
@@ -24,6 +24,14 @@ and announcements, read over the portal's own JSON:API.
|
|
|
24
24
|
|
|
25
25
|
Everything is read-only. Cancelling, signing, and paying happen in the portal.
|
|
26
26
|
|
|
27
|
+
The reads that answer with a SimplePractice record rather than a projection —
|
|
28
|
+
appointments, billing items, the billing overview, one document request,
|
|
29
|
+
announcements — take a `view`. It defaults to `compact`, which returns the slim
|
|
30
|
+
projection where this server has one and otherwise drops logo and avatar URLs a
|
|
31
|
+
model cannot see; `view: "full"` returns the record untouched.
|
|
32
|
+
`simplepractice_list_documents` deliberately takes none: what it returns is the
|
|
33
|
+
file reference, and a shared scan is a `.jpg`.
|
|
34
|
+
|
|
27
35
|
## Setup
|
|
28
36
|
|
|
29
37
|
```sh
|
package/dist/bundle.js
CHANGED
|
@@ -31326,12 +31326,82 @@ function messageOf(err) {
|
|
|
31326
31326
|
return String(err);
|
|
31327
31327
|
}
|
|
31328
31328
|
|
|
31329
|
-
// node_modules/@chrischall/mcp-utils/dist/response/
|
|
31330
|
-
|
|
31331
|
-
|
|
31332
|
-
|
|
31333
|
-
|
|
31329
|
+
// node_modules/@chrischall/mcp-utils/dist/response/view.js
|
|
31330
|
+
var VIEWS = ["compact", "full", "raw"];
|
|
31331
|
+
var DEFAULT_VIEW = "compact";
|
|
31332
|
+
var BLURB = {
|
|
31333
|
+
compact: '"compact" (default) drops fields the response already carries elsewhere',
|
|
31334
|
+
full: '"full" returns every field this server understands',
|
|
31335
|
+
raw: '"raw" returns the upstream payload unprojected'
|
|
31336
|
+
};
|
|
31337
|
+
function viewParam(honoured, opts = {}) {
|
|
31338
|
+
if (honoured.length < 2) {
|
|
31339
|
+
throw new Error("viewParam needs at least two rungs: a parameter offering one value decides nothing");
|
|
31340
|
+
}
|
|
31341
|
+
if (!honoured.includes("compact")) {
|
|
31342
|
+
throw new Error('viewParam must offer "compact": a tool with no cheap rung has nothing to default to');
|
|
31343
|
+
}
|
|
31344
|
+
const ordered = VIEWS.filter((v) => honoured.includes(v));
|
|
31345
|
+
const sentence = `Response shape: ${ordered.map((v) => BLURB[v]).join("; ")}.`;
|
|
31346
|
+
return external_exports.enum(Object.fromEntries(ordered.map((v) => [v, v]))).optional().describe(opts.note ? `${sentence} ${opts.note}` : sentence);
|
|
31347
|
+
}
|
|
31348
|
+
function resolveView(value, honoured) {
|
|
31349
|
+
return value !== void 0 && honoured.includes(value) ? value : DEFAULT_VIEW;
|
|
31350
|
+
}
|
|
31351
|
+
function minifiedResult(data) {
|
|
31352
|
+
return { content: [{ type: "text", text: JSON.stringify(data) }] };
|
|
31353
|
+
}
|
|
31354
|
+
|
|
31355
|
+
// node_modules/@chrischall/mcp-utils/dist/response/media.js
|
|
31356
|
+
var MEDIA_NOUN = "(?:avatar|picture|photo|thumbnail|thumb|image|icon|banner|profile_pic(?:ture)?|logo)";
|
|
31357
|
+
var MEDIA_QUALIFIER = "(?:primary|secondary|main|default|cover|hero|profile|master|rendered|small|medium|large|full|original|tall)";
|
|
31358
|
+
var MEDIA_KEY = new RegExp(`^(?:(?:${MEDIA_QUALIFIER}|${MEDIA_NOUN})[_-]?)?${MEDIA_NOUN}s?(?:[_-]?(?:link|uri|url|src)s?)?$`, "i");
|
|
31359
|
+
var MEDIA_URL = /^https?:\/\/[^\s]+?\.(png|jpe?g|gif|webp|svg|avif|bmp|ico)([?#]|$)/i;
|
|
31360
|
+
function stripMediaUrls(value, opts = {}) {
|
|
31361
|
+
const keep = normalizeRules(opts.keep ?? []);
|
|
31362
|
+
const drop = normalizeRules(opts.drop ?? []);
|
|
31363
|
+
return walk(value, keep, drop);
|
|
31364
|
+
}
|
|
31365
|
+
function normalizeRules(rules) {
|
|
31366
|
+
return rules.map((rule) => typeof rule === "string" ? rule.toLowerCase() : new RegExp(rule.source, rule.flags));
|
|
31367
|
+
}
|
|
31368
|
+
function matchesRule(key, rules) {
|
|
31369
|
+
const lower = key.toLowerCase();
|
|
31370
|
+
for (const rule of rules) {
|
|
31371
|
+
if (typeof rule === "string") {
|
|
31372
|
+
if (rule === lower)
|
|
31373
|
+
return true;
|
|
31374
|
+
continue;
|
|
31375
|
+
}
|
|
31376
|
+
rule.lastIndex = 0;
|
|
31377
|
+
if (rule.test(key))
|
|
31378
|
+
return true;
|
|
31379
|
+
}
|
|
31380
|
+
return false;
|
|
31381
|
+
}
|
|
31382
|
+
function walk(value, keep, drop) {
|
|
31383
|
+
if (Array.isArray(value))
|
|
31384
|
+
return value.map((v) => walk(v, keep, drop));
|
|
31385
|
+
if (value === null || typeof value !== "object")
|
|
31386
|
+
return value;
|
|
31387
|
+
if (Object.getPrototypeOf(value) !== Object.prototype && Object.getPrototypeOf(value) !== null)
|
|
31388
|
+
return value;
|
|
31389
|
+
const out = {};
|
|
31390
|
+
for (const [key, v] of Object.entries(value)) {
|
|
31391
|
+
if (matchesRule(key, keep)) {
|
|
31392
|
+
out[key] = v;
|
|
31393
|
+
continue;
|
|
31394
|
+
}
|
|
31395
|
+
if (MEDIA_KEY.test(key) || matchesRule(key, drop))
|
|
31396
|
+
continue;
|
|
31397
|
+
if (typeof v === "string" && MEDIA_URL.test(v))
|
|
31398
|
+
continue;
|
|
31399
|
+
out[key] = walk(v, keep, drop);
|
|
31400
|
+
}
|
|
31401
|
+
return out;
|
|
31334
31402
|
}
|
|
31403
|
+
|
|
31404
|
+
// node_modules/@chrischall/mcp-utils/dist/response/index.js
|
|
31335
31405
|
function errorResult(message) {
|
|
31336
31406
|
return {
|
|
31337
31407
|
content: [{ type: "text", text: redactSecrets(message) }],
|
|
@@ -31530,7 +31600,7 @@ function toolAnnotations(opts = {}) {
|
|
|
31530
31600
|
}
|
|
31531
31601
|
|
|
31532
31602
|
// src/version.ts
|
|
31533
|
-
var VERSION = "0.
|
|
31603
|
+
var VERSION = "0.4.1";
|
|
31534
31604
|
|
|
31535
31605
|
// node_modules/@chrischall/mcp-utils/dist/session/index.js
|
|
31536
31606
|
import { existsSync, readFileSync, writeFileSync, mkdirSync, chmodSync, renameSync, unlinkSync } from "node:fs";
|
|
@@ -32100,7 +32170,7 @@ function registerAuthTools(server, client2) {
|
|
|
32100
32170
|
async () => {
|
|
32101
32171
|
const host = client2.knownPortalHost();
|
|
32102
32172
|
const session = client2.getSession();
|
|
32103
|
-
return
|
|
32173
|
+
return minifiedResult({
|
|
32104
32174
|
practiceHost: host,
|
|
32105
32175
|
// Not knowing the practice yet is a state to report, not an error:
|
|
32106
32176
|
// it is what a first run looks like before anyone has pasted a link.
|
|
@@ -32128,7 +32198,7 @@ function registerAuthTools(server, client2) {
|
|
|
32128
32198
|
},
|
|
32129
32199
|
async ({ email: email3, practice, confirm }) => {
|
|
32130
32200
|
if (!confirm) {
|
|
32131
|
-
return
|
|
32201
|
+
return minifiedResult({
|
|
32132
32202
|
dryRun: true,
|
|
32133
32203
|
wouldSend: "a Client Portal sign-in email",
|
|
32134
32204
|
to: email3,
|
|
@@ -32141,7 +32211,7 @@ function registerAuthTools(server, client2) {
|
|
|
32141
32211
|
}
|
|
32142
32212
|
const send = async () => {
|
|
32143
32213
|
const { expiresIn } = await requestSignInLink(client2, email3);
|
|
32144
|
-
return
|
|
32214
|
+
return minifiedResult({
|
|
32145
32215
|
sent: true,
|
|
32146
32216
|
to: email3,
|
|
32147
32217
|
practiceHost: client2.portalHost(),
|
|
@@ -32162,7 +32232,7 @@ function registerAuthTools(server, client2) {
|
|
|
32162
32232
|
link: external_exports.string().min(1).describe('The sign-in link from the email, or just the token after the "#".')
|
|
32163
32233
|
}
|
|
32164
32234
|
},
|
|
32165
|
-
async ({ link }) =>
|
|
32235
|
+
async ({ link }) => minifiedResult(await verifySignInToken(client2, link))
|
|
32166
32236
|
);
|
|
32167
32237
|
server.registerTool(
|
|
32168
32238
|
"simplepractice_verify_sign_in_pin",
|
|
@@ -32174,7 +32244,7 @@ function registerAuthTools(server, client2) {
|
|
|
32174
32244
|
pin: external_exports.string().regex(/^\d{6}$/, "The PIN is exactly 6 digits.")
|
|
32175
32245
|
}
|
|
32176
32246
|
},
|
|
32177
|
-
async ({ email: email3, pin }) =>
|
|
32247
|
+
async ({ email: email3, pin }) => minifiedResult(await verifySignInPin(client2, email3, pin))
|
|
32178
32248
|
);
|
|
32179
32249
|
server.registerTool(
|
|
32180
32250
|
"simplepractice_sign_out",
|
|
@@ -32183,7 +32253,7 @@ function registerAuthTools(server, client2) {
|
|
|
32183
32253
|
annotations: toolAnnotations({ readOnly: false, idempotent: true }),
|
|
32184
32254
|
inputSchema: {}
|
|
32185
32255
|
},
|
|
32186
|
-
async () =>
|
|
32256
|
+
async () => minifiedResult({ signedOut: client2.clearSession() })
|
|
32187
32257
|
);
|
|
32188
32258
|
}
|
|
32189
32259
|
|
|
@@ -32205,7 +32275,7 @@ function registerAccountTools(server, client2) {
|
|
|
32205
32275
|
const currentClient = environment.currentClient;
|
|
32206
32276
|
const options = environment.currentClientOptions ?? [];
|
|
32207
32277
|
const name = (c) => [c.preferredName ?? c.firstName, c.lastName].filter(Boolean).join(" ");
|
|
32208
|
-
return
|
|
32278
|
+
return minifiedResult({
|
|
32209
32279
|
practice: practice && {
|
|
32210
32280
|
id: practice.id,
|
|
32211
32281
|
name: practice.fullName,
|
|
@@ -32238,6 +32308,18 @@ function registerAccountTools(server, client2) {
|
|
|
32238
32308
|
);
|
|
32239
32309
|
}
|
|
32240
32310
|
|
|
32311
|
+
// src/view.ts
|
|
32312
|
+
var SP_VIEWS = ["compact", "full"];
|
|
32313
|
+
var NOTE = `compact returns the slim projection where one exists and strips image URLs elsewhere; "full" returns SimplePractice's whole records.`;
|
|
32314
|
+
var viewArg = () => viewParam(SP_VIEWS, { note: NOTE });
|
|
32315
|
+
function isCompact(view) {
|
|
32316
|
+
const rung = resolveView(view, SP_VIEWS);
|
|
32317
|
+
return rung === "compact";
|
|
32318
|
+
}
|
|
32319
|
+
function viewResponse(view, data) {
|
|
32320
|
+
return minifiedResult(isCompact(view) ? stripMediaUrls(data) : data);
|
|
32321
|
+
}
|
|
32322
|
+
|
|
32241
32323
|
// src/tools/appointments.ts
|
|
32242
32324
|
var PAGE_SIZE_MAX = 50;
|
|
32243
32325
|
function compactAppointment(a) {
|
|
@@ -32267,22 +32349,22 @@ function registerAppointmentTools(server, client2) {
|
|
|
32267
32349
|
status: external_exports.enum(["scheduled", "requested"]).default("scheduled").describe("Which side of the pending-confirmation filter to read."),
|
|
32268
32350
|
page: external_exports.number().int().positive().default(1),
|
|
32269
32351
|
pageSize: external_exports.number().int().positive().max(PAGE_SIZE_MAX).default(PAGE_SIZE_MAX),
|
|
32270
|
-
|
|
32352
|
+
view: viewArg()
|
|
32271
32353
|
}
|
|
32272
32354
|
},
|
|
32273
|
-
async ({ status, page, pageSize,
|
|
32355
|
+
async ({ status, page, pageSize, view }) => {
|
|
32274
32356
|
const { records } = await client2.list("/appointments", {
|
|
32275
32357
|
include: "clinician,office,client",
|
|
32276
32358
|
filter: { hasPendingConfirmation: status === "requested" },
|
|
32277
32359
|
page: { number: page, size: pageSize }
|
|
32278
32360
|
});
|
|
32279
|
-
return
|
|
32361
|
+
return minifiedResult({
|
|
32280
32362
|
status,
|
|
32281
32363
|
page,
|
|
32282
32364
|
count: records.length,
|
|
32283
32365
|
// The API sends no total; a short page is the last page.
|
|
32284
32366
|
hasMore: records.length >= pageSize,
|
|
32285
|
-
appointments:
|
|
32367
|
+
appointments: isCompact(view) ? records.map(compactAppointment) : records
|
|
32286
32368
|
});
|
|
32287
32369
|
}
|
|
32288
32370
|
);
|
|
@@ -32314,16 +32396,20 @@ function registerBillingTools(server, client2) {
|
|
|
32314
32396
|
inputSchema: {
|
|
32315
32397
|
kind: external_exports.enum(["invoice", "statement", "superbill", "receipt", "account-history"]).default("invoice"),
|
|
32316
32398
|
before: external_exports.string().optional().describe("Cursor for the next page \u2014 the nextCursor from a previous call."),
|
|
32317
|
-
pageSize: external_exports.number().int().positive().max(PAGE_SIZE_MAX2).default(PAGE_SIZE_MAX2)
|
|
32399
|
+
pageSize: external_exports.number().int().positive().max(PAGE_SIZE_MAX2).default(PAGE_SIZE_MAX2),
|
|
32400
|
+
view: viewArg()
|
|
32318
32401
|
}
|
|
32319
32402
|
},
|
|
32320
|
-
|
|
32403
|
+
// `view` is destructured off, never forwarded: `client.list` turns whatever
|
|
32404
|
+
// it is handed into a JSON:API query string, and a stray `view=compact`
|
|
32405
|
+
// would reach SimplePractice as a filter it never defined.
|
|
32406
|
+
async ({ kind, before, pageSize, view }) => {
|
|
32321
32407
|
const { records, meta: meta3 } = await client2.list("/billing-items", {
|
|
32322
32408
|
filter: KINDS[kind],
|
|
32323
32409
|
page: before ? { size: pageSize, before } : { size: pageSize }
|
|
32324
32410
|
});
|
|
32325
32411
|
const last = records[records.length - 1];
|
|
32326
|
-
return
|
|
32412
|
+
return viewResponse(view, {
|
|
32327
32413
|
kind,
|
|
32328
32414
|
count: records.length,
|
|
32329
32415
|
endBalance: meta3?.endBalance ?? null,
|
|
@@ -32338,11 +32424,11 @@ function registerBillingTools(server, client2) {
|
|
|
32338
32424
|
{
|
|
32339
32425
|
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.",
|
|
32340
32426
|
annotations: toolAnnotations({ readOnly: true }),
|
|
32341
|
-
inputSchema: {}
|
|
32427
|
+
inputSchema: { view: viewArg() }
|
|
32342
32428
|
},
|
|
32343
|
-
async () => {
|
|
32429
|
+
async ({ view }) => {
|
|
32344
32430
|
const overview = await loadClientRelationship(client2, "clientBillingOverview");
|
|
32345
|
-
return
|
|
32431
|
+
return viewResponse(view, overview ?? { note: "No billing overview returned for this client." });
|
|
32346
32432
|
}
|
|
32347
32433
|
);
|
|
32348
32434
|
server.registerTool(
|
|
@@ -32352,10 +32438,13 @@ function registerBillingTools(server, client2) {
|
|
|
32352
32438
|
annotations: toolAnnotations({ readOnly: true }),
|
|
32353
32439
|
inputSchema: {}
|
|
32354
32440
|
},
|
|
32441
|
+
// No `view`: the response below IS a projection, hand-written down to five
|
|
32442
|
+
// fields with knowledge of what a card record holds. Running the blind rung
|
|
32443
|
+
// over it afterwards would let an un-grounded rule overrule a grounded one.
|
|
32355
32444
|
async () => {
|
|
32356
32445
|
const cards = await loadClientRelationship(client2, "cards");
|
|
32357
32446
|
const list = Array.isArray(cards) ? cards : [];
|
|
32358
|
-
return
|
|
32447
|
+
return minifiedResult({
|
|
32359
32448
|
count: list.length,
|
|
32360
32449
|
paymentMethods: list.map((c) => ({
|
|
32361
32450
|
id: c.id,
|
|
@@ -32385,6 +32474,9 @@ function registerDocumentTools(server, client2) {
|
|
|
32385
32474
|
includeBody: external_exports.boolean().default(false).describe("Include the full document body/questions. Off by default \u2014 these are long.")
|
|
32386
32475
|
}
|
|
32387
32476
|
},
|
|
32477
|
+
// No `view`: `items` below is a hand-written projection, and `includeBody`
|
|
32478
|
+
// is a field the caller explicitly asked for. A blind rung run over that
|
|
32479
|
+
// output could only take back something chosen on purpose.
|
|
32388
32480
|
async ({ outstandingOnly, pageSize, includeBody }) => {
|
|
32389
32481
|
const { records, meta: meta3 } = await client2.list("/document-requests", {
|
|
32390
32482
|
page: { size: pageSize }
|
|
@@ -32410,7 +32502,7 @@ function registerDocumentTools(server, client2) {
|
|
|
32410
32502
|
}
|
|
32411
32503
|
return base;
|
|
32412
32504
|
});
|
|
32413
|
-
return
|
|
32505
|
+
return minifiedResult({
|
|
32414
32506
|
count: items.length,
|
|
32415
32507
|
outstanding: records.filter((r) => !SETTLED.has(String(r.status))).length,
|
|
32416
32508
|
welcomeText: meta3?.welcomeText ?? null,
|
|
@@ -32423,13 +32515,18 @@ function registerDocumentTools(server, client2) {
|
|
|
32423
32515
|
{
|
|
32424
32516
|
description: "One document request in full, including its body or its questions and the answers already given.",
|
|
32425
32517
|
annotations: toolAnnotations({ readOnly: true }),
|
|
32426
|
-
inputSchema: {
|
|
32518
|
+
inputSchema: {
|
|
32519
|
+
id: external_exports.string().min(1).describe("The document request id."),
|
|
32520
|
+
view: viewArg()
|
|
32521
|
+
}
|
|
32427
32522
|
},
|
|
32428
|
-
|
|
32523
|
+
// `view` is destructured off rather than passed on: the id is the only part
|
|
32524
|
+
// of this input that may reach the request path.
|
|
32525
|
+
async ({ id, view }) => {
|
|
32429
32526
|
const { records } = await client2.list(`/document-requests/${encodeURIComponent(id)}`);
|
|
32430
32527
|
const record2 = records[0];
|
|
32431
|
-
if (!record2) return
|
|
32432
|
-
return
|
|
32528
|
+
if (!record2) return minifiedResult({ found: false, id });
|
|
32529
|
+
return viewResponse(view, {
|
|
32433
32530
|
...record2,
|
|
32434
32531
|
hasDocumentPdf: asBoolean(record2.hasDocumentPdf) ?? false
|
|
32435
32532
|
});
|
|
@@ -32444,9 +32541,14 @@ function registerDocumentTools(server, client2) {
|
|
|
32444
32541
|
pageSize: external_exports.number().int().positive().max(PAGE_SIZE_MAX3).default(PAGE_SIZE_MAX3)
|
|
32445
32542
|
}
|
|
32446
32543
|
},
|
|
32544
|
+
// No `view`, and this one is the exception worth stating: the PRODUCT of
|
|
32545
|
+
// this tool is the file references themselves. A practice that shares a
|
|
32546
|
+
// scan shares it as a .jpg or .png, and the blind rung drops any string
|
|
32547
|
+
// whose path ends in an image extension — so compacting here would empty
|
|
32548
|
+
// exactly the rows a caller came for rather than shrink them.
|
|
32447
32549
|
async ({ pageSize }) => {
|
|
32448
32550
|
const { records } = await client2.list("/documents", { page: { size: pageSize } });
|
|
32449
|
-
return
|
|
32551
|
+
return minifiedResult({ count: records.length, documents: records });
|
|
32450
32552
|
}
|
|
32451
32553
|
);
|
|
32452
32554
|
server.registerTool(
|
|
@@ -32455,12 +32557,13 @@ function registerDocumentTools(server, client2) {
|
|
|
32455
32557
|
description: "Announcements the practice has posted to the Client Portal. readAt is null on unread ones.",
|
|
32456
32558
|
annotations: toolAnnotations({ readOnly: true }),
|
|
32457
32559
|
inputSchema: {
|
|
32458
|
-
pageSize: external_exports.number().int().positive().max(PAGE_SIZE_MAX3).default(PAGE_SIZE_MAX3)
|
|
32560
|
+
pageSize: external_exports.number().int().positive().max(PAGE_SIZE_MAX3).default(PAGE_SIZE_MAX3),
|
|
32561
|
+
view: viewArg()
|
|
32459
32562
|
}
|
|
32460
32563
|
},
|
|
32461
|
-
async ({ pageSize }) => {
|
|
32564
|
+
async ({ pageSize, view }) => {
|
|
32462
32565
|
const { records } = await client2.list("/announcements", { page: { size: pageSize } });
|
|
32463
|
-
return
|
|
32566
|
+
return viewResponse(view, {
|
|
32464
32567
|
count: records.length,
|
|
32465
32568
|
unread: records.filter((r) => r.readAt === null || r.readAt === void 0).length,
|
|
32466
32569
|
announcements: records
|
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,6 +1,15 @@
|
|
|
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
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.',
|
|
@@ -9,7 +18,7 @@ export function registerAuthTools(server, client) {
|
|
|
9
18
|
}, async () => {
|
|
10
19
|
const host = client.knownPortalHost();
|
|
11
20
|
const session = client.getSession();
|
|
12
|
-
return
|
|
21
|
+
return minifiedResult({
|
|
13
22
|
practiceHost: host,
|
|
14
23
|
// Not knowing the practice yet is a state to report, not an error:
|
|
15
24
|
// it is what a first run looks like before anyone has pasted a link.
|
|
@@ -37,7 +46,7 @@ export function registerAuthTools(server, client) {
|
|
|
37
46
|
},
|
|
38
47
|
}, async ({ email, practice, confirm }) => {
|
|
39
48
|
if (!confirm) {
|
|
40
|
-
return
|
|
49
|
+
return minifiedResult({
|
|
41
50
|
dryRun: true,
|
|
42
51
|
wouldSend: 'a Client Portal sign-in email',
|
|
43
52
|
to: email,
|
|
@@ -50,7 +59,7 @@ export function registerAuthTools(server, client) {
|
|
|
50
59
|
}
|
|
51
60
|
const send = async () => {
|
|
52
61
|
const { expiresIn } = await requestSignInLink(client, email);
|
|
53
|
-
return
|
|
62
|
+
return minifiedResult({
|
|
54
63
|
sent: true,
|
|
55
64
|
to: email,
|
|
56
65
|
practiceHost: client.portalHost(),
|
|
@@ -72,7 +81,7 @@ export function registerAuthTools(server, client) {
|
|
|
72
81
|
.min(1)
|
|
73
82
|
.describe('The sign-in link from the email, or just the token after the "#".'),
|
|
74
83
|
},
|
|
75
|
-
}, async ({ link }) =>
|
|
84
|
+
}, async ({ link }) => minifiedResult(await verifySignInToken(client, link)));
|
|
76
85
|
server.registerTool('simplepractice_verify_sign_in_pin', {
|
|
77
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.',
|
|
78
87
|
annotations: toolAnnotations({ readOnly: false, idempotent: false }),
|
|
@@ -80,10 +89,10 @@ export function registerAuthTools(server, client) {
|
|
|
80
89
|
email: z.string().email().describe('The address the PIN was sent to.'),
|
|
81
90
|
pin: z.string().regex(/^\d{6}$/, 'The PIN is exactly 6 digits.'),
|
|
82
91
|
},
|
|
83
|
-
}, async ({ email, pin }) =>
|
|
92
|
+
}, async ({ email, pin }) => minifiedResult(await verifySignInPin(client, email, pin)));
|
|
84
93
|
server.registerTool('simplepractice_sign_out', {
|
|
85
94
|
description: 'Discard the stored Client Portal session from local state.',
|
|
86
95
|
annotations: toolAnnotations({ readOnly: false, idempotent: true }),
|
|
87
96
|
inputSchema: {},
|
|
88
|
-
}, async () =>
|
|
97
|
+
}, async () => minifiedResult({ signedOut: client.clearSession() }));
|
|
89
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,
|
package/dist/version.js
CHANGED
|
@@ -2,4 +2,4 @@
|
|
|
2
2
|
* Single source of truth for the server version. release-please rewrites the
|
|
3
3
|
* literal below; every other file imports VERSION rather than repeating it.
|
|
4
4
|
*/
|
|
5
|
-
export const VERSION = '0.
|
|
5
|
+
export const VERSION = '0.4.1'; // x-release-please-version
|
package/dist/view.js
ADDED
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import { minifiedResult, resolveView, stripMediaUrls, viewParam } from '@chrischall/mcp-utils';
|
|
2
|
+
/**
|
|
3
|
+
* The rungs this server honours (`@chrischall/mcp-utils`' `view` vocabulary;
|
|
4
|
+
* `chrischall/workflows` `docs/fleet-conventions.md`, "Response shape").
|
|
5
|
+
*
|
|
6
|
+
* A GROUNDED repo: it already had a field projection, and it was opt-in —
|
|
7
|
+
* `compact: false`, so the caller had to know the slim rung existed and ask
|
|
8
|
+
* for it. An efficiency that has to be requested is one that usually is not,
|
|
9
|
+
* and the caller paying for it is the one least able to know.
|
|
10
|
+
*
|
|
11
|
+
* `compact` is the default now. SimplePractice payloads that have no projection get
|
|
12
|
+
* media stripping instead, which needs no knowledge of the shape.
|
|
13
|
+
*
|
|
14
|
+
* A hand-written projection is NOT then media-stripped. Its field choices were
|
|
15
|
+
* made WITH knowledge of the API; running a blind subtractive rule over its
|
|
16
|
+
* output would let an un-grounded rule overrule a grounded one — which bit
|
|
17
|
+
* viator-mcp, where the projection deliberately keeps a cover image.
|
|
18
|
+
*
|
|
19
|
+
* No `raw` rung: `full` already returns the untouched upstream payload.
|
|
20
|
+
*/
|
|
21
|
+
export const SP_VIEWS = ['compact', 'full'];
|
|
22
|
+
const NOTE = 'compact returns the slim projection where one exists and strips image URLs elsewhere; ' +
|
|
23
|
+
'"full" returns SimplePractice\'s whole records.';
|
|
24
|
+
/** The `view` parameter every read tool in this server takes. */
|
|
25
|
+
export const viewArg = () => viewParam(SP_VIEWS, { note: NOTE });
|
|
26
|
+
/** Is this call asking for the slim rung? Replaces the old `compact` boolean. */
|
|
27
|
+
export function isCompact(view) {
|
|
28
|
+
const rung = resolveView(view, SP_VIEWS);
|
|
29
|
+
return rung === 'compact';
|
|
30
|
+
}
|
|
31
|
+
/**
|
|
32
|
+
* Answer a payload that has NO hand-written projection: compact strips media,
|
|
33
|
+
* full passes through.
|
|
34
|
+
*/
|
|
35
|
+
export function viewResponse(view, data) {
|
|
36
|
+
return minifiedResult(isCompact(view) ? stripMediaUrls(data) : data);
|
|
37
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "simplepractice-mcp",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.4.1",
|
|
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)",
|
|
@@ -34,7 +34,7 @@
|
|
|
34
34
|
"test:watch": "vitest"
|
|
35
35
|
},
|
|
36
36
|
"dependencies": {
|
|
37
|
-
"@chrischall/mcp-utils": "^0.
|
|
37
|
+
"@chrischall/mcp-utils": "^0.23.0",
|
|
38
38
|
"@modelcontextprotocol/sdk": "^1.29.0",
|
|
39
39
|
"dotenv": "^17.4.2",
|
|
40
40
|
"zod": "^4.4.3"
|
package/server.json
CHANGED
|
@@ -6,12 +6,12 @@
|
|
|
6
6
|
"url": "https://github.com/chrischall/simplepractice-mcp",
|
|
7
7
|
"source": "github"
|
|
8
8
|
},
|
|
9
|
-
"version": "0.
|
|
9
|
+
"version": "0.4.1",
|
|
10
10
|
"packages": [
|
|
11
11
|
{
|
|
12
12
|
"registryType": "npm",
|
|
13
13
|
"identifier": "simplepractice-mcp",
|
|
14
|
-
"version": "0.
|
|
14
|
+
"version": "0.4.1",
|
|
15
15
|
"transport": {
|
|
16
16
|
"type": "stdio"
|
|
17
17
|
},
|
|
@@ -53,18 +53,89 @@ again; that means another email.
|
|
|
53
53
|
several people (a parent for two children), so confirm *whose* record you
|
|
54
54
|
are about to report on before you report on it. It also returns the
|
|
55
55
|
practice's real cancellation policy and the client's feature permissions.
|
|
56
|
-
- `simplepractice_list_appointments` — `status: "scheduled"` for confirmed and
|
|
56
|
+
- `simplepractice_list_appointments(status?, page?, pageSize?, view?)` — `status: "scheduled"` for confirmed and
|
|
57
57
|
upcoming, `"requested"` for ones the practice has not confirmed yet.
|
|
58
58
|
- `simplepractice_list_document_requests` — paperwork. `outstandingOnly: true`
|
|
59
59
|
answers "is anything waiting for me?", which is the usual question.
|
|
60
|
-
- `simplepractice_get_billing_overview` — balance due and per-category counts.
|
|
60
|
+
- `simplepractice_get_billing_overview(view?)` — balance due and per-category counts.
|
|
61
61
|
Cheaper than listing the billing collections to find out they are empty.
|
|
62
|
-
- `simplepractice_list_billing_items` — invoices, statements, **superbills**
|
|
62
|
+
- `simplepractice_list_billing_items(kind?, before?, pageSize?, view?)` — invoices, statements, **superbills**
|
|
63
63
|
(the receipt to claim out-of-network insurance), receipts, or account
|
|
64
64
|
history. Pages by cursor: pass the returned `nextCursor` back as `before`.
|
|
65
65
|
- `simplepractice_list_payment_methods`, `simplepractice_list_documents`,
|
|
66
66
|
`simplepractice_list_announcements`.
|
|
67
67
|
|
|
68
|
+
## Response shape (`view`)
|
|
69
|
+
|
|
70
|
+
Five of the reads take `view: "compact" | "full"`, and **`compact` is the
|
|
71
|
+
default**: `simplepractice_list_appointments`,
|
|
72
|
+
`simplepractice_list_billing_items`, `simplepractice_get_billing_overview`,
|
|
73
|
+
`simplepractice_get_document_request` and
|
|
74
|
+
`simplepractice_list_announcements`.
|
|
75
|
+
|
|
76
|
+
That default is the point of the parameter. This rung used to be a
|
|
77
|
+
`compact: false` boolean — opt-in, so a caller had to know the slim shape
|
|
78
|
+
existed and ask for it. An efficiency that has to be requested is one that
|
|
79
|
+
usually is not, and the caller paying for it is the one least able to know.
|
|
80
|
+
|
|
81
|
+
**Compact is not one thing here.** One of the tools gets a real field
|
|
82
|
+
projection; the other four get media stripping and no field projection at all,
|
|
83
|
+
and the difference matters because expecting a named field set from the second
|
|
84
|
+
group would be expecting something that was never going to be there.
|
|
85
|
+
|
|
86
|
+
- **`simplepractice_list_appointments` is projected**, down to
|
|
87
|
+
`{id, startTime, endTime, service, clinician, location, videoRoomUrl,
|
|
88
|
+
confirmationStatus, clientConfirmationStatus, isCancellable, fee}`.
|
|
89
|
+
`clinician` is the first and last name JOINED into one string, and
|
|
90
|
+
`location` collapses the office record to `"telehealth"` or
|
|
91
|
+
`"name, city, state"` — so if you are reaching for `clinician.firstName` or
|
|
92
|
+
the `office` object, they are on `full` only.
|
|
93
|
+
- **`list_billing_items`, `get_billing_overview`, `get_document_request` and
|
|
94
|
+
`list_announcements` are media-stripped only.** No field projection is
|
|
95
|
+
claimed, and that is deliberate rather than unfinished: `billing-items` is
|
|
96
|
+
one polymorphic collection switched five ways (`invoice`, `statement`,
|
|
97
|
+
`superbill`, `receipt`, account history), and a field list picked for an
|
|
98
|
+
invoice would quietly drop half of a superbill. The same is true of
|
|
99
|
+
`document-requests`, where a consent form, a questionnaire and a Good Faith
|
|
100
|
+
Estimate are different shapes under one endpoint. What compact takes is the
|
|
101
|
+
practice logo and the clinician avatars; it touches nothing whose key names
|
|
102
|
+
an amount, a date, or a document link.
|
|
103
|
+
|
|
104
|
+
One consequence worth knowing on announcements: the rung drops media keys,
|
|
105
|
+
never nulls. `readAt: null` is data — it is what "unread" means — so it
|
|
106
|
+
survives, and the `unread` count stays reconcilable against the rows beneath
|
|
107
|
+
it.
|
|
108
|
+
|
|
109
|
+
`view: "full"` returns SimplePractice's whole record. There is **no `raw`
|
|
110
|
+
rung**: `full` already IS the untouched upstream payload, so a third value
|
|
111
|
+
could only alias it. And `view` never reaches SimplePractice — it is
|
|
112
|
+
destructured off before the request is built, because `client.list` turns
|
|
113
|
+
whatever it is handed into a JSON:API query string and a stray `view=compact`
|
|
114
|
+
would arrive as a filter SimplePractice never defined.
|
|
115
|
+
|
|
116
|
+
The other ten tools take no `view`, and each has its own reason:
|
|
117
|
+
|
|
118
|
+
- **`simplepractice_get_account`, `simplepractice_list_document_requests` and
|
|
119
|
+
`simplepractice_list_payment_methods` are ALREADY hand-written
|
|
120
|
+
projections** — every field on them was picked by name with knowledge of the
|
|
121
|
+
payload. There is no un-projected shape left underneath, so a `view` there
|
|
122
|
+
would be a parameter that changes nothing, and running a blind rung over that
|
|
123
|
+
output would let an un-grounded rule overrule a grounded one.
|
|
124
|
+
(`list_document_requests` also takes `includeBody`, a field the caller
|
|
125
|
+
explicitly asked for; a blind rung could only take back something chosen on
|
|
126
|
+
purpose.)
|
|
127
|
+
- **`simplepractice_list_documents` is the exception worth stating**: its
|
|
128
|
+
PRODUCT is the file references. A practice that shares a scan shares it as a
|
|
129
|
+
`.jpg` or `.png`, and the blind rung drops any string whose path ends in an
|
|
130
|
+
image extension — so compacting here would not shrink the answer, it would
|
|
131
|
+
empty exactly the rows you came for.
|
|
132
|
+
- **`simplepractice_session_status` and `simplepractice_healthcheck`** answer
|
|
133
|
+
with status, not records.
|
|
134
|
+
- **`simplepractice_request_sign_in_link`, `simplepractice_verify_sign_in_pin`,
|
|
135
|
+
`simplepractice_verify_sign_in_token` and `simplepractice_sign_out`** are
|
|
136
|
+
writes. A write's response is a receipt, with nothing to strip and everything
|
|
137
|
+
to keep.
|
|
138
|
+
|
|
68
139
|
## Reading the results honestly
|
|
69
140
|
|
|
70
141
|
- **An empty billing list is a real answer.** Plenty of practices invoice
|