splitwise-mcp 3.4.1 → 3.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -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 { blobConfig, signedGetUrl, UPLOAD_ID_PATTERN } from './_blob.js';
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,59 @@ async function readSandboxed(requested) {
106
112
  await handle.close();
107
113
  }
108
114
  }
115
+ /** How long the server's own read link for an upload lives — it is used at once. */
116
+ const UPLOAD_READ_TTL_MS = 5 * 60 * 1000;
117
+ /**
118
+ * Read a file the caller PUT to a `sw_get_upload_url` link, through a signed
119
+ * GET on the same blob store. Only `up/<uuid>/<name>` keys are accepted.
120
+ */
121
+ async function readUpload(uploadId, client) {
122
+ if (!UPLOAD_ID_PATTERN.test(uploadId)) {
123
+ throw new Error('receipt.upload_id is not one sw_get_upload_url issued (expected up/<uuid>/<filename>)');
124
+ }
125
+ const cfg = blobConfig();
126
+ if (!cfg || !client) {
127
+ throw new Error('receipt.upload_id only works on the hosted server; pass receipt.path or receipt.base64 here.');
128
+ }
129
+ try {
130
+ const asset = await client.fetchAsset(signedGetUrl(cfg, uploadId, Date.now() + UPLOAD_READ_TTL_MS), {
131
+ maxBytes: MAX_RECEIPT_BYTES,
132
+ });
133
+ return Uint8Array.from(asset.bytes);
134
+ }
135
+ catch (err) {
136
+ if (err instanceof ApiError && (err.status === 404 || err.status === 403)) {
137
+ 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.`);
138
+ }
139
+ if (err.kind === 'too_large') {
140
+ throw new Error(`The uploaded receipt is over the ${LIMIT_LABEL} limit (${MAX_RECEIPT_BYTES} bytes)`);
141
+ }
142
+ throw err;
143
+ }
144
+ }
145
+ /** The canonical MIME type for an upload, or an error naming the ones Splitwise takes. */
146
+ export function canonicalReceiptMime(contentType) {
147
+ const claimed = contentType.trim().toLowerCase();
148
+ const mime = MIME_ALIASES[claimed] ?? claimed;
149
+ if (!RECEIPT_TYPES.some((t) => t.mime === mime)) {
150
+ throw new Error(`content_type must be one of ${RECEIPT_TYPES.map((t) => t.mime).join(', ')} (got ${contentType})`);
151
+ }
152
+ return mime;
153
+ }
109
154
  /**
110
155
  * Read and validate a receipt before the confirmation gate, so a bad file is
111
156
  * refused on the first call and the preview can name exactly what will be sent.
112
157
  */
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');
158
+ export async function loadReceipt(input, client) {
159
+ const sources = [input.upload_id, input.path, input.base64].filter((v) => v !== undefined).length;
160
+ if (sources !== 1) {
161
+ throw new Error('receipt needs exactly one of upload_id, path or base64');
118
162
  }
119
- const bytes = hasPath ? await readSandboxed(input.path) : decodeBase64(input.base64);
163
+ const bytes = input.upload_id !== undefined
164
+ ? await readUpload(input.upload_id, client)
165
+ : input.path !== undefined
166
+ ? await readSandboxed(input.path)
167
+ : decodeBase64(input.base64);
120
168
  if (bytes.length === 0)
121
169
  throw new Error('Receipt file is empty');
122
170
  if (bytes.length > MAX_RECEIPT_BYTES)
@@ -0,0 +1,61 @@
1
+ /**
2
+ * The shares for "`payer` paid it all, everyone in `participants` owes an
3
+ * equal part". Built here rather than with Splitwise's `split_equally`, which
4
+ * always makes the CALLER the payer — the 50/50 case where the other parent
5
+ * paid had to be hand-built as a users[] array.
6
+ *
7
+ * Arithmetic is in minor units at the precision the cost string was written
8
+ * with ("25.01" → cents, "1001" → whole units), so the shares always sum to
9
+ * the cost exactly. Leftover units go one each to the first participants in
10
+ * order — deterministic, and shown in the preview before anything is sent.
11
+ */
12
+ export function equalSplit(cost, payer, participants) {
13
+ const match = /^(\d+)(?:\.(\d+))?$/.exec(cost);
14
+ if (!match)
15
+ throw new Error(`cost must be a plain decimal string like "25.00" for an equal split (got "${cost}")`);
16
+ const ids = [...new Set(participants)];
17
+ if (ids.length === 0)
18
+ throw new Error('split_among must name at least one participant');
19
+ const decimals = match[2]?.length ?? 0;
20
+ const total = BigInt(match[1] + (match[2] ?? ''));
21
+ const count = BigInt(ids.length);
22
+ const base = total / count;
23
+ const leftover = Number(total % count);
24
+ const fmt = (units) => formatUnits(units, decimals);
25
+ const shares = ids.map((id, i) => ({
26
+ user_id: id,
27
+ paid_share: fmt(id === payer ? total : 0n),
28
+ owed_share: fmt(base + (i < leftover ? 1n : 0n)),
29
+ }));
30
+ if (!ids.includes(payer)) {
31
+ shares.push({ user_id: payer, paid_share: fmt(total), owed_share: fmt(0n) });
32
+ }
33
+ return shares;
34
+ }
35
+ function formatUnits(units, decimals) {
36
+ if (decimals === 0)
37
+ return units.toString();
38
+ const digits = units.toString().padStart(decimals + 1, '0');
39
+ return `${digits.slice(0, -decimals)}.${digits.slice(-decimals)}`;
40
+ }
41
+ const fullName = (m) => [m.first_name, m.last_name].filter(Boolean).join(' ');
42
+ /**
43
+ * A member named by id, full name, email or a first name only they have.
44
+ * `field` names the argument in the error, so the caller knows which to fix.
45
+ */
46
+ export function resolveMember(who, members, field) {
47
+ if (typeof who === 'number') {
48
+ if (members.some((m) => m.id === who))
49
+ return who;
50
+ throw new Error(`${field}: user ${who} is not a member of this group`);
51
+ }
52
+ const want = who.trim().toLowerCase();
53
+ const exact = members.filter((m) => fullName(m).toLowerCase() === want || (m.email ?? '').toLowerCase() === want);
54
+ const candidates = exact.length > 0 ? exact : members.filter((m) => (m.first_name ?? '').toLowerCase() === want);
55
+ if (candidates.length === 1)
56
+ return candidates[0].id;
57
+ if (candidates.length > 1) {
58
+ throw new Error(`${field}: "${who}" is ambiguous — ${candidates.map((m) => `${fullName(m)} (${m.id})`).join(', ')}. Pass the user id.`);
59
+ }
60
+ throw new Error(`${field}: "${who}" is not a member of this group (members: ${members.map((m) => `${fullName(m)} (${m.id})`).join(', ')})`);
61
+ }
@@ -0,0 +1,22 @@
1
+ import { registerBatchTool, registerConfirmTool } from '@chrischall/mcp-utils';
2
+ /**
3
+ * `sw_confirm` and `sw_batch` — the two ways to spend fewer calls on Splitwise
4
+ * writes from a client that cannot show a confirmation prompt (claude.ai).
5
+ *
6
+ * - `sw_confirm({confirmToken})` finishes any gated write's two-step
7
+ * confirmation with the token alone: the server kept the phase-1 arguments,
8
+ * so a base64 receipt does not cross the conversation a second time.
9
+ * - `sw_batch({ops})` previews and approves several expense/comment writes at
10
+ * once — "log the expense, comment on it, attach the receipt" in one
11
+ * approval, with the new expense's id fed forward through `refs`.
12
+ *
13
+ * Both come from `@chrischall/mcp-utils` (docs/CONFIRM-TOKEN.md §13–14).
14
+ * Registered LAST: the batch tool lists the writes registered before it.
15
+ */
16
+ export function registerBatchTools(server, _client) {
17
+ registerConfirmTool(server, { name: 'sw_confirm' });
18
+ registerBatchTool(server, {
19
+ name: 'sw_batch',
20
+ description: 'Several Splitwise writes in one go, e.g. create an expense, then sw_create_comment / sw_attach_receipt on it with refs {"expense_id": "0.expense_id"}.',
21
+ });
22
+ }