splitwise-mcp 3.4.1 → 3.5.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.
@@ -0,0 +1,61 @@
1
+ /**
2
+ * The shares for "`payer` paid it all, everyone in `participants` owes an
3
+ * equal part". Built here rather than with Splitwise's `split_equally`, which
4
+ * always makes the CALLER the payer — the 50/50 case where the other parent
5
+ * paid had to be hand-built as a users[] array.
6
+ *
7
+ * Arithmetic is in minor units at the precision the cost string was written
8
+ * with ("25.01" → cents, "1001" → whole units), so the shares always sum to
9
+ * the cost exactly. Leftover units go one each to the first participants in
10
+ * order — deterministic, and shown in the preview before anything is sent.
11
+ */
12
+ export function equalSplit(cost, payer, participants) {
13
+ const match = /^(\d+)(?:\.(\d+))?$/.exec(cost);
14
+ if (!match)
15
+ throw new Error(`cost must be a plain decimal string like "25.00" for an equal split (got "${cost}")`);
16
+ const ids = [...new Set(participants)];
17
+ if (ids.length === 0)
18
+ throw new Error('split_among must name at least one participant');
19
+ const decimals = match[2]?.length ?? 0;
20
+ const total = BigInt(match[1] + (match[2] ?? ''));
21
+ const count = BigInt(ids.length);
22
+ const base = total / count;
23
+ const leftover = Number(total % count);
24
+ const fmt = (units) => formatUnits(units, decimals);
25
+ const shares = ids.map((id, i) => ({
26
+ user_id: id,
27
+ paid_share: fmt(id === payer ? total : 0n),
28
+ owed_share: fmt(base + (i < leftover ? 1n : 0n)),
29
+ }));
30
+ if (!ids.includes(payer)) {
31
+ shares.push({ user_id: payer, paid_share: fmt(total), owed_share: fmt(0n) });
32
+ }
33
+ return shares;
34
+ }
35
+ function formatUnits(units, decimals) {
36
+ if (decimals === 0)
37
+ return units.toString();
38
+ const digits = units.toString().padStart(decimals + 1, '0');
39
+ return `${digits.slice(0, -decimals)}.${digits.slice(-decimals)}`;
40
+ }
41
+ const fullName = (m) => [m.first_name, m.last_name].filter(Boolean).join(' ');
42
+ /**
43
+ * A member named by id, full name, email or a first name only they have.
44
+ * `field` names the argument in the error, so the caller knows which to fix.
45
+ */
46
+ export function resolveMember(who, members, field) {
47
+ if (typeof who === 'number') {
48
+ if (members.some((m) => m.id === who))
49
+ return who;
50
+ throw new Error(`${field}: user ${who} is not a member of this group`);
51
+ }
52
+ const want = who.trim().toLowerCase();
53
+ const exact = members.filter((m) => fullName(m).toLowerCase() === want || (m.email ?? '').toLowerCase() === want);
54
+ const candidates = exact.length > 0 ? exact : members.filter((m) => (m.first_name ?? '').toLowerCase() === want);
55
+ if (candidates.length === 1)
56
+ return candidates[0].id;
57
+ if (candidates.length > 1) {
58
+ throw new Error(`${field}: "${who}" is ambiguous — ${candidates.map((m) => `${fullName(m)} (${m.id})`).join(', ')}. Pass the user id.`);
59
+ }
60
+ throw new Error(`${field}: "${who}" is not a member of this group (members: ${members.map((m) => `${fullName(m)} (${m.id})`).join(', ')})`);
61
+ }
@@ -0,0 +1,22 @@
1
+ import { registerBatchTool, registerConfirmTool } from '@chrischall/mcp-utils';
2
+ /**
3
+ * `sw_confirm` and `sw_batch` — the two ways to spend fewer calls on Splitwise
4
+ * writes from a client that cannot show a confirmation prompt (claude.ai).
5
+ *
6
+ * - `sw_confirm({confirmToken})` finishes any gated write's two-step
7
+ * confirmation with the token alone: the server kept the phase-1 arguments,
8
+ * so a base64 receipt does not cross the conversation a second time.
9
+ * - `sw_batch({ops})` previews and approves several expense/comment writes at
10
+ * once — "log the expense, comment on it, attach the receipt" in one
11
+ * approval, with the new expense's id fed forward through `refs`.
12
+ *
13
+ * Both come from `@chrischall/mcp-utils` (docs/CONFIRM-TOKEN.md §13–14).
14
+ * Registered LAST: the batch tool lists the writes registered before it.
15
+ */
16
+ export function registerBatchTools(server, _client) {
17
+ registerConfirmTool(server, { name: 'sw_confirm' });
18
+ registerBatchTool(server, {
19
+ name: 'sw_batch',
20
+ 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
+ }
@@ -1,9 +1,11 @@
1
1
  import { z } from 'zod';
2
- import { SW_VIEWS, viewExpense, viewExpenses } from '../project.js';
3
- import { buildQueryString, minifiedResult, resolveView, viewParam } from '@chrischall/mcp-utils';
4
- import { CONFIRM_NOTE, confirmTokenParam, confirmWrite } from './_confirm.js';
2
+ import { EXPENSE_LIST_VIEWS, SW_VIEWS, viewExpense, viewExpenses } from '../project.js';
3
+ import { buildQueryString, defineWrite, minifiedResult, registerWrite, resolveView, viewParam, } from '@chrischall/mcp-utils';
5
4
  import { UNTRUSTED_DESCRIPTION_SUFFIX, swUntrustedResult } from './_untrusted.js';
6
5
  import { buildReceiptForm, loadReceipt, receiptParam, receiptSchema, verifyReceiptStored, } from './_receipt.js';
6
+ import { attachComments, decodeCursor, encodeCursor, hasLocalFilters, projectForList, scanExpenses, MAX_COMMENT_ROWS, SCAN_BUDGET, } from './_list.js';
7
+ import { findDuplicates, inferCategory, resolveCategory, resolveEqualSplit } from './_create.js';
8
+ import { assertNotDeleted, explainForbidden, verifyRestored, withPostState } from './_expense-state.js';
7
9
  /** Flattens a users array into Splitwise's flat-param JSON format. */
8
10
  export function flattenUsers(users) {
9
11
  const flat = {};
@@ -14,10 +16,36 @@ export function flattenUsers(users) {
14
16
  });
15
17
  return flat;
16
18
  }
19
+ /** A full unfiltered page may have more behind it: hand back a cursor to it. */
20
+ function withCursor(out, page, offset, want, q, filters) {
21
+ if (!Array.isArray(page) || page.length < want || out === null || typeof out !== 'object')
22
+ return out;
23
+ return { ...out, next_cursor: encodeCursor(offset + page.length, q, filters) };
24
+ }
25
+ function commentNote(note) {
26
+ return note?.truncated
27
+ ? { comments_note: `Comments were inlined for the first ${MAX_COMMENT_ROWS} expenses that have any; narrow the filters or page with a smaller limit for the rest.` }
28
+ : {};
29
+ }
30
+ /**
31
+ * One API key per SplitwiseClient (the hosted path builds a client per user),
32
+ * so there is no account switch inside a server to bind — said explicitly,
33
+ * as `confirmWrite` requires.
34
+ */
35
+ const SINGLE_ACCOUNT = { account: undefined };
36
+ /** The id of the expense a create returned — what sw_batch feeds into a later op. */
37
+ function createdExpenseId(result) {
38
+ const id = result?.expenses?.[0]?.id;
39
+ if (typeof id !== 'number')
40
+ throw new Error('Splitwise returned no id for the created expense');
41
+ return id;
42
+ }
17
43
  function buildExpenseBody(args) {
18
44
  // `confirmToken` is a tool-gate input and `receipt` is sent as a file part,
19
45
  // so neither belongs in the expense fields.
20
- const { split_equally, users, expense_id: _id, confirmToken: _confirmToken, receipt: _receipt, ...rest } = args;
46
+ const { split_equally, users, expense_id: _id, confirmToken: _confirmToken, receipt: _receipt,
47
+ // Resolved by the create handler into category_id / users[].
48
+ category: _category, split: _split, paid_by: _paidBy, split_among: _splitAmong, ...rest } = args;
21
49
  if (split_equally && users) {
22
50
  throw new Error('Provide either split_equally or users, not both');
23
51
  }
@@ -53,12 +81,13 @@ const userShareSchema = z.object({
53
81
  });
54
82
  export function registerExpenseTools(server, client) {
55
83
  server.registerTool('sw_list_expenses', {
56
- description: `List or search Splitwise expenses. All filters are optional. Use group_id to filter by group, dated_after/dated_before for date ranges. ${UNTRUSTED_DESCRIPTION_SUFFIX}`,
84
+ 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}`,
57
85
  annotations: { readOnlyHint: true, openWorldHint: true },
58
86
  inputSchema: z.object({
59
- view: viewParam(SW_VIEWS, {
60
- 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.',
61
- }),
87
+ view: z
88
+ .enum(EXPENSE_LIST_VIEWS)
89
+ .describe('Response shape. "compact" (default) keeps the share breakdown, repayments and receipt presence and drops the avatars and the repeat/reminder/transaction block; "rows" is one flat line per expense for reconciling long histories; "full" returns Splitwise\'s whole records.')
90
+ .optional(),
62
91
  group_id: z.number().describe('Only expenses in this group').optional(),
63
92
  friend_id: z.number().describe('Only expenses with this friend').optional(),
64
93
  dated_after: z
@@ -71,6 +100,33 @@ export function registerExpenseTools(server, client) {
71
100
  .optional(),
72
101
  updated_after: z.string().describe('ISO 8601 datetime').optional(),
73
102
  updated_before: z.string().describe('ISO 8601 datetime').optional(),
103
+ description_contains: z
104
+ .string()
105
+ .min(1)
106
+ .describe('Only expenses whose description contains this text (case-insensitive)')
107
+ .optional(),
108
+ include_deleted: z
109
+ .union([z.boolean(), z.literal('only')])
110
+ .describe('true (default): live and deleted; false: live only; "only": deleted only')
111
+ .optional(),
112
+ created_by: z
113
+ .union([z.number(), z.string().min(1)])
114
+ .describe('Only expenses created by this user: a user id, or part of their name / their email')
115
+ .optional(),
116
+ min_cost: z.number().describe('Only expenses costing at least this much').optional(),
117
+ max_cost: z.number().describe('Only expenses costing at most this much').optional(),
118
+ category: z
119
+ .union([z.number(), z.string().min(1)])
120
+ .describe('Only expenses in this category: a category id, or its name (any case)')
121
+ .optional(),
122
+ include_comments: z
123
+ .boolean()
124
+ .describe(`Inline each matched expense's comments (only those that have any; at most ${MAX_COMMENT_ROWS} expenses per call)`)
125
+ .optional(),
126
+ cursor: z
127
+ .string()
128
+ .describe('next_cursor from a previous call with the same filters, to continue where it stopped')
129
+ .optional(),
74
130
  // Bounded: Splitwise reads limit=0 as "return everything", an
75
131
  // unbounded result the host refuses.
76
132
  limit: z
@@ -78,23 +134,57 @@ export function registerExpenseTools(server, client) {
78
134
  .int()
79
135
  .min(1)
80
136
  .max(200)
81
- .describe('Max results, 1-200 (API default: 20). Page with offset for more.')
137
+ .describe('Max results, 1-200 (default 20). With the local filters, the max number of MATCHES returned.')
82
138
  .optional(),
83
- offset: z.number().int().min(0).describe('Pagination offset').optional(),
139
+ offset: z.number().int().min(0).describe('Pagination offset (prefer cursor)').optional(),
84
140
  }),
85
141
  }, async (args) => {
86
- const qs = buildQueryString({
142
+ const view = args.view ?? 'compact';
143
+ const q = {
87
144
  group_id: args.group_id,
88
145
  friend_id: args.friend_id,
89
146
  dated_after: args.dated_after,
90
147
  dated_before: args.dated_before,
91
148
  updated_after: args.updated_after,
92
149
  updated_before: args.updated_before,
93
- limit: args.limit,
94
- offset: args.offset,
150
+ };
151
+ const filters = {
152
+ description_contains: args.description_contains,
153
+ include_deleted: args.include_deleted,
154
+ created_by: args.created_by,
155
+ min_cost: args.min_cost,
156
+ max_cost: args.max_cost,
157
+ category: args.category,
158
+ };
159
+ const want = args.limit ?? 20;
160
+ if (!hasLocalFilters(filters) && args.cursor === undefined) {
161
+ // No local filtering: one request, exactly as before.
162
+ const qs = buildQueryString({ ...q, limit: args.limit, offset: args.offset });
163
+ const data = await client.request('GET', `/get_expenses${qs}`);
164
+ const page = data?.expenses;
165
+ if (view === 'compact' && !args.include_comments) {
166
+ const out = viewExpenses('compact', data);
167
+ return swUntrustedResult(withCursor(out, page, (args.offset ?? 0), want, q, filters));
168
+ }
169
+ if (!Array.isArray(page))
170
+ return swUntrustedResult(data);
171
+ const rows = page.map((e) => projectForList(view, e));
172
+ const note = args.include_comments ? await attachComments(client, page, rows) : undefined;
173
+ return swUntrustedResult(withCursor({ expenses: rows, ...commentNote(note) }, page, args.offset ?? 0, want, q, filters));
174
+ }
175
+ const start = args.cursor !== undefined ? decodeCursor(args.cursor, q, filters) : (args.offset ?? 0);
176
+ const scan = await scanExpenses(client, q, filters, start, want);
177
+ const rows = scan.expenses.map((e) => projectForList(view, e));
178
+ const note = args.include_comments ? await attachComments(client, scan.expenses, rows) : undefined;
179
+ return swUntrustedResult({
180
+ expenses: rows,
181
+ scanned: scan.scanned,
182
+ ...(scan.nextOffset !== undefined ? { next_cursor: encodeCursor(scan.nextOffset, q, filters) } : {}),
183
+ ...(scan.nextOffset !== undefined && scan.expenses.length < want
184
+ ? { note: `Stopped after reading ${scan.scanned} records; pass next_cursor to keep searching older expenses.` }
185
+ : {}),
186
+ ...commentNote(note),
95
187
  });
96
- const data = await client.request('GET', `/get_expenses${qs}`);
97
- return swUntrustedResult(viewExpenses(resolveView(args.view, SW_VIEWS), data));
98
188
  });
99
189
  server.registerTool('sw_get_expense', {
100
190
  description: `Get full details of a single Splitwise expense by id. ${UNTRUSTED_DESCRIPTION_SUFFIX}`,
@@ -109,9 +199,9 @@ export function registerExpenseTools(server, client) {
109
199
  const data = await client.request('GET', `/get_expense/${id}`);
110
200
  return swUntrustedResult(viewExpense(resolveView(view, SW_VIEWS), data));
111
201
  });
112
- server.registerTool('sw_create_expense', {
113
- description: `Create a Splitwise expense. Use split_equally:true to split evenly among group members, or provide 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 receipt to attach an image or PDF in the same call. This puts charges on every participant's balance and notifies them. ${CONFIRM_NOTE}`,
114
- annotations: { readOnlyHint: false, destructiveHint: true, openWorldHint: true },
202
+ registerWrite(server, defineWrite({
203
+ name: 'sw_create_expense',
204
+ 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.`,
115
205
  inputSchema: z.object({
116
206
  group_id: z.number().describe('Group to add expense to (use 0 for no group)'),
117
207
  description: z.string().describe('Short description of the expense'),
@@ -124,38 +214,96 @@ export function registerExpenseTools(server, client) {
124
214
  .array(userShareSchema)
125
215
  .describe('Custom split (mutually exclusive with split_equally). Full list of participants required.')
126
216
  .optional(),
217
+ split: z
218
+ .literal('equal')
219
+ .describe('"equal": paid_by paid the whole cost and everyone in split_among owes an equal part (odd cents go to the first participants). Use instead of split_equally (which always makes YOU the payer) or users.')
220
+ .optional(),
221
+ paid_by: z
222
+ .union([z.number(), z.string().min(1)])
223
+ .describe('With split:"equal" — who paid: a user id, or a group member\'s name or email')
224
+ .optional(),
225
+ split_among: z
226
+ .array(z.union([z.number(), z.string().min(1)]))
227
+ .min(1)
228
+ .describe('With split:"equal" — who shares the cost (ids, names or emails). Default: every group member.')
229
+ .optional(),
127
230
  currency_code: z
128
231
  .string()
129
232
  .describe('Currency code, e.g. "USD". Defaults to group/user default.')
130
233
  .optional(),
131
234
  date: z.string().describe('ISO 8601 datetime').optional(),
132
235
  category_id: z.number().describe('Category id from sw_get_categories').optional(),
236
+ category: z
237
+ .string()
238
+ .min(1)
239
+ .describe('Category by name, e.g. "Education" or "Medical expenses" (any case). Mutually exclusive with category_id. Omit both and the most common category of similar expenses in the group is used.')
240
+ .optional(),
133
241
  details: z.string().describe('Notes').optional(),
134
242
  receipt: receiptParam,
135
- confirmToken: confirmTokenParam,
136
243
  }),
137
- }, async (args, ctx) => {
138
- const body = buildExpenseBody(args);
139
- const receipt = args.receipt ? await loadReceipt(args.receipt) : undefined;
140
- const gate = await confirmWrite(ctx, {
141
- tool: 'sw_create_expense',
142
- action: 'expense.create',
143
- summary: `Create a Splitwise expense "${args.description}" (${args.cost})${receipt ? ` with receipt ${receipt.filename}` : ''} — notifies group members`,
144
- method: 'POST',
145
- path: '/create_expense',
146
- body: previewBody(body, receipt),
147
- target: args.group_id,
148
- confirmToken: args.confirmToken,
149
- args,
150
- });
151
- if (gate)
152
- return gate;
153
- const data = await sendExpenseWrite(client, '/create_expense', body, receipt);
154
- return minifiedResult(data);
155
- });
156
- server.registerTool('sw_update_expense', {
157
- 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. ${CONFIRM_NOTE}`,
158
- annotations: { readOnlyHint: false, destructiveHint: true, openWorldHint: true },
244
+ plan: async (args) => {
245
+ if (args.category !== undefined && args.category_id !== undefined) {
246
+ throw new Error('Provide either category or category_id, not both');
247
+ }
248
+ if (args.split === 'equal') {
249
+ if (args.paid_by === undefined)
250
+ throw new Error('split:"equal" needs paid_by (who paid the whole cost)');
251
+ if (args.split_equally)
252
+ throw new Error('split:"equal" replaces split_equally — pass one or the other');
253
+ if (args.users)
254
+ throw new Error('split:"equal" builds users for you — pass split or users, not both');
255
+ }
256
+ else if (args.paid_by !== undefined || args.split_among !== undefined) {
257
+ throw new Error('paid_by and split_among only apply with split:"equal"');
258
+ }
259
+ const body = buildExpenseBody(args);
260
+ if (args.split === 'equal') {
261
+ Object.assign(body, flattenUsers(await resolveEqualSplit(client, {
262
+ group_id: args.group_id,
263
+ cost: args.cost,
264
+ paid_by: args.paid_by,
265
+ split_among: args.split_among,
266
+ })));
267
+ }
268
+ const extra = {};
269
+ if (args.category !== undefined) {
270
+ body.category_id = (await resolveCategory(client, args.category)).id;
271
+ }
272
+ else if (args.category_id === undefined && args.group_id > 0) {
273
+ const inferred = await inferCategory(client, args.group_id, args.description);
274
+ if (inferred) {
275
+ body.category_id = inferred.id;
276
+ extra.inferred_category = inferred;
277
+ }
278
+ }
279
+ const receipt = args.receipt ? await loadReceipt(args.receipt, client) : undefined;
280
+ const duplicates = args.group_id > 0
281
+ ? await findDuplicates(client, { group_id: args.group_id, cost: args.cost, description: args.description, date: args.date })
282
+ : [];
283
+ if (duplicates.length > 0)
284
+ extra.possible_duplicates = duplicates;
285
+ return {
286
+ gate: {
287
+ action: 'expense.create',
288
+ summary: `Create a Splitwise expense "${args.description}" (${args.cost})${receipt ? ` with receipt ${receipt.filename}` : ''} — notifies group members` +
289
+ (duplicates.length > 0
290
+ ? ` — WARNING: ${duplicates.length} possible duplicate${duplicates.length > 1 ? 's' : ''} already in the group (see possible_duplicates)`
291
+ : ''),
292
+ ...SINGLE_ACCOUNT,
293
+ target: args.group_id,
294
+ request: { method: 'POST', path: '/create_expense', body: previewBody(body, receipt) },
295
+ preview: extra,
296
+ },
297
+ state: { body, receipt },
298
+ };
299
+ },
300
+ execute: async ({ body, receipt }) => withPostState(await sendExpenseWrite(client, '/create_expense', body, receipt)),
301
+ render: minifiedResult,
302
+ produces: { expense_id: (result) => createdExpenseId(result) },
303
+ }));
304
+ registerWrite(server, defineWrite({
305
+ name: 'sw_update_expense',
306
+ 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.',
159
307
  inputSchema: z.object({
160
308
  expense_id: z.number().describe('ID of the expense to update'),
161
309
  description: z.string().optional(),
@@ -170,104 +318,93 @@ export function registerExpenseTools(server, client) {
170
318
  category_id: z.number().optional(),
171
319
  details: z.string().optional(),
172
320
  receipt: receiptParam,
173
- confirmToken: confirmTokenParam,
174
321
  }),
175
- }, async (args, ctx) => {
176
- const { expense_id } = args;
177
- const body = buildExpenseBody(args);
178
- const receipt = args.receipt ? await loadReceipt(args.receipt) : undefined;
179
- const gate = await confirmWrite(ctx, {
180
- tool: 'sw_update_expense',
181
- action: 'expense.update',
182
- summary: `Update Splitwise expense ${expense_id}${receipt ? ` (attach receipt ${receipt.filename})` : ''} — notifies group members`,
183
- method: 'POST',
184
- path: `/update_expense/${expense_id}`,
185
- body: previewBody(body, receipt),
186
- target: expense_id,
187
- confirmToken: args.confirmToken,
188
- args,
189
- });
190
- if (gate)
191
- return gate;
192
- const data = await sendExpenseWrite(client, `/update_expense/${expense_id}`, body, receipt, expense_id);
193
- return minifiedResult(data);
194
- });
195
- server.registerTool('sw_attach_receipt', {
196
- 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.base64. Fails if Splitwise does not store the file. ${CONFIRM_NOTE}`,
322
+ plan: async (args) => {
323
+ const { expense_id } = args;
324
+ const body = buildExpenseBody(args);
325
+ const receipt = args.receipt ? await loadReceipt(args.receipt, client) : undefined;
326
+ await assertNotDeleted(client, expense_id);
327
+ return {
328
+ gate: {
329
+ action: 'expense.update',
330
+ summary: `Update Splitwise expense ${expense_id}${receipt ? ` (attach receipt ${receipt.filename})` : ''} — notifies group members`,
331
+ ...SINGLE_ACCOUNT,
332
+ target: expense_id,
333
+ request: { method: 'POST', path: `/update_expense/${expense_id}`, body: previewBody(body, receipt) },
334
+ },
335
+ state: { expense_id, body, receipt },
336
+ };
337
+ },
338
+ execute: async ({ expense_id, body, receipt }) => withPostState(await explainForbidden(client, expense_id, () => sendExpenseWrite(client, `/update_expense/${expense_id}`, body, receipt, expense_id))),
339
+ render: minifiedResult,
340
+ }));
341
+ registerWrite(server, defineWrite({
342
+ 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.',
197
344
  annotations: { readOnlyHint: false, destructiveHint: true, idempotentHint: true, openWorldHint: true },
198
345
  inputSchema: z.object({
199
346
  expense_id: z.number().describe('ID of the expense to attach the receipt to'),
200
347
  receipt: receiptSchema,
201
- confirmToken: confirmTokenParam,
202
348
  }),
203
- }, async (args, ctx) => {
204
- const { expense_id } = args;
205
- const receipt = await loadReceipt(args.receipt);
206
- const path = `/update_expense/${expense_id}`;
207
- const gate = await confirmWrite(ctx, {
208
- tool: 'sw_attach_receipt',
209
- action: 'expense.attach_receipt',
210
- summary: `Attach receipt ${receipt.filename} to Splitwise expense ${expense_id} (replaces any existing receipt)`,
211
- method: 'POST',
212
- path,
213
- body: previewBody({}, receipt),
214
- target: expense_id,
215
- confirmToken: args.confirmToken,
216
- args,
217
- });
218
- if (gate)
219
- return gate;
220
- const data = await sendExpenseWrite(client, path, {}, receipt, expense_id);
221
- return minifiedResult(data);
222
- });
223
- server.registerTool('sw_delete_expense', {
224
- description: `Soft-delete a Splitwise expense by id. Returns {success: true} on success. Use sw_undelete_expense to restore. ${CONFIRM_NOTE}`,
225
- annotations: { readOnlyHint: false, destructiveHint: true, openWorldHint: true },
349
+ plan: async (args) => {
350
+ const { expense_id } = args;
351
+ const receipt = await loadReceipt(args.receipt, client);
352
+ await assertNotDeleted(client, expense_id);
353
+ return {
354
+ gate: {
355
+ action: 'expense.attach_receipt',
356
+ summary: `Attach receipt ${receipt.filename} to Splitwise expense ${expense_id} (replaces any existing receipt)`,
357
+ ...SINGLE_ACCOUNT,
358
+ target: expense_id,
359
+ request: { method: 'POST', path: `/update_expense/${expense_id}`, body: previewBody({}, receipt) },
360
+ },
361
+ state: { expense_id, receipt },
362
+ };
363
+ },
364
+ execute: async ({ expense_id, receipt }) => withPostState(await explainForbidden(client, expense_id, () => sendExpenseWrite(client, `/update_expense/${expense_id}`, {}, receipt, expense_id))),
365
+ render: minifiedResult,
366
+ }));
367
+ registerWrite(server, defineWrite({
368
+ name: 'sw_delete_expense',
369
+ description: 'Soft-delete a Splitwise expense by id. Returns {success: true} on success. Use sw_undelete_expense to restore.',
226
370
  inputSchema: z.object({
227
371
  id: z.number().describe('Expense ID to delete'),
228
- confirmToken: confirmTokenParam,
229
372
  }),
230
- }, async (args, ctx) => {
231
- const { id, confirmToken } = args;
232
- const gate = await confirmWrite(ctx, {
233
- tool: 'sw_delete_expense',
234
- action: 'expense.delete',
235
- summary: `Soft-delete Splitwise expense ${id}`,
236
- method: 'POST',
237
- path: `/delete_expense/${id}`,
238
- target: id,
239
- confirmToken,
240
- args,
241
- });
242
- if (gate)
243
- return gate;
244
- const data = await client.request('POST', `/delete_expense/${id}`);
245
- return minifiedResult(data);
246
- });
247
- server.registerTool('sw_undelete_expense', {
248
- description: `Restore a soft-deleted Splitwise expense. This puts its charges back on every participant's balance and notifies them, so it is gated like the other writes. ${CONFIRM_NOTE}`,
373
+ plan: ({ id }) => ({
374
+ gate: {
375
+ action: 'expense.delete',
376
+ summary: `Soft-delete Splitwise expense ${id}`,
377
+ ...SINGLE_ACCOUNT,
378
+ target: id,
379
+ request: { method: 'POST', path: `/delete_expense/${id}` },
380
+ },
381
+ state: id,
382
+ }),
383
+ execute: (id) => client.request('POST', `/delete_expense/${id}`),
384
+ render: minifiedResult,
385
+ }));
386
+ registerWrite(server, defineWrite({
387
+ name: 'sw_undelete_expense',
388
+ 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.',
249
389
  // Destructive despite the delete tool being its record-level inverse:
250
390
  // the restore notifies every participant, and no later call un-sends that.
391
+ // Stated rather than left to registerWrite's default, because it is a
392
+ // decision about this tool, not a default it happens to share.
251
393
  annotations: { readOnlyHint: false, destructiveHint: true, openWorldHint: true },
252
394
  inputSchema: z.object({
253
395
  id: z.number().describe('Expense ID to restore'),
254
- confirmToken: confirmTokenParam,
255
396
  }),
256
- }, async (args, ctx) => {
257
- const { id, confirmToken } = args;
258
- const gate = await confirmWrite(ctx, {
259
- tool: 'sw_undelete_expense',
260
- action: 'expense.undelete',
261
- summary: `Restore soft-deleted Splitwise expense ${id} — puts it back on balances and notifies its participants`,
262
- method: 'POST',
263
- path: `/undelete_expense/${id}`,
264
- target: id,
265
- confirmToken,
266
- args,
267
- });
268
- if (gate)
269
- return gate;
270
- const data = await client.request('POST', `/undelete_expense/${id}`);
271
- return minifiedResult(data);
272
- });
397
+ plan: ({ id }) => ({
398
+ gate: {
399
+ action: 'expense.undelete',
400
+ summary: `Restore soft-deleted Splitwise expense ${id} — puts it back on balances and notifies its participants`,
401
+ ...SINGLE_ACCOUNT,
402
+ target: id,
403
+ request: { method: 'POST', path: `/undelete_expense/${id}` },
404
+ },
405
+ state: id,
406
+ }),
407
+ execute: async (id) => verifyRestored(client, id, await client.request('POST', `/undelete_expense/${id}`)),
408
+ render: minifiedResult,
409
+ }));
273
410
  }
@@ -0,0 +1,45 @@
1
+ import { randomUUID } from 'crypto';
2
+ import { z } from 'zod';
3
+ import { minifiedResult } from '@chrischall/mcp-utils';
4
+ import { blobStoreFromEnv } from '@chrischall/mcp-utils/export';
5
+ import { canonicalReceiptMime, MAX_RECEIPT_BYTES, safeKeyName } from './_receipt.js';
6
+ /** Long enough to run a curl in the same turn, short enough that a leaked link is soon dead. */
7
+ export const UPLOAD_URL_TTL_MS = 10 * 60 * 1000;
8
+ /**
9
+ * `sw_get_upload_url` — get a receipt's bytes to the server without passing
10
+ * them through the model. A hosted server cannot read the caller's files
11
+ * (receipt.path is off) and base64 costs ~1.37 bytes of context per byte of
12
+ * image; a signed PUT moves the original file with one curl. Always
13
+ * registered, so the tool list is the same everywhere; on a server with no
14
+ * blob store it says which receipt source to use instead.
15
+ */
16
+ export function registerUploadTools(server, _client) {
17
+ 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
+ annotations: { readOnlyHint: true, openWorldHint: true },
20
+ inputSchema: z.object({
21
+ filename: z.string().min(1).max(200).describe('Name of the file, e.g. "receipt.jpg"'),
22
+ content_type: z
23
+ .string()
24
+ .describe('The file\'s type: image/png, image/jpeg, image/gif or application/pdf. The upload must send exactly this Content-Type.'),
25
+ }),
26
+ }, async ({ filename, content_type }) => {
27
+ const store = blobStoreFromEnv();
28
+ if (!store) {
29
+ throw new Error('sw_get_upload_url only works on the hosted server (no blob store is configured here). Pass the file as receipt.path (a file inside SPLITWISE_INPUT_DIR) or receipt.base64 instead.');
30
+ }
31
+ const contentType = canonicalReceiptMime(content_type);
32
+ const key = `up/${randomUUID()}/${safeKeyName(filename)}`;
33
+ const { url: uploadUrl, expiresAtMs: exp } = store.signPutUrl(key, contentType, { ttlMs: UPLOAD_URL_TTL_MS });
34
+ return minifiedResult({
35
+ upload_id: key,
36
+ upload_url: uploadUrl,
37
+ method: 'PUT',
38
+ headers: { 'Content-Type': contentType },
39
+ max_bytes: MAX_RECEIPT_BYTES,
40
+ expires_at: new Date(exp).toISOString(),
41
+ curl: `curl -sS -f -X PUT -H "Content-Type: ${contentType}" --data-binary @<path-to-file> "${uploadUrl}"`,
42
+ next: `After the upload succeeds, pass receipt: {"upload_id": "${key}", "filename": "${safeKeyName(filename)}"} to sw_attach_receipt (or sw_create_expense / sw_update_expense).`,
43
+ });
44
+ });
45
+ }