splitwise-mcp 3.5.0 → 3.6.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/dist/bundle.js +10278 -9065
- package/dist/index.js +1 -1
- package/dist/tools/_brief.js +142 -0
- package/dist/tools/_receipt.js +16 -4
- package/dist/tools/batch.js +4 -3
- package/dist/tools/expenses.js +13 -10
- package/dist/tools/friends.js +15 -2
- package/dist/tools/groups.js +8 -0
- package/dist/tools/hints.js +40 -0
- package/dist/tools/receipts.js +2 -0
- package/dist/tools/uploads.js +7 -6
- package/dist/tools/user.js +4 -0
- package/dist/tools/utilities.js +19 -7
- package/package.json +3 -3
- package/server.json +2 -2
- package/skills/splitwise-mcp/SKILL.md +23 -1
- package/dist/tools/_blob.js +0 -40
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.
|
|
24
|
+
version: '3.6.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
|
+
}
|
package/dist/tools/_receipt.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { createHash } from 'crypto';
|
|
2
2
|
import { ApiError, readEnvVar } from '@chrischall/mcp-utils';
|
|
3
|
-
import {
|
|
3
|
+
import { blobStoreFromEnv } from '@chrischall/mcp-utils/export';
|
|
4
4
|
import { z } from 'zod';
|
|
5
5
|
/**
|
|
6
6
|
* Splitwise does not document an upload cap, so this is a conservative 10 MiB:
|
|
@@ -112,6 +112,18 @@ async function readSandboxed(requested) {
|
|
|
112
112
|
await handle.close();
|
|
113
113
|
}
|
|
114
114
|
}
|
|
115
|
+
/**
|
|
116
|
+
* The only keys `receipt.upload_id` may name: `up/<uuid>/<safe name>`, exactly
|
|
117
|
+
* what `sw_get_upload_url` mints. Anything else in this registration's space
|
|
118
|
+
* is not the caller's to read through a receipt.
|
|
119
|
+
*/
|
|
120
|
+
export const UPLOAD_ID_PATTERN = /^up\/[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}\/[A-Za-z0-9_-][A-Za-z0-9._-]{0,99}$/;
|
|
121
|
+
/** A filename made safe for a blob key segment: no separators, no dot-dot, no surprises. */
|
|
122
|
+
export function safeKeyName(filename) {
|
|
123
|
+
const base = filename.split(/[\\/]/).pop() ?? '';
|
|
124
|
+
const cleaned = base.replace(/[^A-Za-z0-9._-]+/g, '_').replace(/^[._]+/, '').slice(0, 100);
|
|
125
|
+
return cleaned || 'receipt';
|
|
126
|
+
}
|
|
115
127
|
/** How long the server's own read link for an upload lives — it is used at once. */
|
|
116
128
|
const UPLOAD_READ_TTL_MS = 5 * 60 * 1000;
|
|
117
129
|
/**
|
|
@@ -122,12 +134,12 @@ async function readUpload(uploadId, client) {
|
|
|
122
134
|
if (!UPLOAD_ID_PATTERN.test(uploadId)) {
|
|
123
135
|
throw new Error('receipt.upload_id is not one sw_get_upload_url issued (expected up/<uuid>/<filename>)');
|
|
124
136
|
}
|
|
125
|
-
const
|
|
126
|
-
if (!
|
|
137
|
+
const store = blobStoreFromEnv();
|
|
138
|
+
if (!store || !client) {
|
|
127
139
|
throw new Error('receipt.upload_id only works on the hosted server; pass receipt.path or receipt.base64 here.');
|
|
128
140
|
}
|
|
129
141
|
try {
|
|
130
|
-
const asset = await client.fetchAsset(
|
|
142
|
+
const asset = await client.fetchAsset(store.signGetUrl(uploadId, { ttlMs: UPLOAD_READ_TTL_MS }).url, {
|
|
131
143
|
maxBytes: MAX_RECEIPT_BYTES,
|
|
132
144
|
});
|
|
133
145
|
return Uint8Array.from(asset.bytes);
|
package/dist/tools/batch.js
CHANGED
|
@@ -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
|
}
|
package/dist/tools/expenses.js
CHANGED
|
@@ -6,6 +6,7 @@ import { buildReceiptForm, loadReceipt, receiptParam, receiptSchema, verifyRecei
|
|
|
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 = {};
|
|
@@ -83,6 +84,7 @@ export function registerExpenseTools(server, client) {
|
|
|
83
84
|
server.registerTool('sw_list_expenses', {
|
|
84
85
|
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
86
|
annotations: { readOnlyHint: true, openWorldHint: true },
|
|
87
|
+
_meta: moneyMeta('lookup', { aliases: ['expenses', 'shared expenses', 'what did we spend', 'bills'] }),
|
|
86
88
|
inputSchema: z.object({
|
|
87
89
|
view: z
|
|
88
90
|
.enum(EXPENSE_LIST_VIEWS)
|
|
@@ -189,6 +191,7 @@ export function registerExpenseTools(server, client) {
|
|
|
189
191
|
server.registerTool('sw_get_expense', {
|
|
190
192
|
description: `Get full details of a single Splitwise expense by id. ${UNTRUSTED_DESCRIPTION_SUFFIX}`,
|
|
191
193
|
annotations: { readOnlyHint: true, openWorldHint: true },
|
|
194
|
+
_meta: moneyMeta('lookup'),
|
|
192
195
|
inputSchema: z.object({
|
|
193
196
|
view: viewParam(SW_VIEWS, {
|
|
194
197
|
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 +202,7 @@ export function registerExpenseTools(server, client) {
|
|
|
199
202
|
const data = await client.request('GET', `/get_expense/${id}`);
|
|
200
203
|
return swUntrustedResult(viewExpense(resolveView(view, SW_VIEWS), data));
|
|
201
204
|
});
|
|
202
|
-
registerWrite(server, defineWrite({
|
|
205
|
+
withToolMeta(server, moneyMeta('change', { aliases: EXPENSE_WRITE_ALIASES }), () => registerWrite(server, defineWrite({
|
|
203
206
|
name: 'sw_create_expense',
|
|
204
207
|
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
208
|
inputSchema: z.object({
|
|
@@ -300,8 +303,8 @@ export function registerExpenseTools(server, client) {
|
|
|
300
303
|
execute: async ({ body, receipt }) => withPostState(await sendExpenseWrite(client, '/create_expense', body, receipt)),
|
|
301
304
|
render: minifiedResult,
|
|
302
305
|
produces: { expense_id: (result) => createdExpenseId(result) },
|
|
303
|
-
}));
|
|
304
|
-
registerWrite(server, defineWrite({
|
|
306
|
+
})));
|
|
307
|
+
withToolMeta(server, moneyMeta('change'), () => registerWrite(server, defineWrite({
|
|
305
308
|
name: 'sw_update_expense',
|
|
306
309
|
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
310
|
inputSchema: z.object({
|
|
@@ -337,8 +340,8 @@ export function registerExpenseTools(server, client) {
|
|
|
337
340
|
},
|
|
338
341
|
execute: async ({ expense_id, body, receipt }) => withPostState(await explainForbidden(client, expense_id, () => sendExpenseWrite(client, `/update_expense/${expense_id}`, body, receipt, expense_id))),
|
|
339
342
|
render: minifiedResult,
|
|
340
|
-
}));
|
|
341
|
-
registerWrite(server, defineWrite({
|
|
343
|
+
})));
|
|
344
|
+
withToolMeta(server, moneyMeta('change'), () => registerWrite(server, defineWrite({
|
|
342
345
|
name: 'sw_attach_receipt',
|
|
343
346
|
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.',
|
|
344
347
|
annotations: { readOnlyHint: false, destructiveHint: true, idempotentHint: true, openWorldHint: true },
|
|
@@ -363,8 +366,8 @@ export function registerExpenseTools(server, client) {
|
|
|
363
366
|
},
|
|
364
367
|
execute: async ({ expense_id, receipt }) => withPostState(await explainForbidden(client, expense_id, () => sendExpenseWrite(client, `/update_expense/${expense_id}`, {}, receipt, expense_id))),
|
|
365
368
|
render: minifiedResult,
|
|
366
|
-
}));
|
|
367
|
-
registerWrite(server, defineWrite({
|
|
369
|
+
})));
|
|
370
|
+
withToolMeta(server, moneyMeta('change'), () => registerWrite(server, defineWrite({
|
|
368
371
|
name: 'sw_delete_expense',
|
|
369
372
|
description: 'Soft-delete a Splitwise expense by id. Returns {success: true} on success. Use sw_undelete_expense to restore.',
|
|
370
373
|
inputSchema: z.object({
|
|
@@ -382,8 +385,8 @@ export function registerExpenseTools(server, client) {
|
|
|
382
385
|
}),
|
|
383
386
|
execute: (id) => client.request('POST', `/delete_expense/${id}`),
|
|
384
387
|
render: minifiedResult,
|
|
385
|
-
}));
|
|
386
|
-
registerWrite(server, defineWrite({
|
|
388
|
+
})));
|
|
389
|
+
withToolMeta(server, moneyMeta('change'), () => registerWrite(server, defineWrite({
|
|
387
390
|
name: 'sw_undelete_expense',
|
|
388
391
|
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
392
|
// Destructive despite the delete tool being its record-level inverse:
|
|
@@ -406,5 +409,5 @@ export function registerExpenseTools(server, client) {
|
|
|
406
409
|
}),
|
|
407
410
|
execute: async (id) => verifyRestored(client, id, await client.request('POST', `/undelete_expense/${id}`)),
|
|
408
411
|
render: minifiedResult,
|
|
409
|
-
}));
|
|
412
|
+
})));
|
|
410
413
|
}
|
package/dist/tools/friends.js
CHANGED
|
@@ -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
|
|
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:
|
|
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,
|
package/dist/tools/groups.js
CHANGED
|
@@ -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'];
|
package/dist/tools/receipts.js
CHANGED
|
@@ -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
|
package/dist/tools/uploads.js
CHANGED
|
@@ -1,8 +1,9 @@
|
|
|
1
1
|
import { randomUUID } from 'crypto';
|
|
2
2
|
import { z } from 'zod';
|
|
3
3
|
import { minifiedResult } from '@chrischall/mcp-utils';
|
|
4
|
-
import {
|
|
5
|
-
import { canonicalReceiptMime, MAX_RECEIPT_BYTES } from './_receipt.js';
|
|
4
|
+
import { blobStoreFromEnv } from '@chrischall/mcp-utils/export';
|
|
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
|
/**
|
|
@@ -17,6 +18,7 @@ export function registerUploadTools(server, _client) {
|
|
|
17
18
|
server.registerTool('sw_get_upload_url', {
|
|
18
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. 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
|
|
@@ -24,14 +26,13 @@ export function registerUploadTools(server, _client) {
|
|
|
24
26
|
.describe('The file\'s type: image/png, image/jpeg, image/gif or application/pdf. The upload must send exactly this Content-Type.'),
|
|
25
27
|
}),
|
|
26
28
|
}, async ({ filename, content_type }) => {
|
|
27
|
-
const
|
|
28
|
-
if (!
|
|
29
|
+
const store = blobStoreFromEnv();
|
|
30
|
+
if (!store) {
|
|
29
31
|
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
32
|
}
|
|
31
33
|
const contentType = canonicalReceiptMime(content_type);
|
|
32
34
|
const key = `up/${randomUUID()}/${safeKeyName(filename)}`;
|
|
33
|
-
const exp =
|
|
34
|
-
const uploadUrl = signedPutUrl(cfg, key, contentType, exp);
|
|
35
|
+
const { url: uploadUrl, expiresAtMs: exp } = store.signPutUrl(key, contentType, { ttlMs: UPLOAD_URL_TTL_MS });
|
|
35
36
|
return minifiedResult({
|
|
36
37
|
upload_id: key,
|
|
37
38
|
upload_url: uploadUrl,
|
package/dist/tools/user.js
CHANGED
|
@@ -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
|
package/dist/tools/utilities.js
CHANGED
|
@@ -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:
|
|
12
|
-
|
|
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
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "splitwise-mcp",
|
|
3
|
-
"version": "3.
|
|
3
|
+
"version": "3.6.0",
|
|
4
4
|
"mcpName": "io.github.chrischall/splitwise-mcp",
|
|
5
5
|
"description": "Splitwise MCP server for Claude — developed and maintained by AI (Claude Code)",
|
|
6
6
|
"author": "Claude Code (AI) <https://www.anthropic.com/claude>",
|
|
@@ -45,10 +45,10 @@
|
|
|
45
45
|
"typecheck": "tsc -p tsconfig.json --noEmit"
|
|
46
46
|
},
|
|
47
47
|
"dependencies": {
|
|
48
|
-
"@chrischall/mcp-utils": "^3.
|
|
48
|
+
"@chrischall/mcp-utils": "^3.3.0",
|
|
49
49
|
"@modelcontextprotocol/server": "^2.3.0",
|
|
50
50
|
"dotenv": "^18.0.2",
|
|
51
|
-
"unpdf": "^
|
|
51
|
+
"unpdf": "^2.0.0",
|
|
52
52
|
"zod": "^4.6.5"
|
|
53
53
|
},
|
|
54
54
|
"devDependencies": {
|