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