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.
@@ -6,14 +6,14 @@
6
6
  },
7
7
  "metadata": {
8
8
  "description": "SimplePractice Client Portal tools for Claude",
9
- "version": "0.3.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.3.0",
16
+ "version": "0.4.1",
17
17
  "author": {
18
18
  "name": "Chris Chall",
19
19
  "url": "https://github.com/chrischall"
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "simplepractice",
3
- "version": "0.3.0",
3
+ "version": "0.4.1",
4
4
  "description": "SimplePractice Client Portal — appointments, billing, documents, and announcements",
5
5
  "author": {
6
6
  "name": "Chris Chall",
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/index.js
31330
- function textResult(data) {
31331
- return {
31332
- content: [{ type: "text", text: JSON.stringify(data, null, 2) }]
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.3.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 textResult({
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 textResult({
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 textResult({
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 }) => textResult(await verifySignInToken(client2, 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 }) => textResult(await verifySignInPin(client2, 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 () => textResult({ signedOut: client2.clearSession() })
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 textResult({
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
- compact: external_exports.boolean().default(true).describe("Return a slim projection. Set false for the full records.")
32352
+ view: viewArg()
32271
32353
  }
32272
32354
  },
32273
- async ({ status, page, pageSize, compact }) => {
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 textResult({
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: compact ? records.map(compactAppointment) : records
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
- async ({ kind, before, pageSize }) => {
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 textResult({
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 textResult(overview ?? { note: "No billing overview returned for this client." });
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 textResult({
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 textResult({
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: { id: external_exports.string().min(1).describe("The document request id.") }
32518
+ inputSchema: {
32519
+ id: external_exports.string().min(1).describe("The document request id."),
32520
+ view: viewArg()
32521
+ }
32427
32522
  },
32428
- async ({ id }) => {
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 textResult({ found: false, id });
32432
- return textResult({
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 textResult({ count: records.length, documents: records });
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 textResult({
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
@@ -1,5 +1,15 @@
1
- import { textResult, toolAnnotations } from '@chrischall/mcp-utils';
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 textResult({
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 { textResult, toolAnnotations } from '@chrischall/mcp-utils';
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
- compact: z
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, compact }) => {
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 textResult({
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: compact ? records.map(compactAppointment) : records,
49
+ appointments: isCompact(view) ? records.map(compactAppointment) : records,
52
50
  });
53
51
  });
54
52
  }
@@ -1,6 +1,15 @@
1
1
  import { z } from 'zod';
2
- import { textResult, toolAnnotations, schemaConfirm } from '@chrischall/mcp-utils';
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 textResult({
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 textResult({
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 textResult({
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 }) => textResult(await verifySignInToken(client, 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 }) => textResult(await verifySignInPin(client, 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 () => textResult({ signedOut: client.clearSession() }));
97
+ }, async () => minifiedResult({ signedOut: client.clearSession() }));
89
98
  }
@@ -1,5 +1,6 @@
1
1
  import { z } from 'zod';
2
- import { textResult, toolAnnotations } from '@chrischall/mcp-utils';
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
- }, async ({ kind, before, pageSize }) => {
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
- return textResult({
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
- return textResult(overview ?? { note: 'No billing overview returned for this client.' });
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
- }, async () => {
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 textResult({
97
+ return minifiedResult({
77
98
  count: list.length,
78
99
  paymentMethods: list.map((c) => ({
79
100
  id: c.id,
@@ -1,5 +1,6 @@
1
1
  import { z } from 'zod';
2
- import { textResult, toolAnnotations } from '@chrischall/mcp-utils';
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
- }, async ({ outstandingOnly, pageSize, includeBody }) => {
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 textResult({
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: { id: z.string().min(1).describe('The document request id.') },
60
- }, async ({ id }) => {
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 textResult({ found: false, id });
65
- return textResult({
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
- }, async ({ pageSize }) => {
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 textResult({ count: records.length, documents: records });
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
- return textResult({
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.3.0'; // x-release-please-version
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.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.19.3",
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.3.0",
9
+ "version": "0.4.1",
10
10
  "packages": [
11
11
  {
12
12
  "registryType": "npm",
13
13
  "identifier": "simplepractice-mcp",
14
- "version": "0.3.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