splitwise-mcp 3.4.1 → 3.5.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.js CHANGED
@@ -7,6 +7,8 @@ import { registerGroupTools } from './tools/groups.js';
7
7
  import { registerFriendTools } from './tools/friends.js';
8
8
  import { registerExpenseTools } from './tools/expenses.js';
9
9
  import { registerReceiptTools } from './tools/receipts.js';
10
+ import { registerUploadTools } from './tools/uploads.js';
11
+ import { registerBatchTools } from './tools/batch.js';
10
12
  import { registerUtilityTools } from './tools/utilities.js';
11
13
  // runMcp builds the McpServer, applies the registrars (threading `client`
12
14
  // through as deps), prints the banner to stderr, wires graceful shutdown, and
@@ -19,7 +21,7 @@ import { registerUtilityTools } from './tools/utilities.js';
19
21
  // client into the same registrars instead of this singleton.
20
22
  await runMcp({
21
23
  name: 'splitwise-mcp',
22
- version: '3.4.1', // x-release-please-version
24
+ version: '3.5.1', // x-release-please-version
23
25
  deps: client,
24
26
  banner: '[splitwise-mcp] This project was developed and is maintained by AI (Claude Sonnet 4.6). Use at your own discretion.',
25
27
  tools: [
@@ -29,6 +31,9 @@ await runMcp({
29
31
  registerFriendTools,
30
32
  registerExpenseTools,
31
33
  registerReceiptTools,
34
+ registerUploadTools,
32
35
  registerUtilityTools,
36
+ // Last: sw_batch lists the writes registered before it.
37
+ registerBatchTools,
33
38
  ],
34
39
  });
package/dist/project.js CHANGED
@@ -29,7 +29,7 @@ function asList(v) {
29
29
  return Array.isArray(v) ? v : [];
30
30
  }
31
31
  /** "Alison Hall" from the first/last pair Splitwise returns on every user shape. */
32
- function nameOf(user) {
32
+ export function nameOf(user) {
33
33
  if (!user)
34
34
  return undefined;
35
35
  const parts = [user.first_name, user.last_name].filter((p) => typeof p === 'string' && p !== '');
@@ -105,8 +105,6 @@ export function compactGroup(raw) {
105
105
  */
106
106
  export function compactExpense(raw) {
107
107
  const e = asDict(raw) ?? {};
108
- const receipt = asDict(e.receipt);
109
- const original = receipt?.original;
110
108
  return pruneUndefined({
111
109
  id: e.id,
112
110
  group_id: e.group_id,
@@ -119,9 +117,14 @@ export function compactExpense(raw) {
119
117
  payment: e.payment === true ? true : undefined,
120
118
  created_by: nameOf(asDict(e.created_by)),
121
119
  updated_at: e.updated_at,
120
+ updated_by: nameOf(asDict(e.updated_by)),
122
121
  // Kept only when set. A deleted expense still comes back from the list
123
122
  // endpoint, and a caller that cannot see `deleted_at` will count it.
124
123
  deleted_at: e.deleted_at ?? undefined,
124
+ // Who deleted it is the first question after "is it deleted" — in a shared
125
+ // group it is often not the caller. Without it here, every reconciliation
126
+ // needed a second `view: "full"` read.
127
+ deleted_by: e.deleted_at ? nameOf(asDict(e.deleted_by)) : undefined,
125
128
  comments_count: e.comments_count,
126
129
  repayments: e.repayments,
127
130
  users: asList(e.users).map((entry) => {
@@ -135,9 +138,38 @@ export function compactExpense(raw) {
135
138
  };
136
139
  }),
137
140
  // A receipt is evidence; `sw_get_receipt` is how a caller fetches it.
138
- has_receipt: typeof original === 'string' ? true : undefined,
141
+ // Always stated: "no receipt" is a fact a reconciliation acts on.
142
+ has_receipt: hasReceipt(e),
139
143
  });
140
144
  }
145
+ function hasReceipt(e) {
146
+ return typeof asDict(e.receipt)?.original === 'string';
147
+ }
148
+ /**
149
+ * The `rows` rung of `sw_list_expenses`: one flat line per expense, for
150
+ * reconciling a long history. A year of one group came back as ~110K
151
+ * characters on `compact` — the share breakdown and repayments are most of
152
+ * that, and a "which of these are duplicates / who deleted what" pass needs
153
+ * none of it. `sw_get_expense` has the rest.
154
+ */
155
+ export function rowExpense(raw) {
156
+ const e = asDict(raw) ?? {};
157
+ return pruneUndefined({
158
+ id: e.id,
159
+ date: e.date,
160
+ description: e.description,
161
+ cost: e.cost,
162
+ currency_code: e.currency_code,
163
+ category: asDict(e.category)?.name,
164
+ created_by: nameOf(asDict(e.created_by)),
165
+ deleted_at: e.deleted_at ?? undefined,
166
+ deleted_by: e.deleted_at ? nameOf(asDict(e.deleted_by)) : undefined,
167
+ comments_count: e.comments_count,
168
+ has_receipt: hasReceipt(e),
169
+ });
170
+ }
171
+ /** `sw_list_expenses` alone also offers `rows`. */
172
+ export const EXPENSE_LIST_VIEWS = [...SW_VIEWS, 'rows'];
141
173
  /**
142
174
  * Project a payload's `<key>` array, or hand the whole thing back.
143
175
  *
@@ -19,7 +19,7 @@ export function confirmWrite(ctx, gate) {
19
19
  path: gate.path,
20
20
  ...(gate.body !== undefined ? { willSend: gate.body } : {}),
21
21
  };
22
- const preview = { action: gate.summary, ...request };
22
+ const preview = { ...gate.extra, action: gate.summary, ...request };
23
23
  return requireConfirmationWithFallback(ctx, confirmationFromEnv({
24
24
  action: gate.action,
25
25
  message: `Review and confirm: ${gate.summary}`,
@@ -0,0 +1,174 @@
1
+ import { buildQueryString } from '@chrischall/mcp-utils';
2
+ import { equalSplit, resolveMember } from './_split.js';
3
+ const asDict = (v) => v !== null && typeof v === 'object' && !Array.isArray(v) ? v : undefined;
4
+ const asList = (v) => (Array.isArray(v) ? v : []);
5
+ /** Categories barely change; one fetch per client per hour. Keyed by client: a hosted server has one per user. */
6
+ const CATEGORY_TTL_MS = 60 * 60 * 1000;
7
+ let categoryCache = new WeakMap();
8
+ /** Forget every cached category list (tests). */
9
+ export function clearCategoryCache() {
10
+ categoryCache = new WeakMap();
11
+ }
12
+ async function loadCategories(client) {
13
+ const cached = categoryCache.get(client);
14
+ if (cached && Date.now() - cached.at < CATEGORY_TTL_MS) {
15
+ return cached.list;
16
+ }
17
+ const list = client.request('GET', '/get_categories').then((data) => {
18
+ const subs = [];
19
+ const parents = new Map();
20
+ for (const raw of asList(asDict(data)?.categories)) {
21
+ const parent = asDict(raw);
22
+ if (!parent || typeof parent.name !== 'string')
23
+ continue;
24
+ const children = asList(parent.subcategories)
25
+ .map(asDict)
26
+ .filter((c) => typeof c?.id === 'number' && typeof c.name === 'string')
27
+ .map((c) => ({ id: c.id, name: c.name, parent: parent.name }));
28
+ subs.push(...children);
29
+ parents.set(parent.name.toLowerCase(), children);
30
+ }
31
+ return { subs, parents };
32
+ });
33
+ // A failed fetch must not be cached as the answer for the next hour.
34
+ list.catch(() => categoryCache.delete(client));
35
+ categoryCache.set(client, { at: Date.now(), list });
36
+ return list;
37
+ }
38
+ /**
39
+ * A category name → its id. Matches subcategory names case-insensitively
40
+ * (an expense always takes a subcategory id; "General" is the one under
41
+ * "Uncategorized"). A parent name is refused with its subcategories listed,
42
+ * and an unknown name with the closest names.
43
+ */
44
+ export async function resolveCategory(client, name) {
45
+ const { subs, parents } = await loadCategories(client);
46
+ const want = name.trim().toLowerCase();
47
+ const exact = subs.filter((c) => c.name.toLowerCase() === want);
48
+ if (exact.length === 1)
49
+ return exact[0];
50
+ if (exact.length > 1) {
51
+ throw new Error(`category "${name}" is ambiguous: ${exact.map((c) => `${c.parent} › ${c.name} (${c.id})`).join(', ')}. Pass category_id.`);
52
+ }
53
+ const children = parents.get(want);
54
+ if (children) {
55
+ throw new Error(`category "${name}" is a parent category; pick one of its subcategories: ${children.map((c) => c.name).join(', ')}`);
56
+ }
57
+ const words = want.split(/\W+/).filter(Boolean);
58
+ const close = subs
59
+ .filter((c) => {
60
+ const n = c.name.toLowerCase();
61
+ return n.includes(want) || want.includes(n) || words.some((w) => w.length > 2 && n.includes(w));
62
+ })
63
+ .slice(0, 8)
64
+ .map((c) => c.name);
65
+ throw new Error(`No Splitwise category named "${name}".${close.length ? ` Did you mean: ${close.join(', ')}?` : ''} sw_get_categories lists them all.`);
66
+ }
67
+ // ---------------------------------------------------------------- similarity
68
+ const tokens = (s) => new Set(s.toLowerCase().match(/[a-z0-9]+/g) ?? []);
69
+ /** Same thing, written slightly differently: one contains the other, or at least half the words overlap. */
70
+ export function similarDescriptions(a, b) {
71
+ const x = a.trim().toLowerCase();
72
+ const y = b.trim().toLowerCase();
73
+ if (!x || !y)
74
+ return false;
75
+ if (x.includes(y) || y.includes(x))
76
+ return true;
77
+ const tx = tokens(x);
78
+ const ty = tokens(y);
79
+ const shared = [...tx].filter((t) => ty.has(t)).length;
80
+ const union = new Set([...tx, ...ty]).size;
81
+ return union > 0 && shared / union >= 0.5;
82
+ }
83
+ // ---------------------------------------------------------------- inference
84
+ /**
85
+ * The most common category among the group's recent expenses with a similar
86
+ * description — "General" excluded, since it is what an unset category
87
+ * becomes. Advisory: no match, or a failed read, infers nothing.
88
+ */
89
+ export async function inferCategory(client, groupId, description) {
90
+ let recent;
91
+ try {
92
+ const data = await client.request('GET', `/get_expenses${buildQueryString({ group_id: groupId, limit: 200 })}`);
93
+ recent = asList(asDict(data)?.expenses);
94
+ }
95
+ catch {
96
+ return undefined;
97
+ }
98
+ const counts = new Map();
99
+ for (const raw of recent) {
100
+ const e = asDict(raw);
101
+ const category = asDict(e?.category);
102
+ if (!e || typeof e.description !== 'string' || typeof category?.id !== 'number')
103
+ continue;
104
+ if (String(category.name).toLowerCase() === 'general')
105
+ continue;
106
+ if (!similarDescriptions(e.description, description))
107
+ continue;
108
+ const entry = counts.get(category.id) ?? { name: String(category.name), n: 0 };
109
+ entry.n++;
110
+ counts.set(category.id, entry);
111
+ }
112
+ let best;
113
+ // Map keeps first-seen order and the list is newest first, so a tie goes to the most recent use.
114
+ for (const [id, { name, n }] of counts) {
115
+ if (!best || n > best.similar_expenses)
116
+ best = { id, name, similar_expenses: n };
117
+ }
118
+ return best;
119
+ }
120
+ // ---------------------------------------------------------------- duplicates
121
+ const DAY_MS = 24 * 60 * 60 * 1000;
122
+ /**
123
+ * Expenses in the group — live or deleted — with the same cost, a similar
124
+ * description and a date within a day either side. A warning for the preview,
125
+ * never a refusal; a failed read finds nothing rather than blocking the create.
126
+ */
127
+ export async function findDuplicates(client, input) {
128
+ const when = input.date ? new Date(input.date) : new Date();
129
+ if (Number.isNaN(when.getTime()))
130
+ return [];
131
+ const qs = buildQueryString({
132
+ group_id: input.group_id,
133
+ dated_after: new Date(when.getTime() - DAY_MS).toISOString(),
134
+ dated_before: new Date(when.getTime() + DAY_MS).toISOString(),
135
+ limit: 200,
136
+ });
137
+ let candidates;
138
+ try {
139
+ candidates = asList(asDict(await client.request('GET', `/get_expenses${qs}`))?.expenses);
140
+ }
141
+ catch {
142
+ return [];
143
+ }
144
+ const cost = Number(input.cost);
145
+ return candidates
146
+ .map(asDict)
147
+ .filter((e) => !!e && Number(e.cost) === cost && typeof e.description === 'string' && similarDescriptions(e.description, input.description))
148
+ .map((e) => ({ id: e.id, date: e.date, description: e.description, cost: e.cost, deleted: Boolean(e.deleted_at) }));
149
+ }
150
+ // ---------------------------------------------------------------- equal split
151
+ /**
152
+ * The users[] for `split: "equal"`: `paid_by` paid the whole cost, everyone in
153
+ * `split_among` (default: every group member) owes an equal part. Names are
154
+ * resolved against the group's members, which are read only when needed.
155
+ */
156
+ export async function resolveEqualSplit(client, input) {
157
+ const needMembers = input.split_among === undefined || typeof input.paid_by === 'string' || input.split_among.some((m) => typeof m === 'string');
158
+ let members;
159
+ if (needMembers) {
160
+ if (!(input.group_id > 0)) {
161
+ throw new Error('split:"equal" outside a group needs paid_by and split_among as user ids (there are no group members to resolve names against)');
162
+ }
163
+ const data = await client.request('GET', `/get_group/${input.group_id}`);
164
+ members = asList(asDict(asDict(data)?.group)?.members)
165
+ .map(asDict)
166
+ .filter((m) => typeof m?.id === 'number');
167
+ if (members.length === 0)
168
+ throw new Error(`Could not read the members of group ${input.group_id}`);
169
+ }
170
+ const resolve = (who, field) => members ? resolveMember(who, members, field) : who;
171
+ const payer = resolve(input.paid_by, 'paid_by');
172
+ const among = input.split_among ? input.split_among.map((m) => resolve(m, 'split_among')) : members.map((m) => m.id);
173
+ return equalSplit(input.cost, payer, among);
174
+ }
@@ -0,0 +1,86 @@
1
+ import { ApiError } from '@chrischall/mcp-utils';
2
+ import { nameOf } from '../project.js';
3
+ const asDict = (v) => v !== null && typeof v === 'object' && !Array.isArray(v) ? v : undefined;
4
+ /** The expense from a `GET /get_expense/{id}` response, or undefined when it has none. */
5
+ export async function readExpense(client, id) {
6
+ const data = await client.request('GET', `/get_expense/${id}`);
7
+ return asDict(asDict(data)?.expense);
8
+ }
9
+ /**
10
+ * The few fields that say what a write actually left behind. A shared group
11
+ * has another writer: a restore can be undone by the other member within
12
+ * seconds, so "the request succeeded" and "the expense is live" are different
13
+ * facts, and only this one answers the second.
14
+ */
15
+ export function postState(expense) {
16
+ return {
17
+ id: expense.id,
18
+ deleted_at: expense.deleted_at ?? null,
19
+ deleted_by: expense.deleted_at ? (nameOf(asDict(expense.deleted_by)) ?? null) : null,
20
+ updated_at: expense.updated_at ?? null,
21
+ updated_by: nameOf(asDict(expense.updated_by)) ?? null,
22
+ };
23
+ }
24
+ /** "by Alison Hall at 2026-10-02T09:15:00Z", from whatever of the two Splitwise reported. */
25
+ function deletedBy(expense) {
26
+ const who = nameOf(asDict(expense.deleted_by)) ?? 'unknown';
27
+ return `by ${who} at ${String(expense.deleted_at)}`;
28
+ }
29
+ export function deletedError(id, expense) {
30
+ return new Error(`Splitwise expense ${id} is deleted (${deletedBy(expense)}); restore it with sw_undelete_expense first. ` +
31
+ 'Splitwise answers a write to a deleted expense with a bare 403 "you do not have permission".');
32
+ }
33
+ /**
34
+ * Refuse a write to a deleted expense up front, with who deleted it, instead
35
+ * of letting Splitwise answer a bare 403. Runs before the confirm gate, so the
36
+ * caller hears it on the first call. The read is advisory: when it fails or
37
+ * finds nothing, the write goes ahead and reports its own error, so the check
38
+ * can never block a write that would have worked.
39
+ */
40
+ export async function assertNotDeleted(client, id) {
41
+ const expense = await readExpense(client, id).catch(() => undefined);
42
+ if (expense?.deleted_at)
43
+ throw deletedError(id, expense);
44
+ }
45
+ /**
46
+ * Run a write to an existing expense; if Splitwise answers 403, re-read the
47
+ * expense, and when it turns out deleted (between the pre-check and the write)
48
+ * say so rather than "you do not have permission". Any other failure, or a 403
49
+ * on a live expense, is passed through unchanged.
50
+ */
51
+ export async function explainForbidden(client, id, write) {
52
+ try {
53
+ return await write();
54
+ }
55
+ catch (err) {
56
+ if (!(err instanceof ApiError) || err.status !== 403)
57
+ throw err;
58
+ const expense = await readExpense(client, id).catch(() => undefined);
59
+ if (expense?.deleted_at)
60
+ throw deletedError(id, expense);
61
+ throw err;
62
+ }
63
+ }
64
+ /** The response of a create/update with `post_state` lifted to the top, from the expense it returned. */
65
+ export function withPostState(data) {
66
+ const box = asDict(data);
67
+ const expense = asDict(box?.expenses?.[0]);
68
+ if (!box || !expense)
69
+ return data;
70
+ return { post_state: postState(expense), ...box };
71
+ }
72
+ /**
73
+ * After a restore, read the expense back. Still deleted is an error that names
74
+ * who deleted it and when — never `{success: true}`.
75
+ */
76
+ export async function verifyRestored(client, id, data) {
77
+ const expense = await readExpense(client, id);
78
+ if (!expense) {
79
+ return { ...(asDict(data) ?? {}), post_state: null, note: `Could not re-read expense ${id} to confirm the restore` };
80
+ }
81
+ if (expense.deleted_at) {
82
+ throw new Error(`Splitwise accepted the restore of expense ${id}, but it is still deleted (${deletedBy(expense)}). ` +
83
+ 'Another group member may have deleted it again — read its comments (sw_get_comments) before restoring it a second time.');
84
+ }
85
+ return { ...(asDict(data) ?? {}), post_state: postState(expense) };
86
+ }
@@ -0,0 +1,166 @@
1
+ import { createHash } from 'crypto';
2
+ import { buildQueryString } from '@chrischall/mcp-utils';
3
+ import { compactExpense, nameOf, rowExpense } from '../project.js';
4
+ const asDict = (v) => v !== null && typeof v === 'object' && !Array.isArray(v) ? v : undefined;
5
+ /** Splitwise's own page cap; the scan reads this many per request. */
6
+ export const UPSTREAM_PAGE = 200;
7
+ /** Records one call may read before it hands back a cursor instead. */
8
+ export const SCAN_BUDGET = 1000;
9
+ /** Matched rows that may have their comments inlined in one call. */
10
+ export const MAX_COMMENT_ROWS = 25;
11
+ export function hasLocalFilters(f) {
12
+ return (f.description_contains !== undefined ||
13
+ (f.include_deleted !== undefined && f.include_deleted !== true) ||
14
+ f.created_by !== undefined ||
15
+ f.min_cost !== undefined ||
16
+ f.max_cost !== undefined ||
17
+ f.category !== undefined);
18
+ }
19
+ export function matches(raw, f) {
20
+ const e = asDict(raw) ?? {};
21
+ const deleted = Boolean(e.deleted_at);
22
+ if (f.include_deleted === false && deleted)
23
+ return false;
24
+ if (f.include_deleted === 'only' && !deleted)
25
+ return false;
26
+ if (f.description_contains !== undefined) {
27
+ const description = typeof e.description === 'string' ? e.description.toLowerCase() : '';
28
+ if (!description.includes(f.description_contains.toLowerCase()))
29
+ return false;
30
+ }
31
+ if (f.created_by !== undefined) {
32
+ const creator = asDict(e.created_by);
33
+ if (typeof f.created_by === 'number') {
34
+ if (creator?.id !== f.created_by)
35
+ return false;
36
+ }
37
+ else {
38
+ const want = f.created_by.trim().toLowerCase();
39
+ const name = (nameOf(creator) ?? '').toLowerCase();
40
+ const email = typeof creator?.email === 'string' ? creator.email.toLowerCase() : '';
41
+ if (!name.includes(want) && email !== want)
42
+ return false;
43
+ }
44
+ }
45
+ const cost = Number(e.cost);
46
+ if (f.min_cost !== undefined && !(cost >= f.min_cost))
47
+ return false;
48
+ if (f.max_cost !== undefined && !(cost <= f.max_cost))
49
+ return false;
50
+ if (f.category !== undefined) {
51
+ const category = asDict(e.category);
52
+ if (typeof f.category === 'number') {
53
+ if (category?.id !== f.category)
54
+ return false;
55
+ }
56
+ else if (String(category?.name ?? '').toLowerCase() !== f.category.trim().toLowerCase()) {
57
+ return false;
58
+ }
59
+ }
60
+ return true;
61
+ }
62
+ /**
63
+ * An opaque resume point: the upstream offset to continue from, plus a hash
64
+ * of the query it belongs to, so a cursor from one query is refused by
65
+ * another instead of silently skipping rows of it.
66
+ */
67
+ function queryHash(q, f) {
68
+ return createHash('sha256').update(JSON.stringify([q, f])).digest('base64url').slice(0, 16);
69
+ }
70
+ export function encodeCursor(offset, q, f) {
71
+ return Buffer.from(JSON.stringify({ o: offset, q: queryHash(q, f) })).toString('base64url');
72
+ }
73
+ export function decodeCursor(cursor, q, f) {
74
+ let parsed;
75
+ try {
76
+ parsed = JSON.parse(Buffer.from(cursor, 'base64url').toString('utf8'));
77
+ }
78
+ catch {
79
+ throw new Error('cursor is not one this tool issued');
80
+ }
81
+ if (typeof parsed.o !== 'number' || !Number.isInteger(parsed.o) || parsed.o < 0) {
82
+ throw new Error('cursor is not one this tool issued');
83
+ }
84
+ if (parsed.q !== queryHash(q, f)) {
85
+ throw new Error('cursor belongs to a different query — pass the same filters it was issued with, or drop it to start over');
86
+ }
87
+ return parsed.o;
88
+ }
89
+ /**
90
+ * Page through Splitwise 200 at a time from `start`, keeping the records that
91
+ * pass `filters`, until `want` have matched, the history ends, or
92
+ * {@link SCAN_BUDGET} records have been read. Resumes exactly after the last
93
+ * record examined, so paging never skips or repeats a match.
94
+ */
95
+ export async function scanExpenses(client, q, filters, start, want) {
96
+ const found = [];
97
+ let offset = start;
98
+ let scanned = 0;
99
+ while (scanned < SCAN_BUDGET) {
100
+ const path = `/get_expenses${buildQueryString({ ...q, limit: UPSTREAM_PAGE, offset })}`;
101
+ const data = await client.request('GET', path);
102
+ const page = asDict(data)?.expenses;
103
+ if (!Array.isArray(page))
104
+ throw new Error('Splitwise returned no expenses[] array');
105
+ for (let i = 0; i < page.length; i++) {
106
+ scanned++;
107
+ if (matches(page[i], filters))
108
+ found.push(page[i]);
109
+ if (found.length === want) {
110
+ // More may follow even when this was the page's last record — unless
111
+ // the page was short, which means the history ended here.
112
+ const exhausted = i === page.length - 1 && page.length < UPSTREAM_PAGE;
113
+ return { expenses: found, scanned, ...(exhausted ? {} : { nextOffset: offset + i + 1 }) };
114
+ }
115
+ }
116
+ offset += page.length;
117
+ if (page.length < UPSTREAM_PAGE)
118
+ return { expenses: found, scanned };
119
+ }
120
+ return { expenses: found, scanned, nextOffset: offset };
121
+ }
122
+ /** One comment, as a reconciliation reads it: who said what, and when. */
123
+ function compactComment(raw) {
124
+ const c = asDict(raw) ?? {};
125
+ const out = {
126
+ id: c.id,
127
+ author: nameOf(asDict(c.user)),
128
+ content: c.content,
129
+ comment_type: c.comment_type,
130
+ created_at: c.created_at,
131
+ };
132
+ if (c.deleted_at)
133
+ out.deleted_at = c.deleted_at;
134
+ return Object.fromEntries(Object.entries(out).filter(([, v]) => v !== undefined));
135
+ }
136
+ /**
137
+ * Inline each matched row's comments — the reason an entry was deleted is
138
+ * usually written there. Only rows that HAVE comments are fetched, and only the
139
+ * first {@link MAX_COMMENT_ROWS} of them, one request each.
140
+ */
141
+ export async function attachComments(client, raws, rows) {
142
+ let fetched = 0;
143
+ let truncated = false;
144
+ for (let i = 0; i < raws.length; i++) {
145
+ const e = asDict(raws[i]);
146
+ if (!e || !(Number(e.comments_count) > 0))
147
+ continue;
148
+ if (fetched === MAX_COMMENT_ROWS) {
149
+ truncated = true;
150
+ break;
151
+ }
152
+ fetched++;
153
+ const data = await client.request('GET', `/get_comments${buildQueryString({ expense_id: e.id })}`);
154
+ const comments = asDict(data)?.comments;
155
+ rows[i].comments = Array.isArray(comments) ? comments.map(compactComment) : [];
156
+ }
157
+ return { truncated };
158
+ }
159
+ /** One record on the requested rung; `full` is Splitwise's record untouched. */
160
+ export function projectForList(view, raw) {
161
+ if (view === 'rows')
162
+ return rowExpense(raw);
163
+ if (view === 'compact')
164
+ return compactExpense(raw);
165
+ return (asDict(raw) ?? {});
166
+ }
@@ -1,5 +1,6 @@
1
1
  import { createHash } from 'crypto';
2
- import { readEnvVar } from '@chrischall/mcp-utils';
2
+ import { ApiError, readEnvVar } from '@chrischall/mcp-utils';
3
+ import { blobStoreFromEnv } from '@chrischall/mcp-utils/export';
3
4
  import { z } from 'zod';
4
5
  /**
5
6
  * Splitwise does not document an upload cap, so this is a conservative 10 MiB:
@@ -22,12 +23,17 @@ export const receiptSchema = z
22
23
  base64: z
23
24
  .string()
24
25
  .min(1)
25
- .describe('The file bytes, base64-encoded (a data: URL prefix is accepted and stripped). The normal way to pass a receipt.')
26
+ .describe('The file bytes, base64-encoded (a data: URL prefix is accepted and stripped). Costs context for every byte — on the hosted server prefer upload_id.')
26
27
  .optional(),
27
28
  path: z
28
29
  .string()
29
30
  .min(1)
30
- .describe('A file inside SPLITWISE_INPUT_DIR on the machine running this server (relative to it, or absolute). Only works when that variable is set — never on a hosted server; use base64 there.')
31
+ .describe('A file inside SPLITWISE_INPUT_DIR on the machine running this server (relative to it, or absolute). Only works when that variable is set — never on a hosted server; use upload_id (or base64) there.')
32
+ .optional(),
33
+ upload_id: z
34
+ .string()
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.')
31
37
  .optional(),
32
38
  filename: z.string().min(1).max(200).describe('Name to store the file under, e.g. "receipt.pdf".'),
33
39
  mime_type: z
@@ -35,7 +41,7 @@ export const receiptSchema = z
35
41
  .describe('Optional. If given, it must match the type detected from the file contents.')
36
42
  .optional(),
37
43
  })
38
- .describe(`Receipt to attach: PNG, JPEG, GIF or PDF, up to ${LIMIT_LABEL}. Give exactly one of base64 or path. The type is detected from the file contents. Replaces any receipt already on the expense.`);
44
+ .describe(`Receipt to attach: PNG, JPEG, GIF or PDF, up to ${LIMIT_LABEL}. Give exactly one of upload_id, path or base64. The type is detected from the file contents. Replaces any receipt already on the expense.`);
39
45
  export const receiptParam = receiptSchema.optional();
40
46
  const tooBig = (size) => new Error(`Receipt is ${size} bytes, over the ${LIMIT_LABEL} limit (${MAX_RECEIPT_BYTES} bytes)`);
41
47
  function sniff(bytes) {
@@ -106,17 +112,71 @@ async function readSandboxed(requested) {
106
112
  await handle.close();
107
113
  }
108
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
+ }
127
+ /** How long the server's own read link for an upload lives — it is used at once. */
128
+ const UPLOAD_READ_TTL_MS = 5 * 60 * 1000;
129
+ /**
130
+ * Read a file the caller PUT to a `sw_get_upload_url` link, through a signed
131
+ * GET on the same blob store. Only `up/<uuid>/<name>` keys are accepted.
132
+ */
133
+ async function readUpload(uploadId, client) {
134
+ if (!UPLOAD_ID_PATTERN.test(uploadId)) {
135
+ throw new Error('receipt.upload_id is not one sw_get_upload_url issued (expected up/<uuid>/<filename>)');
136
+ }
137
+ const store = blobStoreFromEnv();
138
+ if (!store || !client) {
139
+ throw new Error('receipt.upload_id only works on the hosted server; pass receipt.path or receipt.base64 here.');
140
+ }
141
+ try {
142
+ const asset = await client.fetchAsset(store.signGetUrl(uploadId, { ttlMs: UPLOAD_READ_TTL_MS }).url, {
143
+ maxBytes: MAX_RECEIPT_BYTES,
144
+ });
145
+ return Uint8Array.from(asset.bytes);
146
+ }
147
+ catch (err) {
148
+ if (err instanceof ApiError && (err.status === 404 || err.status === 403)) {
149
+ throw new Error(`No upload found for receipt.upload_id ${uploadId} — the PUT may not have run, failed, or the upload expired. Get a fresh link with sw_get_upload_url and upload the file again.`);
150
+ }
151
+ if (err.kind === 'too_large') {
152
+ throw new Error(`The uploaded receipt is over the ${LIMIT_LABEL} limit (${MAX_RECEIPT_BYTES} bytes)`);
153
+ }
154
+ throw err;
155
+ }
156
+ }
157
+ /** The canonical MIME type for an upload, or an error naming the ones Splitwise takes. */
158
+ export function canonicalReceiptMime(contentType) {
159
+ const claimed = contentType.trim().toLowerCase();
160
+ const mime = MIME_ALIASES[claimed] ?? claimed;
161
+ if (!RECEIPT_TYPES.some((t) => t.mime === mime)) {
162
+ throw new Error(`content_type must be one of ${RECEIPT_TYPES.map((t) => t.mime).join(', ')} (got ${contentType})`);
163
+ }
164
+ return mime;
165
+ }
109
166
  /**
110
167
  * Read and validate a receipt before the confirmation gate, so a bad file is
111
168
  * refused on the first call and the preview can name exactly what will be sent.
112
169
  */
113
- export async function loadReceipt(input) {
114
- const hasPath = input.path !== undefined;
115
- const hasBase64 = input.base64 !== undefined;
116
- if (hasPath === hasBase64) {
117
- throw new Error('receipt needs exactly one of path or base64');
170
+ export async function loadReceipt(input, client) {
171
+ const sources = [input.upload_id, input.path, input.base64].filter((v) => v !== undefined).length;
172
+ if (sources !== 1) {
173
+ throw new Error('receipt needs exactly one of upload_id, path or base64');
118
174
  }
119
- const bytes = hasPath ? await readSandboxed(input.path) : decodeBase64(input.base64);
175
+ const bytes = input.upload_id !== undefined
176
+ ? await readUpload(input.upload_id, client)
177
+ : input.path !== undefined
178
+ ? await readSandboxed(input.path)
179
+ : decodeBase64(input.base64);
120
180
  if (bytes.length === 0)
121
181
  throw new Error('Receipt file is empty');
122
182
  if (bytes.length > MAX_RECEIPT_BYTES)