splitwise-mcp 3.4.1 → 3.5.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/.claude-plugin/marketplace.json +2 -2
- package/.claude-plugin/plugin.json +1 -1
- package/README.md +20 -7
- package/dist/bundle.js +1516 -457
- package/dist/index.js +6 -1
- package/dist/project.js +36 -4
- package/dist/tools/_blob.js +40 -0
- package/dist/tools/_confirm.js +1 -1
- package/dist/tools/_create.js +174 -0
- package/dist/tools/_expense-state.js +86 -0
- package/dist/tools/_list.js +166 -0
- package/dist/tools/_receipt.js +58 -10
- package/dist/tools/_split.js +61 -0
- package/dist/tools/batch.js +22 -0
- package/dist/tools/expenses.js +263 -126
- package/dist/tools/uploads.js +46 -0
- package/dist/tools/utilities.js +38 -45
- package/mint.yaml +7 -0
- package/package.json +6 -2
- package/server.json +2 -2
- package/skills/splitwise-mcp/SKILL.md +44 -8
package/dist/tools/expenses.js
CHANGED
|
@@ -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,
|
|
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.
|
|
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:
|
|
60
|
-
|
|
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 (
|
|
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
|
|
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
|
-
|
|
94
|
-
|
|
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
|
|
113
|
-
|
|
114
|
-
|
|
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
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
args
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
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
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
description:
|
|
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
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
}
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
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
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
return
|
|
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
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
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,46 @@
|
|
|
1
|
+
import { randomUUID } from 'crypto';
|
|
2
|
+
import { z } from 'zod';
|
|
3
|
+
import { minifiedResult } from '@chrischall/mcp-utils';
|
|
4
|
+
import { blobConfig, safeKeyName, signedPutUrl } from './_blob.js';
|
|
5
|
+
import { canonicalReceiptMime, MAX_RECEIPT_BYTES } 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 cfg = blobConfig();
|
|
28
|
+
if (!cfg) {
|
|
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 exp = Date.now() + UPLOAD_URL_TTL_MS;
|
|
34
|
+
const uploadUrl = signedPutUrl(cfg, key, contentType, exp);
|
|
35
|
+
return minifiedResult({
|
|
36
|
+
upload_id: key,
|
|
37
|
+
upload_url: uploadUrl,
|
|
38
|
+
method: 'PUT',
|
|
39
|
+
headers: { 'Content-Type': contentType },
|
|
40
|
+
max_bytes: MAX_RECEIPT_BYTES,
|
|
41
|
+
expires_at: new Date(exp).toISOString(),
|
|
42
|
+
curl: `curl -sS -f -X PUT -H "Content-Type: ${contentType}" --data-binary @<path-to-file> "${uploadUrl}"`,
|
|
43
|
+
next: `After the upload succeeds, pass receipt: {"upload_id": "${key}", "filename": "${safeKeyName(filename)}"} to sw_attach_receipt (or sw_create_expense / sw_update_expense).`,
|
|
44
|
+
});
|
|
45
|
+
});
|
|
46
|
+
}
|
package/dist/tools/utilities.js
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
import { z } from 'zod';
|
|
2
2
|
import { SW_VIEWS, viewGeneric } from '../project.js';
|
|
3
|
-
import { buildQueryString, minifiedResult, resolveView, viewParam } from '@chrischall/mcp-utils';
|
|
4
|
-
import { CONFIRM_NOTE, confirmTokenParam, confirmWrite } from './_confirm.js';
|
|
3
|
+
import { buildQueryString, defineWrite, minifiedResult, registerWrite, resolveView, viewParam, } from '@chrischall/mcp-utils';
|
|
5
4
|
import { UNTRUSTED_DESCRIPTION_SUFFIX, swUntrustedResult } from './_untrusted.js';
|
|
5
|
+
import { assertNotDeleted, explainForbidden } from './_expense-state.js';
|
|
6
6
|
export function registerUtilityTools(server, client) {
|
|
7
7
|
server.registerTool('sw_get_notifications', {
|
|
8
8
|
description: `Get recent Splitwise activity notifications for the current user. ${UNTRUSTED_DESCRIPTION_SUFFIX}`,
|
|
@@ -45,54 +45,47 @@ export function registerUtilityTools(server, client) {
|
|
|
45
45
|
const data = await client.request('GET', `/get_comments${buildQueryString({ expense_id })}`);
|
|
46
46
|
return swUntrustedResult(viewGeneric(resolveView(view, SW_VIEWS), data));
|
|
47
47
|
});
|
|
48
|
-
server
|
|
49
|
-
|
|
50
|
-
|
|
48
|
+
registerWrite(server, defineWrite({
|
|
49
|
+
name: 'sw_create_comment',
|
|
50
|
+
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
51
|
inputSchema: z.object({
|
|
52
52
|
expense_id: z.number().describe('Expense ID to comment on'),
|
|
53
53
|
content: z.string().describe('Comment text'),
|
|
54
|
-
confirmToken: confirmTokenParam,
|
|
55
54
|
}),
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
description:
|
|
76
|
-
annotations: { readOnlyHint: false, destructiveHint: true, openWorldHint: true },
|
|
55
|
+
plan: async ({ expense_id, content }) => {
|
|
56
|
+
await assertNotDeleted(client, expense_id);
|
|
57
|
+
return {
|
|
58
|
+
gate: {
|
|
59
|
+
action: 'comment.create',
|
|
60
|
+
summary: `Comment on Splitwise expense ${expense_id} (visible to participants)`,
|
|
61
|
+
// One API key per client: no account switch inside a server to bind.
|
|
62
|
+
account: undefined,
|
|
63
|
+
target: expense_id,
|
|
64
|
+
request: { method: 'POST', path: '/create_comment', body: { expense_id, content } },
|
|
65
|
+
},
|
|
66
|
+
state: { expense_id, content },
|
|
67
|
+
};
|
|
68
|
+
},
|
|
69
|
+
execute: ({ expense_id, content }) => explainForbidden(client, expense_id, () => client.request('POST', '/create_comment', { expense_id, content })),
|
|
70
|
+
render: minifiedResult,
|
|
71
|
+
}));
|
|
72
|
+
registerWrite(server, defineWrite({
|
|
73
|
+
name: 'sw_delete_comment',
|
|
74
|
+
description: 'Delete a comment by id.',
|
|
77
75
|
inputSchema: z.object({
|
|
78
76
|
id: z.number().describe('Comment ID to delete'),
|
|
79
|
-
confirmToken: confirmTokenParam,
|
|
80
77
|
}),
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
return gate;
|
|
95
|
-
const data = await client.request('POST', `/delete_comment/${id}`);
|
|
96
|
-
return minifiedResult(data);
|
|
97
|
-
});
|
|
78
|
+
plan: ({ id }) => ({
|
|
79
|
+
gate: {
|
|
80
|
+
action: 'comment.delete',
|
|
81
|
+
summary: `Delete Splitwise comment ${id}`,
|
|
82
|
+
account: undefined,
|
|
83
|
+
target: id,
|
|
84
|
+
request: { method: 'POST', path: `/delete_comment/${id}` },
|
|
85
|
+
},
|
|
86
|
+
state: id,
|
|
87
|
+
}),
|
|
88
|
+
execute: (id) => client.request('POST', `/delete_comment/${id}`),
|
|
89
|
+
render: minifiedResult,
|
|
90
|
+
}));
|
|
98
91
|
}
|