splitwise-mcp 3.5.1 → 3.7.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/index.js CHANGED
@@ -21,7 +21,7 @@ import { registerUtilityTools } from './tools/utilities.js';
21
21
  // client into the same registrars instead of this singleton.
22
22
  await runMcp({
23
23
  name: 'splitwise-mcp',
24
- version: '3.5.1', // x-release-please-version
24
+ version: '3.7.0', // x-release-please-version
25
25
  deps: client,
26
26
  banner: '[splitwise-mcp] This project was developed and is maintained by AI (Claude Sonnet 4.6). Use at your own discretion.',
27
27
  tools: [
@@ -0,0 +1,142 @@
1
+ // The `view: 'brief'` rung of the two canonical reads (the mcp-host
2
+ // context-groups brief-view contract): one line per item, at most 120 chars,
3
+ // `title — <ISO date | no date> — <status>`. A line starting `-- ` is a NOTE,
4
+ // not an item: `-- none` for an empty answer, `-- N of M shown` when the list
5
+ // was capped, `-- error: …` when the upstream answer could not be read; an
6
+ // item whose own title starts `-- ` or `\` is escaped with one `\`. The
7
+ // lines carry text other Splitwise users wrote (their names, expense
8
+ // descriptions), so they go inside the untrusted-data envelope as
9
+ // `data: string[]`, minified — the same envelope the tools' other rungs use.
10
+ import { UNTRUSTED_CONTENT_RULE, untrustedEnvelope } from '@chrischall/mcp-utils';
11
+ import { nameOf } from '../project.js';
12
+ import { SW_UNTRUSTED_NOTE } from './_untrusted.js';
13
+ export const BRIEF_MAX = 120;
14
+ /** Notifications past this many are counted in a `-- N of M shown` note. */
15
+ export const BRIEF_NOTIFICATION_CAP = 50;
16
+ function asDict(v) {
17
+ return v !== null && typeof v === 'object' && !Array.isArray(v) ? v : undefined;
18
+ }
19
+ /** The shortest a title is cut to before the status gives way too. */
20
+ const TITLE_FLOOR = 24;
21
+ const cut = (s, n) => (s.length > n ? `${s.slice(0, Math.max(n - 1, 0))}…` : s);
22
+ /**
23
+ * `title — date — status`, cut to ≤ 120 chars as a WHOLE, title first: the
24
+ * title shortens until the line fits, and once it is at its floor the status
25
+ * is cut too. A title starting `-- ` or `\` is escaped with one leading `\`
26
+ * so it never reads as a note (the gateway strips one backslash from every
27
+ * item line after its note check).
28
+ */
29
+ export function briefLine(rawTitle, date, status) {
30
+ const title = rawTitle.startsWith('-- ') || rawTitle.startsWith('\\') ? `\\${rawTitle}` : rawTitle;
31
+ const sep = ` — ${date} — `;
32
+ const room = BRIEF_MAX - sep.length;
33
+ if (title.length + status.length <= room)
34
+ return `${title}${sep}${status}`;
35
+ const shownTitle = cut(title, Math.max(room - status.length, Math.min(title.length, TITLE_FLOOR)));
36
+ return `${shownTitle}${sep}${cut(status, room - shownTitle.length)}`;
37
+ }
38
+ function enveloped(lines, note) {
39
+ return { content: [{ type: 'text', text: JSON.stringify(untrustedEnvelope(lines, { note })) }] };
40
+ }
41
+ const FRIENDS_NOTE = `Friend names below are set by those Splitwise users, not the user. ${UNTRUSTED_CONTENT_RULE}`;
42
+ /** "owes you USD 12.50" / "you owe EUR 3.00", per non-zero currency. */
43
+ function balanceStatus(balance) {
44
+ if (!Array.isArray(balance))
45
+ return undefined;
46
+ const parts = [];
47
+ for (const entry of balance) {
48
+ const b = asDict(entry);
49
+ const amount = Number(b?.amount);
50
+ if (!b || !Number.isFinite(amount) || amount === 0)
51
+ continue;
52
+ const money = `${String(b.currency_code ?? '')} ${Math.abs(amount).toFixed(2)}`.trim();
53
+ parts.push(amount > 0 ? `owes you ${money}` : `you owe ${money}`);
54
+ }
55
+ return parts.length > 0 ? parts.join('; ') : undefined;
56
+ }
57
+ /**
58
+ * `sw_list_friends` brief: one line per friend with an outstanding balance
59
+ * (Splitwise's friend balance is the total across every group), then a
60
+ * `-- N settled up` note counting the rest, so nothing is dropped unsaid.
61
+ */
62
+ export function friendsBrief(payload) {
63
+ const friends = asDict(payload)?.friends;
64
+ if (!Array.isArray(friends)) {
65
+ return enveloped(['-- error: Splitwise returned no friends list'], FRIENDS_NOTE);
66
+ }
67
+ const lines = [];
68
+ let settled = 0;
69
+ for (const raw of friends) {
70
+ const f = asDict(raw);
71
+ const status = balanceStatus(f?.balance);
72
+ if (status === undefined) {
73
+ settled += 1;
74
+ continue;
75
+ }
76
+ lines.push(briefLine(nameOf(f) ?? `user ${String(f?.id ?? '?')}`, 'no date', status));
77
+ }
78
+ if (lines.length === 0)
79
+ return enveloped(['-- none'], FRIENDS_NOTE);
80
+ if (settled > 0)
81
+ lines.push(`-- ${settled} settled up`);
82
+ return enveloped(lines, FRIENDS_NOTE);
83
+ }
84
+ /** Splitwise's notification `type` codes, in the words of the activity feed. */
85
+ const NOTIFICATION_KIND = {
86
+ 0: 'expense added',
87
+ 1: 'expense updated',
88
+ 2: 'expense deleted',
89
+ 3: 'comment added',
90
+ 4: 'added to group',
91
+ 5: 'removed from group',
92
+ 6: 'group deleted',
93
+ 7: 'group settings changed',
94
+ 8: 'added as friend',
95
+ 9: 'removed as friend',
96
+ 10: 'news',
97
+ 11: 'debt simplification',
98
+ 12: 'group restored',
99
+ 13: 'expense restored',
100
+ 14: 'group currency conversion',
101
+ 15: 'friend currency conversion',
102
+ };
103
+ const ENTITIES = {
104
+ amp: '&', lt: '<', gt: '>', quot: '"', apos: "'", nbsp: ' ',
105
+ ldquo: '“', rdquo: '”', lsquo: '‘', rsquo: '’', hellip: '…', mdash: '—', ndash: '–',
106
+ };
107
+ /** Splitwise's notification `content` is a short HTML fragment: make it one plain line. */
108
+ export function plainText(html) {
109
+ if (typeof html !== 'string')
110
+ return '';
111
+ return html
112
+ .replace(/<[^>]*>/g, '')
113
+ .replace(/&(#x[0-9a-f]+|#\d+|[a-z]+);/gi, (whole, code) => {
114
+ if (code[0] === '#') {
115
+ const n = code[1] === 'x' || code[1] === 'X' ? parseInt(code.slice(2), 16) : parseInt(code.slice(1), 10);
116
+ return Number.isFinite(n) && n > 0 && n <= 0x10ffff ? String.fromCodePoint(n) : whole;
117
+ }
118
+ return ENTITIES[code.toLowerCase()] ?? whole;
119
+ })
120
+ .replace(/\s+/g, ' ')
121
+ .trim();
122
+ }
123
+ function isoDate(value) {
124
+ return typeof value === 'string' && /^\d{4}-\d{2}-\d{2}/.test(value) ? value.slice(0, 10) : 'no date';
125
+ }
126
+ /** `sw_get_notifications` brief: one line per activity item, newest first as Splitwise sends them. */
127
+ export function notificationsBrief(payload) {
128
+ const items = asDict(payload)?.notifications;
129
+ if (!Array.isArray(items)) {
130
+ return enveloped(['-- error: Splitwise returned no notifications list'], SW_UNTRUSTED_NOTE);
131
+ }
132
+ if (items.length === 0)
133
+ return enveloped(['-- none'], SW_UNTRUSTED_NOTE);
134
+ const lines = items.slice(0, BRIEF_NOTIFICATION_CAP).map((raw) => {
135
+ const n = asDict(raw);
136
+ const kind = typeof n?.type === 'number' ? NOTIFICATION_KIND[n.type] : undefined;
137
+ return briefLine(plainText(n?.content) || '(no text)', isoDate(n?.created_at), kind ?? 'activity');
138
+ });
139
+ if (items.length > BRIEF_NOTIFICATION_CAP)
140
+ lines.push(`-- ${BRIEF_NOTIFICATION_CAP} of ${items.length} shown`);
141
+ return enveloped(lines, SW_UNTRUSTED_NOTE);
142
+ }
@@ -33,7 +33,7 @@ export const receiptSchema = z
33
33
  upload_id: z
34
34
  .string()
35
35
  .min(1)
36
- .describe('The upload_id from sw_get_upload_url, after the file was PUT to its upload_url. The way to send a full-size file to the hosted server without base64.')
36
+ .describe('The upload_id from sw_get_upload_url, after the file was PUT to its upload_url. The way to send a full-size file to the hosted server without base64. Single use: the upload is deleted once Splitwise has the receipt (the reply says upload_deleted); a failed or declined write keeps it, so the same upload_id can be retried.')
37
37
  .optional(),
38
38
  filename: z.string().min(1).max(200).describe('Name to store the file under, e.g. "receipt.pdf".'),
39
39
  mime_type: z
@@ -146,7 +146,7 @@ async function readUpload(uploadId, client) {
146
146
  }
147
147
  catch (err) {
148
148
  if (err instanceof ApiError && (err.status === 404 || err.status === 403)) {
149
- throw new Error(`No upload found for receipt.upload_id ${uploadId} — the PUT may not have run, failed, or the upload expired. Get a fresh link with sw_get_upload_url and upload the file again.`);
149
+ throw new Error(`No upload found for receipt.upload_id ${uploadId} — the PUT may not have run, failed, the upload expired, or it was already used (an upload is deleted once its receipt is attached). Get a fresh link with sw_get_upload_url and upload the file again.`);
150
150
  }
151
151
  if (err.kind === 'too_large') {
152
152
  throw new Error(`The uploaded receipt is over the ${LIMIT_LABEL} limit (${MAX_RECEIPT_BYTES} bytes)`);
@@ -198,8 +198,40 @@ export async function loadReceipt(input, client) {
198
198
  filename,
199
199
  mimeType: type.mime,
200
200
  summary: { filename, mime_type: type.mime, bytes: bytes.length, sha256 },
201
+ ...(input.upload_id !== undefined ? { uploadId: input.upload_id } : {}),
201
202
  };
202
203
  }
204
+ /** A delete is housekeeping after a write that already landed: never let it hold the reply for long. */
205
+ const UPLOAD_DELETE_TIMEOUT_MS = 10_000;
206
+ /**
207
+ * Delete the blob-store copy of a receipt Splitwise now holds. Call it only
208
+ * after the write SUCCEEDED (and the file was verified stored): the preview may
209
+ * still be declined, and a failed write must leave the upload_id retryable.
210
+ *
211
+ * Never throws. The expense is already written, so an error here would read as
212
+ * a failed write and invite a duplicate; a delete that fails is reported, and
213
+ * the store's retention prune reclaims the object. A 404 means it is already
214
+ * gone (e.g. two ops in one batch used the same upload), which is the goal.
215
+ * Not `BlobStore.consume`: the read happens in `plan` (before the gate) and
216
+ * the write in `execute`, possibly calls apart, so there is no one callback
217
+ * to wrap.
218
+ */
219
+ export async function releaseUpload(receipt) {
220
+ if (receipt.uploadId === undefined)
221
+ return {};
222
+ try {
223
+ const store = blobStoreFromEnv();
224
+ if (!store)
225
+ throw new Error('no blob store is configured on this server');
226
+ await store.delete(receipt.uploadId, { timeoutMs: UPLOAD_DELETE_TIMEOUT_MS });
227
+ return { upload_deleted: true };
228
+ }
229
+ catch (err) {
230
+ if (err.status === 404)
231
+ return { upload_deleted: true };
232
+ return { upload_deleted: false, upload_delete_error: err instanceof Error ? err.message : String(err) };
233
+ }
234
+ }
203
235
  /**
204
236
  * Build the multipart body Splitwise takes when an expense carries a file:
205
237
  * every expense field as a form field (the same flat `users__N__*` keys the
@@ -1,4 +1,5 @@
1
1
  import { registerBatchTool, registerConfirmTool } from '@chrischall/mcp-utils';
2
+ import { EXPENSE_WRITE_ALIASES, moneyMeta, withToolMeta } from './hints.js';
2
3
  /**
3
4
  * `sw_confirm` and `sw_batch` — the two ways to spend fewer calls on Splitwise
4
5
  * writes from a client that cannot show a confirmation prompt (claude.ai).
@@ -14,9 +15,9 @@ import { registerBatchTool, registerConfirmTool } from '@chrischall/mcp-utils';
14
15
  * Registered LAST: the batch tool lists the writes registered before it.
15
16
  */
16
17
  export function registerBatchTools(server, _client) {
17
- registerConfirmTool(server, { name: 'sw_confirm' });
18
- registerBatchTool(server, {
18
+ withToolMeta(server, moneyMeta('other'), () => registerConfirmTool(server, { name: 'sw_confirm' }));
19
+ withToolMeta(server, moneyMeta('change', { aliases: EXPENSE_WRITE_ALIASES }), () => registerBatchTool(server, {
19
20
  name: 'sw_batch',
20
21
  description: 'Several Splitwise writes in one go, e.g. create an expense, then sw_create_comment / sw_attach_receipt on it with refs {"expense_id": "0.expense_id"}.',
21
- });
22
+ }));
22
23
  }
@@ -2,10 +2,11 @@ import { z } from 'zod';
2
2
  import { EXPENSE_LIST_VIEWS, SW_VIEWS, viewExpense, viewExpenses } from '../project.js';
3
3
  import { buildQueryString, defineWrite, minifiedResult, registerWrite, resolveView, viewParam, } from '@chrischall/mcp-utils';
4
4
  import { UNTRUSTED_DESCRIPTION_SUFFIX, swUntrustedResult } from './_untrusted.js';
5
- import { buildReceiptForm, loadReceipt, receiptParam, receiptSchema, verifyReceiptStored, } from './_receipt.js';
5
+ import { buildReceiptForm, loadReceipt, receiptParam, receiptSchema, releaseUpload, verifyReceiptStored, } from './_receipt.js';
6
6
  import { attachComments, decodeCursor, encodeCursor, hasLocalFilters, projectForList, scanExpenses, MAX_COMMENT_ROWS, SCAN_BUDGET, } from './_list.js';
7
7
  import { findDuplicates, inferCategory, resolveCategory, resolveEqualSplit } from './_create.js';
8
8
  import { assertNotDeleted, explainForbidden, verifyRestored, withPostState } from './_expense-state.js';
9
+ import { moneyMeta, withToolMeta, EXPENSE_WRITE_ALIASES } from './hints.js';
9
10
  /** Flattens a users array into Splitwise's flat-param JSON format. */
10
11
  export function flattenUsers(users) {
11
12
  const flat = {};
@@ -62,13 +63,18 @@ function buildExpenseBody(args) {
62
63
  * Send a create/update. With a receipt the whole expense goes as multipart
63
64
  * (Splitwise only takes a file that way) and the response must show the file
64
65
  * stored; without one it stays JSON and the response passes through, exactly
65
- * as before.
66
+ * as before. A receipt that came from `upload_id` is deleted from the blob
67
+ * store once Splitwise has it (`upload_deleted`), here in `execute` — so a
68
+ * direct call, `sw_confirm` and `sw_batch` all clean up, and the preview never
69
+ * does.
66
70
  */
67
71
  async function sendExpenseWrite(client, path, body, receipt, expenseId) {
68
72
  if (!receipt)
69
73
  return client.request('POST', path, body);
70
74
  const data = await client.requestMultipart('POST', path, buildReceiptForm(body, receipt));
71
- return verifyReceiptStored(data, expenseId);
75
+ // Only past verification: a dropped file keeps the upload for sw_attach_receipt.
76
+ const stored = verifyReceiptStored(data, expenseId);
77
+ return { ...stored, ...(await releaseUpload(receipt)) };
72
78
  }
73
79
  /** The body the confirmation preview shows: the fields, plus the file's identity in place of its bytes. */
74
80
  function previewBody(body, receipt) {
@@ -83,6 +89,7 @@ export function registerExpenseTools(server, client) {
83
89
  server.registerTool('sw_list_expenses', {
84
90
  description: `List or search Splitwise expenses. All filters are optional. group_id / friend_id / dated_after / dated_before / updated_after / updated_before are applied by Splitwise; description_contains, include_deleted, created_by, min_cost / max_cost and category are applied here while paging through the history (up to ${SCAN_BUDGET} records per call), returning up to \`limit\` matches and a next_cursor to continue. For reconciliation use view:"rows" (one flat line per expense: id, date, description, cost, created_by, deleted_at, deleted_by, comments_count, has_receipt) and include_comments:true to see why entries were deleted. Deleted expenses are included unless include_deleted is false. ${UNTRUSTED_DESCRIPTION_SUFFIX}`,
85
91
  annotations: { readOnlyHint: true, openWorldHint: true },
92
+ _meta: moneyMeta('lookup', { aliases: ['expenses', 'shared expenses', 'what did we spend', 'bills'] }),
86
93
  inputSchema: z.object({
87
94
  view: z
88
95
  .enum(EXPENSE_LIST_VIEWS)
@@ -189,6 +196,7 @@ export function registerExpenseTools(server, client) {
189
196
  server.registerTool('sw_get_expense', {
190
197
  description: `Get full details of a single Splitwise expense by id. ${UNTRUSTED_DESCRIPTION_SUFFIX}`,
191
198
  annotations: { readOnlyHint: true, openWorldHint: true },
199
+ _meta: moneyMeta('lookup'),
192
200
  inputSchema: z.object({
193
201
  view: viewParam(SW_VIEWS, {
194
202
  note: 'compact keeps the share breakdown, repayments and receipt presence and drops the avatars and the repeat/reminder/transaction block; "full" returns Splitwise\'s whole records.',
@@ -199,7 +207,7 @@ export function registerExpenseTools(server, client) {
199
207
  const data = await client.request('GET', `/get_expense/${id}`);
200
208
  return swUntrustedResult(viewExpense(resolveView(view, SW_VIEWS), data));
201
209
  });
202
- registerWrite(server, defineWrite({
210
+ withToolMeta(server, moneyMeta('change', { aliases: EXPENSE_WRITE_ALIASES }), () => registerWrite(server, defineWrite({
203
211
  name: 'sw_create_expense',
204
212
  description: `Create a Splitwise expense. Splits: split_equally:true (you paid, everyone in the group owes an equal part); split:"equal" with paid_by (someone — e.g. the other parent — paid, split equally among split_among or the whole group); or a users array for custom per-person splits (paid_share and owed_share as decimal strings like "25.00"). cost must be a decimal string. Pass category by name (or category_id); with neither, the most common category of similar expenses in the group is used and shown in the preview as inferred_category. The preview also lists possible_duplicates — expenses in the group, live or deleted, with the same cost, a similar description and a date within a day; check them before confirming. Pass receipt to attach an image or PDF in the same call. This puts charges on every participant's balance and notifies them. In sw_batch it produces expense_id.`,
205
213
  inputSchema: z.object({
@@ -300,8 +308,8 @@ export function registerExpenseTools(server, client) {
300
308
  execute: async ({ body, receipt }) => withPostState(await sendExpenseWrite(client, '/create_expense', body, receipt)),
301
309
  render: minifiedResult,
302
310
  produces: { expense_id: (result) => createdExpenseId(result) },
303
- }));
304
- registerWrite(server, defineWrite({
311
+ })));
312
+ withToolMeta(server, moneyMeta('change'), () => registerWrite(server, defineWrite({
305
313
  name: 'sw_update_expense',
306
314
  description: 'Edit an existing Splitwise expense. Provide expense_id and any fields to change. For custom split updates, the full users array must be provided (the API replaces the entire split). To attach or replace the receipt on an existing expense, pass expense_id and receipt alone.',
307
315
  inputSchema: z.object({
@@ -337,10 +345,10 @@ export function registerExpenseTools(server, client) {
337
345
  },
338
346
  execute: async ({ expense_id, body, receipt }) => withPostState(await explainForbidden(client, expense_id, () => sendExpenseWrite(client, `/update_expense/${expense_id}`, body, receipt, expense_id))),
339
347
  render: minifiedResult,
340
- }));
341
- registerWrite(server, defineWrite({
348
+ })));
349
+ withToolMeta(server, moneyMeta('change'), () => registerWrite(server, defineWrite({
342
350
  name: 'sw_attach_receipt',
343
- description: 'Attach (or replace) the receipt image or PDF on an existing Splitwise expense. Changes nothing else on the expense — cost, split and description are not sent. Pass the file as receipt.upload_id (hosted: upload it first via sw_get_upload_url — no bytes in the tool call), receipt.path (local) or receipt.base64. Fails if Splitwise does not store the file.',
351
+ description: 'Attach (or replace) the receipt image or PDF on an existing Splitwise expense. Changes nothing else on the expense — cost, split and description are not sent. Pass the file as receipt.upload_id (hosted: upload it first via sw_get_upload_url — no bytes in the tool call), receipt.path (local) or receipt.base64. Fails if Splitwise does not store the file. An upload_id is deleted from the upload store once the file is stored (upload_deleted in the reply).',
344
352
  annotations: { readOnlyHint: false, destructiveHint: true, idempotentHint: true, openWorldHint: true },
345
353
  inputSchema: z.object({
346
354
  expense_id: z.number().describe('ID of the expense to attach the receipt to'),
@@ -363,8 +371,8 @@ export function registerExpenseTools(server, client) {
363
371
  },
364
372
  execute: async ({ expense_id, receipt }) => withPostState(await explainForbidden(client, expense_id, () => sendExpenseWrite(client, `/update_expense/${expense_id}`, {}, receipt, expense_id))),
365
373
  render: minifiedResult,
366
- }));
367
- registerWrite(server, defineWrite({
374
+ })));
375
+ withToolMeta(server, moneyMeta('change'), () => registerWrite(server, defineWrite({
368
376
  name: 'sw_delete_expense',
369
377
  description: 'Soft-delete a Splitwise expense by id. Returns {success: true} on success. Use sw_undelete_expense to restore.',
370
378
  inputSchema: z.object({
@@ -382,8 +390,8 @@ export function registerExpenseTools(server, client) {
382
390
  }),
383
391
  execute: (id) => client.request('POST', `/delete_expense/${id}`),
384
392
  render: minifiedResult,
385
- }));
386
- registerWrite(server, defineWrite({
393
+ })));
394
+ withToolMeta(server, moneyMeta('change'), () => registerWrite(server, defineWrite({
387
395
  name: 'sw_undelete_expense',
388
396
  description: 'Restore a soft-deleted Splitwise expense, then re-read it and return post_state {deleted_at, deleted_by, updated_at, updated_by}; if it is still (or again) deleted, the call fails naming who deleted it and when. This puts its charges back on every participant\'s balance and notifies them, so it is gated like the other writes.',
389
397
  // Destructive despite the delete tool being its record-level inverse:
@@ -406,5 +414,5 @@ export function registerExpenseTools(server, client) {
406
414
  }),
407
415
  execute: async (id) => verifyRestored(client, id, await client.request('POST', `/undelete_expense/${id}`)),
408
416
  render: minifiedResult,
409
- }));
417
+ })));
410
418
  }
@@ -1,21 +1,33 @@
1
1
  import { z } from 'zod';
2
2
  import { PERSON_VIEW_NOTE, SW_VIEWS, viewFriends } from '../project.js';
3
- import { minifiedResult, pruneUndefined, resolveView, viewParam } from '@chrischall/mcp-utils';
3
+ import { minifiedResult, pruneUndefined, resolveView } from '@chrischall/mcp-utils';
4
4
  import { CONFIRM_NOTE, confirmTokenParam, confirmWrite } from './_confirm.js';
5
+ import { moneyMeta } from './hints.js';
6
+ import { friendsBrief } from './_brief.js';
5
7
  export function registerFriendTools(server, client) {
6
8
  server.registerTool('sw_list_friends', {
7
9
  description: "List all Splitwise friends. Use this to resolve a friend's name to a user_id before adding them to a group or building a custom expense split. The compact default returns id, name (first_name + last_name joined), email, registration_status and any non-empty balance per friend; pass view:'full' for Splitwise's raw records, which keep first_name and last_name separate.",
8
10
  annotations: { readOnlyHint: true, openWorldHint: true },
11
+ _meta: moneyMeta('whats-due', {
12
+ canonical: true,
13
+ aliases: ['what do i owe', 'who owes me', 'balances', 'owe', 'settle up', 'splitwise', 'iou', 'debts'],
14
+ }),
9
15
  inputSchema: z.object({
10
- view: viewParam(SW_VIEWS, { note: PERSON_VIEW_NOTE }),
16
+ view: z
17
+ .enum([...SW_VIEWS, 'brief'])
18
+ .describe(`Response shape. ${PERSON_VIEW_NOTE} "brief" answers "what do I owe / who owes me": one line per friend with an outstanding balance (name — no date — "owes you <amount>" or "you owe <amount>"), inside the untrusted-data envelope, then a "-- N settled up" note; "-- none" when nothing is owed either way.`)
19
+ .optional(),
11
20
  }),
12
21
  }, async ({ view }) => {
13
22
  const data = await client.request('GET', '/get_friends');
23
+ if (view === 'brief')
24
+ return friendsBrief(data);
14
25
  return minifiedResult(viewFriends(resolveView(view, SW_VIEWS), data));
15
26
  });
16
27
  server.registerTool('sw_create_friend', {
17
28
  description: `Add a Splitwise friend by email (sends them an invite). ${CONFIRM_NOTE}`,
18
29
  annotations: { readOnlyHint: false, destructiveHint: true, openWorldHint: true },
30
+ _meta: moneyMeta('change'),
19
31
  inputSchema: z.object({
20
32
  user_email: z.string().describe('Email of the user to add as a friend'),
21
33
  user_first_name: z.string().describe('First name of the user').optional(),
@@ -44,6 +56,7 @@ export function registerFriendTools(server, client) {
44
56
  server.registerTool('sw_delete_friend', {
45
57
  description: `Remove a Splitwise friendship by user id. ${CONFIRM_NOTE}`,
46
58
  annotations: { readOnlyHint: false, destructiveHint: true, openWorldHint: true },
59
+ _meta: moneyMeta('change'),
47
60
  inputSchema: z.object({
48
61
  id: z.number().describe('User ID of the friend to remove'),
49
62
  confirmToken: confirmTokenParam,
@@ -2,10 +2,12 @@ import { z } from 'zod';
2
2
  import { SW_VIEWS, viewGroup, viewGroups } from '../project.js';
3
3
  import { minifiedResult, pruneUndefined, resolveView, viewParam } from '@chrischall/mcp-utils';
4
4
  import { CONFIRM_NOTE, confirmTokenParam, confirmWrite } from './_confirm.js';
5
+ import { moneyMeta } from './hints.js';
5
6
  export function registerGroupTools(server, client) {
6
7
  server.registerTool('sw_list_groups', {
7
8
  description: 'List all Splitwise groups the current user belongs to. Returns id, name, and members for each group. Use this to resolve a group name to its id.',
8
9
  annotations: { readOnlyHint: true, openWorldHint: true },
10
+ _meta: moneyMeta('lookup', { aliases: ['splitwise groups', 'shared groups', 'house', 'trip'] }),
9
11
  inputSchema: z.object({
10
12
  view: viewParam(SW_VIEWS, {
11
13
  note: 'compact drops the avatar/cover-photo URLs (60% of a live 51-group response, which does not fit in a tool result at all) and the whiteboard/reminder settings; "full" returns Splitwise\'s whole records.',
@@ -18,6 +20,7 @@ export function registerGroupTools(server, client) {
18
20
  server.registerTool('sw_get_group', {
19
21
  description: 'Get details of a single Splitwise group including all members and balances.',
20
22
  annotations: { readOnlyHint: true, openWorldHint: true },
23
+ _meta: moneyMeta('lookup', { aliases: ['group balance', 'who owes whom'] }),
21
24
  inputSchema: z.object({
22
25
  view: viewParam(SW_VIEWS, {
23
26
  note: 'compact drops the avatar/cover-photo URLs (60% of a live 51-group response, which does not fit in a tool result at all) and the whiteboard/reminder settings; "full" returns Splitwise\'s whole records.',
@@ -31,6 +34,7 @@ export function registerGroupTools(server, client) {
31
34
  server.registerTool('sw_create_group', {
32
35
  description: `Create a new Splitwise group. ${CONFIRM_NOTE}`,
33
36
  annotations: { readOnlyHint: false, destructiveHint: false, openWorldHint: true },
37
+ _meta: moneyMeta('change'),
34
38
  inputSchema: z.object({
35
39
  name: z.string().describe('Group name'),
36
40
  group_type: z
@@ -65,6 +69,7 @@ export function registerGroupTools(server, client) {
65
69
  server.registerTool('sw_add_user_to_group', {
66
70
  description: `Add a user to a Splitwise group. Provide user_id (preferred, use sw_list_friends to resolve a name) or first_name + last_name + email to invite by email. ${CONFIRM_NOTE}`,
67
71
  annotations: { readOnlyHint: false, destructiveHint: true, openWorldHint: true },
72
+ _meta: moneyMeta('change'),
68
73
  inputSchema: z.object({
69
74
  group_id: z.number().describe('Group ID'),
70
75
  user_id: z.number().describe('User ID (preferred)').optional(),
@@ -104,6 +109,7 @@ export function registerGroupTools(server, client) {
104
109
  server.registerTool('sw_remove_user_from_group', {
105
110
  description: `Remove a user from a Splitwise group. ${CONFIRM_NOTE}`,
106
111
  annotations: { readOnlyHint: false, destructiveHint: true, openWorldHint: true },
112
+ _meta: moneyMeta('change'),
107
113
  inputSchema: z.object({
108
114
  group_id: z.number().describe('Group ID'),
109
115
  user_id: z.number().describe('User ID to remove'),
@@ -130,6 +136,7 @@ export function registerGroupTools(server, client) {
130
136
  server.registerTool('sw_delete_group', {
131
137
  description: `Soft-delete a Splitwise group. ${CONFIRM_NOTE}`,
132
138
  annotations: { readOnlyHint: false, destructiveHint: true, openWorldHint: true },
139
+ _meta: moneyMeta('change'),
133
140
  inputSchema: z.object({
134
141
  id: z.number().describe('Group ID to delete'),
135
142
  confirmToken: confirmTokenParam,
@@ -156,6 +163,7 @@ export function registerGroupTools(server, client) {
156
163
  // Destructive despite the delete tool being its record-level inverse:
157
164
  // the restore notifies every participant, and no later call un-sends that.
158
165
  annotations: { readOnlyHint: false, destructiveHint: true, openWorldHint: true },
166
+ _meta: moneyMeta('change'),
159
167
  inputSchema: z.object({
160
168
  id: z.number().describe('Group ID to restore'),
161
169
  confirmToken: confirmTokenParam,
@@ -0,0 +1,40 @@
1
+ // Context-group hints, published on each tool as `_meta.mint` (the mcp-host
2
+ // context-groups contract, `mintHintSchema`). Inert data: a host that does not
3
+ // read them sees an extra `_meta` key and nothing else. The shape is restated
4
+ // here rather than imported — this server takes no dependency on mcp-host.
5
+ //
6
+ // `subject` and `when` name THIS tool's own argument names (the host fills
7
+ // them), never values; `aliases` are the words a person says — lowercase, at
8
+ // most 8, each at most 40 chars. Splitwise names no `subject` anywhere: one API
9
+ // key is one account, and nothing here is a set of subjects a person would
10
+ // name (friends and groups are counterparties, not whose-question-is-this).
11
+ /** `_meta` for a Splitwise tool: every tool here is in the `money` group. */
12
+ export function moneyMeta(intent, extra = {}) {
13
+ return { mint: { domain: 'money', intent, ...extra } };
14
+ }
15
+ /**
16
+ * Run `register` with `_meta` added to every tool it registers.
17
+ *
18
+ * For mcp-utils' `registerWrite`, `registerConfirmTool` and `registerBatchTool`,
19
+ * which build the `registerTool` config themselves and take no `_meta`. The SAME
20
+ * server object is patched (not a proxy), so registerWrite's per-server write
21
+ * registry — what `sw_batch` reads — still keys on the real server; the original
22
+ * `registerTool` is restored afterwards, even when `register` throws.
23
+ */
24
+ export function withToolMeta(server, meta, register) {
25
+ const target = server;
26
+ const hadOwn = Object.prototype.hasOwnProperty.call(target, 'registerTool');
27
+ const original = target.registerTool;
28
+ target.registerTool = (name, config, ...rest) => original.call(server, name, { ...config, _meta: meta }, ...rest);
29
+ try {
30
+ register();
31
+ }
32
+ finally {
33
+ if (hadOwn)
34
+ target.registerTool = original;
35
+ else
36
+ delete target.registerTool;
37
+ }
38
+ }
39
+ /** The words a person says about a write that puts a charge on someone. */
40
+ export const EXPENSE_WRITE_ALIASES = ['split a bill', 'add an expense', 'log an expense', 'split the cost', 'i paid for'];
@@ -1,6 +1,7 @@
1
1
  import { z } from 'zod';
2
2
  import { minifiedResult, imageResult, readEnvVar, resolveOutputDir, writeBinaryOutput, sniffMimeBytes, extensionForMime, } from '@chrischall/mcp-utils';
3
3
  import { UNTRUSTED_DESCRIPTION_SUFFIX, swUntrustedEnvelope } from './_untrusted.js';
4
+ import { moneyMeta } from './hints.js';
4
5
  /** Cap on bytes returned as base64 in the tool result. */
5
6
  const MAX_INLINE_BYTES = 4 * 1024 * 1024;
6
7
  /** Cap on extracted characters, so a long PDF can't flood the result. */
@@ -80,6 +81,7 @@ export function registerReceiptTools(server, client) {
80
81
  // prompt. Not destructive either — `uniquePath` always picks a filename
81
82
  // that doesn't exist, so an existing file is never touched.
82
83
  annotations: { readOnlyHint: false, destructiveHint: false, openWorldHint: true },
84
+ _meta: moneyMeta('other', { aliases: ['receipt'] }),
83
85
  inputSchema: z.object({
84
86
  id: z.number().describe('Expense ID (the same id sw_get_expense takes)'),
85
87
  size: z
@@ -3,6 +3,7 @@ import { z } from 'zod';
3
3
  import { minifiedResult } from '@chrischall/mcp-utils';
4
4
  import { blobStoreFromEnv } from '@chrischall/mcp-utils/export';
5
5
  import { canonicalReceiptMime, MAX_RECEIPT_BYTES, safeKeyName } from './_receipt.js';
6
+ import { moneyMeta } from './hints.js';
6
7
  /** Long enough to run a curl in the same turn, short enough that a leaked link is soon dead. */
7
8
  export const UPLOAD_URL_TTL_MS = 10 * 60 * 1000;
8
9
  /**
@@ -15,8 +16,9 @@ export const UPLOAD_URL_TTL_MS = 10 * 60 * 1000;
15
16
  */
16
17
  export function registerUploadTools(server, _client) {
17
18
  server.registerTool('sw_get_upload_url', {
18
- description: 'Get a short-lived (~10 min) signed URL to upload a receipt file (PNG, JPEG, GIF or PDF, up to 10 MiB) to this server WITHOUT putting its bytes in a tool call: run the returned curl command from a shell or sandbox that has the file and network access to the URL\'s host, then pass receipt: {upload_id, filename} to sw_attach_receipt, sw_create_expense or sw_update_expense. Send the original file — no need to shrink it. Only on the hosted server; locally use receipt.path. If you have no shell that can reach the URL, use receipt.base64 instead.',
19
+ description: 'Get a short-lived (~10 min) signed URL to upload a receipt file (PNG, JPEG, GIF or PDF, up to 10 MiB) to this server WITHOUT putting its bytes in a tool call: run the returned curl command from a shell or sandbox that has the file and network access to the URL\'s host, then pass receipt: {upload_id, filename} to sw_attach_receipt, sw_create_expense or sw_update_expense. Send the original file — no need to shrink it. The upload is deleted from this server once the receipt is attached (a failed write keeps it for a retry), so each upload_id is used once. Only on the hosted server; locally use receipt.path. If you have no shell that can reach the URL, use receipt.base64 instead.',
19
20
  annotations: { readOnlyHint: true, openWorldHint: true },
21
+ _meta: moneyMeta('other'),
20
22
  inputSchema: z.object({
21
23
  filename: z.string().min(1).max(200).describe('Name of the file, e.g. "receipt.jpg"'),
22
24
  content_type: z
@@ -2,10 +2,12 @@ import { z } from 'zod';
2
2
  import { PERSON_VIEW_NOTE, SW_VIEWS, viewUser } from '../project.js';
3
3
  import { minifiedResult, pruneUndefined, resolveView, viewParam } from '@chrischall/mcp-utils';
4
4
  import { CONFIRM_NOTE, confirmTokenParam, confirmWrite } from './_confirm.js';
5
+ import { moneyMeta } from './hints.js';
5
6
  export function registerUserTools(server, client) {
6
7
  server.registerTool('sw_get_current_user', {
7
8
  description: "Get the authenticated Splitwise user's profile. Use the returned id when building custom expense splits. The compact default returns id, name (first_name + last_name joined), email and registration_status; pass view:'full' for Splitwise's raw record, which keeps first_name and last_name separate — the form sw_update_user takes.",
8
9
  annotations: { readOnlyHint: true, openWorldHint: true },
10
+ _meta: moneyMeta('lookup'),
9
11
  inputSchema: z.object({
10
12
  view: viewParam(SW_VIEWS, { note: PERSON_VIEW_NOTE }),
11
13
  }),
@@ -16,6 +18,7 @@ export function registerUserTools(server, client) {
16
18
  server.registerTool('sw_get_user', {
17
19
  description: "Get another Splitwise user's profile by id. Same shape as sw_get_current_user: the compact default merges first_name/last_name into name, and view:'full' keeps them separate.",
18
20
  annotations: { readOnlyHint: true, openWorldHint: true },
21
+ _meta: moneyMeta('lookup'),
19
22
  inputSchema: z.object({
20
23
  view: viewParam(SW_VIEWS, { note: PERSON_VIEW_NOTE }),
21
24
  id: z.number().describe('User ID'),
@@ -27,6 +30,7 @@ export function registerUserTools(server, client) {
27
30
  server.registerTool('sw_update_user', {
28
31
  description: "Update the current user's profile fields: name, locale and default currency. id must be the current user's id. The login email and password are deliberately not settable here — account credentials are changed in the Splitwise app, not by an assistant. " + CONFIRM_NOTE,
29
32
  annotations: { readOnlyHint: false, destructiveHint: true, openWorldHint: true },
33
+ _meta: moneyMeta('change'),
30
34
  // No `email` / `password`: changing the login email is an account
31
35
  // takeover primitive (a password reset to the new address follows), and
32
36
  // the only gate would be a model-driven confirmation that injected text from
@@ -3,22 +3,32 @@ import { SW_VIEWS, viewGeneric } from '../project.js';
3
3
  import { buildQueryString, defineWrite, minifiedResult, registerWrite, resolveView, viewParam, } from '@chrischall/mcp-utils';
4
4
  import { UNTRUSTED_DESCRIPTION_SUFFIX, swUntrustedResult } from './_untrusted.js';
5
5
  import { assertNotDeleted, explainForbidden } from './_expense-state.js';
6
+ import { moneyMeta, withToolMeta } from './hints.js';
7
+ import { BRIEF_NOTIFICATION_CAP, notificationsBrief } from './_brief.js';
6
8
  export function registerUtilityTools(server, client) {
7
9
  server.registerTool('sw_get_notifications', {
8
10
  description: `Get recent Splitwise activity notifications for the current user. ${UNTRUSTED_DESCRIPTION_SUFFIX}`,
9
11
  annotations: { readOnlyHint: true, openWorldHint: true },
12
+ _meta: moneyMeta('whats-new', {
13
+ canonical: true,
14
+ aliases: ['splitwise', 'anything new', 'recent activity', 'activity', 'new expenses'],
15
+ }),
10
16
  inputSchema: z.object({
11
- view: viewParam(SW_VIEWS, {
12
- note: 'compact drops the avatar URLs; "full" returns Splitwise\'s whole record.',
13
- }),
17
+ view: z
18
+ .enum([...SW_VIEWS, 'brief'])
19
+ .describe(`Response shape. compact (default) drops the avatar URLs; "full" returns Splitwise's whole record; "brief" is one line per notification (text — ISO date — kind, e.g. "expense added"), at most ${BRIEF_NOTIFICATION_CAP} with a "-- N of M shown" note past that, "-- none" when there is no activity.`)
20
+ .optional(),
14
21
  }),
15
22
  }, async ({ view }) => {
16
23
  const data = await client.request('GET', '/get_notifications');
24
+ if (view === 'brief')
25
+ return notificationsBrief(data);
17
26
  return swUntrustedResult(viewGeneric(resolveView(view, SW_VIEWS), data));
18
27
  });
19
28
  server.registerTool('sw_get_categories', {
20
29
  description: 'Get the hierarchical list of Splitwise expense categories. Use the returned id as category_id when creating expenses.',
21
30
  annotations: { readOnlyHint: true, openWorldHint: true },
31
+ _meta: moneyMeta('lookup'),
22
32
  }, async () => {
23
33
  const data = await client.request('GET', '/get_categories');
24
34
  return minifiedResult(data);
@@ -26,6 +36,7 @@ export function registerUtilityTools(server, client) {
26
36
  server.registerTool('sw_get_currencies', {
27
37
  description: 'Get all Splitwise-supported currency codes and units. Use the currency_code value when creating expenses in non-default currencies.',
28
38
  annotations: { readOnlyHint: true, openWorldHint: true },
39
+ _meta: moneyMeta('lookup'),
29
40
  }, async () => {
30
41
  const data = await client.request('GET', '/get_currencies');
31
42
  return minifiedResult(data);
@@ -33,6 +44,7 @@ export function registerUtilityTools(server, client) {
33
44
  server.registerTool('sw_get_comments', {
34
45
  description: `Get all comments on a Splitwise expense. ${UNTRUSTED_DESCRIPTION_SUFFIX}`,
35
46
  annotations: { readOnlyHint: true, openWorldHint: true },
47
+ _meta: moneyMeta('lookup'),
36
48
  inputSchema: z.object({
37
49
  view: viewParam(SW_VIEWS, {
38
50
  note: 'compact drops the avatar URLs; "full" returns Splitwise\'s whole record.',
@@ -45,7 +57,7 @@ export function registerUtilityTools(server, client) {
45
57
  const data = await client.request('GET', `/get_comments${buildQueryString({ expense_id })}`);
46
58
  return swUntrustedResult(viewGeneric(resolveView(view, SW_VIEWS), data));
47
59
  });
48
- registerWrite(server, defineWrite({
60
+ withToolMeta(server, moneyMeta('send', { aliases: ['comment on an expense'] }), () => registerWrite(server, defineWrite({
49
61
  name: 'sw_create_comment',
50
62
  description: 'Add a comment to a Splitwise expense (visible to other participants). Refused up front, naming who deleted it, if the expense is deleted — restore it with sw_undelete_expense first.',
51
63
  inputSchema: z.object({
@@ -68,8 +80,8 @@ export function registerUtilityTools(server, client) {
68
80
  },
69
81
  execute: ({ expense_id, content }) => explainForbidden(client, expense_id, () => client.request('POST', '/create_comment', { expense_id, content })),
70
82
  render: minifiedResult,
71
- }));
72
- registerWrite(server, defineWrite({
83
+ })));
84
+ withToolMeta(server, moneyMeta('change'), () => registerWrite(server, defineWrite({
73
85
  name: 'sw_delete_comment',
74
86
  description: 'Delete a comment by id.',
75
87
  inputSchema: z.object({
@@ -87,5 +99,5 @@ export function registerUtilityTools(server, client) {
87
99
  }),
88
100
  execute: (id) => client.request('POST', `/delete_comment/${id}`),
89
101
  render: minifiedResult,
90
- }));
102
+ })));
91
103
  }