splitwise-mcp 3.3.1 → 3.4.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/client.js CHANGED
@@ -128,7 +128,9 @@ export class SplitwiseClient {
128
128
  * Optional constructor seam: a hosted per-user deployment builds one client
129
129
  * per request with that user's `apiKey` injected. The stdio path passes no
130
130
  * options, so the key resolves from the environment exactly as before —
131
- * byte-for-byte identical behaviour.
131
+ * byte-for-byte identical behaviour. `baseUrl` points the API at another
132
+ * origin (the HTTP integration tests' fake Splitwise); it defaults to the
133
+ * real API.
132
134
  */
133
135
  constructor(opts) {
134
136
  // Injected apiKey (hosted per-user path) takes precedence; otherwise fall
@@ -153,7 +155,7 @@ export class SplitwiseClient {
153
155
  this.apiKey = key;
154
156
  this.configError = null;
155
157
  }
156
- this.api = this.buildApiClient(BASE_URL, true);
158
+ this.api = this.buildApiClient(opts?.baseUrl ?? BASE_URL, true);
157
159
  }
158
160
  /**
159
161
  * Which source supplied the API key, for `sw_healthcheck`. Returns a LABEL
@@ -197,6 +199,16 @@ export class SplitwiseClient {
197
199
  assertWriteSucceeded(data);
198
200
  return data;
199
201
  }
202
+ /**
203
+ * A write sent as multipart/form-data — the only encoding Splitwise accepts
204
+ * for an expense that carries a receipt file. Same auth, retry and
205
+ * write-rejection handling as `request`.
206
+ */
207
+ async requestMultipart(method, path, formData) {
208
+ const data = await this.api.fetchJson(method, path, { formData });
209
+ assertWriteSucceeded(data);
210
+ return data;
211
+ }
200
212
  /**
201
213
  * Fetch the raw bytes behind an absolute asset URL the API handed back (an
202
214
  * expense receipt). Splitwise serves receipts two ways and this covers both:
package/dist/index.js CHANGED
@@ -19,7 +19,7 @@ import { registerUtilityTools } from './tools/utilities.js';
19
19
  // client into the same registrars instead of this singleton.
20
20
  await runMcp({
21
21
  name: 'splitwise-mcp',
22
- version: '3.3.1', // x-release-please-version
22
+ version: '3.4.1', // x-release-please-version
23
23
  deps: client,
24
24
  banner: '[splitwise-mcp] This project was developed and is maintained by AI (Claude Sonnet 4.6). Use at your own discretion.',
25
25
  tools: [
@@ -0,0 +1,173 @@
1
+ import { createHash } from 'crypto';
2
+ import { readEnvVar } from '@chrischall/mcp-utils';
3
+ import { z } from 'zod';
4
+ /**
5
+ * Splitwise does not document an upload cap, so this is a conservative 10 MiB:
6
+ * comfortably above a phone photo or a multi-page PDF receipt, and small
7
+ * enough that the base64 form (≈13.4 MiB) stays a reasonable tool argument.
8
+ */
9
+ export const MAX_RECEIPT_BYTES = 10 * 1024 * 1024;
10
+ const LIMIT_LABEL = '10 MiB';
11
+ /** The types the Splitwise web UI offers ("Attach an image or PDF"). */
12
+ const RECEIPT_TYPES = [
13
+ { mime: 'image/png', ext: 'png', magic: [0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a] },
14
+ { mime: 'image/jpeg', ext: 'jpg', magic: [0xff, 0xd8, 0xff] },
15
+ { mime: 'image/gif', ext: 'gif', magic: [0x47, 0x49, 0x46, 0x38] },
16
+ { mime: 'application/pdf', ext: 'pdf', magic: [0x25, 0x50, 0x44, 0x46, 0x2d] },
17
+ ];
18
+ /** Spellings a caller may reasonably send for a detected type. */
19
+ const MIME_ALIASES = { 'image/jpg': 'image/jpeg', 'image/pjpeg': 'image/jpeg' };
20
+ export const receiptSchema = z
21
+ .object({
22
+ base64: z
23
+ .string()
24
+ .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
+ .optional(),
27
+ path: z
28
+ .string()
29
+ .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
+ .optional(),
32
+ filename: z.string().min(1).max(200).describe('Name to store the file under, e.g. "receipt.pdf".'),
33
+ mime_type: z
34
+ .string()
35
+ .describe('Optional. If given, it must match the type detected from the file contents.')
36
+ .optional(),
37
+ })
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.`);
39
+ export const receiptParam = receiptSchema.optional();
40
+ const tooBig = (size) => new Error(`Receipt is ${size} bytes, over the ${LIMIT_LABEL} limit (${MAX_RECEIPT_BYTES} bytes)`);
41
+ function sniff(bytes) {
42
+ return RECEIPT_TYPES.find((t) => t.magic.every((b, i) => bytes[i] === b));
43
+ }
44
+ function decodeBase64(input) {
45
+ const stripped = input.replace(/^data:[^;,]*;base64,/i, '').replace(/\s+/g, '');
46
+ // Refuse on the encoded length so an oversized argument is never decoded.
47
+ const decodedSize = Math.floor((stripped.length * 3) / 4) - (stripped.match(/=*$/)?.[0].length ?? 0);
48
+ if (decodedSize > MAX_RECEIPT_BYTES)
49
+ throw tooBig(decodedSize);
50
+ // Never echo the input: it is the caller's file.
51
+ if (!/^[A-Za-z0-9+/]*={0,2}$/.test(stripped) || stripped.length % 4 === 1) {
52
+ throw new Error('receipt.base64 is not valid base64');
53
+ }
54
+ return Uint8Array.from(Buffer.from(stripped, 'base64'));
55
+ }
56
+ /** Keep the stored name to a plain basename: no directories, no control characters. */
57
+ function cleanFilename(name) {
58
+ const base = name.split(/[\\/]/).pop() ?? '';
59
+ return base.replace(/[\u0000-\u001f\u007f"]/g, '').trim();
60
+ }
61
+ /**
62
+ * Read a receipt from SPLITWISE_INPUT_DIR. Unset means no filesystem access at
63
+ * all — the hosted case, where the caller's files are not on this machine and
64
+ * the server's own files must not be reachable. Both the dir and the target
65
+ * are realpath-resolved, so `..` and symlinks (to a file or through a
66
+ * directory) that lead out of the dir are refused; the bytes are then read
67
+ * through the handle that was checked, not by re-opening the name.
68
+ */
69
+ async function readSandboxed(requested) {
70
+ const dir = readEnvVar('SPLITWISE_INPUT_DIR');
71
+ if (!dir) {
72
+ throw new Error('receipt.path is disabled: SPLITWISE_INPUT_DIR is not set on this server (and a hosted server cannot see your files). Pass the file as receipt.base64 instead.');
73
+ }
74
+ const { open, realpath } = await import('fs/promises');
75
+ const { constants } = await import('fs');
76
+ const { isAbsolute, relative, resolve, sep } = await import('path');
77
+ let root;
78
+ try {
79
+ root = await realpath(dir);
80
+ }
81
+ catch (err) {
82
+ throw new Error(`SPLITWISE_INPUT_DIR (${dir}) is not readable: ${err.message}`);
83
+ }
84
+ let target;
85
+ try {
86
+ target = await realpath(resolve(root, requested));
87
+ }
88
+ catch (err) {
89
+ throw new Error(`Cannot read receipt file ${requested}: ${err.code ?? err.message}`);
90
+ }
91
+ const rel = relative(root, target);
92
+ if (rel === '' || rel === '..' || rel.startsWith(`..${sep}`) || isAbsolute(rel)) {
93
+ throw new Error(`receipt.path ${requested} resolves outside SPLITWISE_INPUT_DIR`);
94
+ }
95
+ // O_NOFOLLOW: a symlink swapped in after the realpath check is refused.
96
+ const handle = await open(target, constants.O_RDONLY | constants.O_NOFOLLOW);
97
+ try {
98
+ const info = await handle.stat();
99
+ if (!info.isFile())
100
+ throw new Error(`Cannot read receipt file ${requested}: not a regular file`);
101
+ if (info.size > MAX_RECEIPT_BYTES)
102
+ throw tooBig(info.size);
103
+ return Uint8Array.from(await handle.readFile());
104
+ }
105
+ finally {
106
+ await handle.close();
107
+ }
108
+ }
109
+ /**
110
+ * Read and validate a receipt before the confirmation gate, so a bad file is
111
+ * refused on the first call and the preview can name exactly what will be sent.
112
+ */
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');
118
+ }
119
+ const bytes = hasPath ? await readSandboxed(input.path) : decodeBase64(input.base64);
120
+ if (bytes.length === 0)
121
+ throw new Error('Receipt file is empty');
122
+ if (bytes.length > MAX_RECEIPT_BYTES)
123
+ throw tooBig(bytes.length);
124
+ const type = sniff(bytes);
125
+ if (!type) {
126
+ throw new Error('Receipt must be a PNG, JPEG, GIF or PDF (detected from the file contents)');
127
+ }
128
+ if (input.mime_type !== undefined) {
129
+ const claimed = input.mime_type.trim().toLowerCase();
130
+ if ((MIME_ALIASES[claimed] ?? claimed) !== type.mime) {
131
+ throw new Error(`receipt.mime_type ${claimed} does not match the file contents (${type.mime})`);
132
+ }
133
+ }
134
+ const filename = cleanFilename(input.filename) || `receipt.${type.ext}`;
135
+ const sha256 = createHash('sha256').update(bytes).digest('hex');
136
+ return {
137
+ bytes,
138
+ filename,
139
+ mimeType: type.mime,
140
+ summary: { filename, mime_type: type.mime, bytes: bytes.length, sha256 },
141
+ };
142
+ }
143
+ /**
144
+ * Build the multipart body Splitwise takes when an expense carries a file:
145
+ * every expense field as a form field (the same flat `users__N__*` keys the
146
+ * JSON path sends), plus the file under `receipt`.
147
+ */
148
+ export function buildReceiptForm(fields, receipt) {
149
+ const form = new FormData();
150
+ for (const [key, value] of Object.entries(fields)) {
151
+ if (value === undefined || value === null)
152
+ continue;
153
+ form.append(key, String(value));
154
+ }
155
+ form.append('receipt', new Blob([receipt.bytes], { type: receipt.mimeType }), receipt.filename);
156
+ return form;
157
+ }
158
+ /**
159
+ * After a receipt write, confirm Splitwise actually stored the file. It can
160
+ * answer 200 with the expense saved and `receipt.original` still null; that is
161
+ * a failure the caller must hear about, not a success. Returns the response
162
+ * with `receipt_attached` and the expense's `receipt` lifted to the top.
163
+ */
164
+ export function verifyReceiptStored(data, expenseId) {
165
+ const expense = data
166
+ ?.expenses?.[0];
167
+ const id = expense?.id ?? expenseId;
168
+ if (!expense?.receipt?.original) {
169
+ throw new Error(`Splitwise saved expense ${id ?? '(unknown id)'} but did not attach the receipt (receipt.original is empty). ` +
170
+ `The expense itself was written — do not re-create it; retry the file with sw_attach_receipt on expense ${id ?? '<id>'}.`);
171
+ }
172
+ return { receipt_attached: true, receipt: expense.receipt, ...data };
173
+ }
@@ -3,6 +3,7 @@ import { SW_VIEWS, viewExpense, viewExpenses } from '../project.js';
3
3
  import { buildQueryString, minifiedResult, resolveView, viewParam } from '@chrischall/mcp-utils';
4
4
  import { CONFIRM_NOTE, confirmTokenParam, confirmWrite } from './_confirm.js';
5
5
  import { UNTRUSTED_DESCRIPTION_SUFFIX, swUntrustedResult } from './_untrusted.js';
6
+ import { buildReceiptForm, loadReceipt, receiptParam, receiptSchema, verifyReceiptStored, } from './_receipt.js';
6
7
  /** Flattens a users array into Splitwise's flat-param JSON format. */
7
8
  export function flattenUsers(users) {
8
9
  const flat = {};
@@ -14,8 +15,9 @@ export function flattenUsers(users) {
14
15
  return flat;
15
16
  }
16
17
  function buildExpenseBody(args) {
17
- // `confirmToken` is a tool-gate input, not part of the Splitwise expense payload.
18
- const { split_equally, users, expense_id: _id, confirmToken: _confirmToken, ...rest } = args;
18
+ // `confirmToken` is a tool-gate input and `receipt` is sent as a file part,
19
+ // so neither belongs in the expense fields.
20
+ const { split_equally, users, expense_id: _id, confirmToken: _confirmToken, receipt: _receipt, ...rest } = args;
19
21
  if (split_equally && users) {
20
22
  throw new Error('Provide either split_equally or users, not both');
21
23
  }
@@ -28,6 +30,22 @@ function buildExpenseBody(args) {
28
30
  }
29
31
  return body;
30
32
  }
33
+ /**
34
+ * Send a create/update. With a receipt the whole expense goes as multipart
35
+ * (Splitwise only takes a file that way) and the response must show the file
36
+ * stored; without one it stays JSON and the response passes through, exactly
37
+ * as before.
38
+ */
39
+ async function sendExpenseWrite(client, path, body, receipt, expenseId) {
40
+ if (!receipt)
41
+ return client.request('POST', path, body);
42
+ const data = await client.requestMultipart('POST', path, buildReceiptForm(body, receipt));
43
+ return verifyReceiptStored(data, expenseId);
44
+ }
45
+ /** The body the confirmation preview shows: the fields, plus the file's identity in place of its bytes. */
46
+ function previewBody(body, receipt) {
47
+ return receipt ? { ...body, receipt: receipt.summary } : body;
48
+ }
31
49
  const userShareSchema = z.object({
32
50
  user_id: z.number(),
33
51
  paid_share: z.string().describe('Amount this user paid, e.g. "25.00"'),
@@ -92,7 +110,7 @@ export function registerExpenseTools(server, client) {
92
110
  return swUntrustedResult(viewExpense(resolveView(view, SW_VIEWS), data));
93
111
  });
94
112
  server.registerTool('sw_create_expense', {
95
- description: `Create a Splitwise expense. Use split_equally:true to split evenly among group members, or provide 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. This puts charges on every participant's balance and notifies them. ${CONFIRM_NOTE}`,
113
+ description: `Create a Splitwise expense. Use split_equally:true to split evenly among group members, or provide 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 receipt to attach an image or PDF in the same call. This puts charges on every participant's balance and notifies them. ${CONFIRM_NOTE}`,
96
114
  annotations: { readOnlyHint: false, destructiveHint: true, openWorldHint: true },
97
115
  inputSchema: z.object({
98
116
  group_id: z.number().describe('Group to add expense to (use 0 for no group)'),
@@ -113,28 +131,30 @@ export function registerExpenseTools(server, client) {
113
131
  date: z.string().describe('ISO 8601 datetime').optional(),
114
132
  category_id: z.number().describe('Category id from sw_get_categories').optional(),
115
133
  details: z.string().describe('Notes').optional(),
134
+ receipt: receiptParam,
116
135
  confirmToken: confirmTokenParam,
117
136
  }),
118
137
  }, async (args, ctx) => {
119
138
  const body = buildExpenseBody(args);
139
+ const receipt = args.receipt ? await loadReceipt(args.receipt) : undefined;
120
140
  const gate = await confirmWrite(ctx, {
121
141
  tool: 'sw_create_expense',
122
142
  action: 'expense.create',
123
- summary: `Create a Splitwise expense "${args.description}" (${args.cost}) — notifies group members`,
143
+ summary: `Create a Splitwise expense "${args.description}" (${args.cost})${receipt ? ` with receipt ${receipt.filename}` : ''} — notifies group members`,
124
144
  method: 'POST',
125
145
  path: '/create_expense',
126
- body,
146
+ body: previewBody(body, receipt),
127
147
  target: args.group_id,
128
148
  confirmToken: args.confirmToken,
129
149
  args,
130
150
  });
131
151
  if (gate)
132
152
  return gate;
133
- const data = await client.request('POST', '/create_expense', body);
153
+ const data = await sendExpenseWrite(client, '/create_expense', body, receipt);
134
154
  return minifiedResult(data);
135
155
  });
136
156
  server.registerTool('sw_update_expense', {
137
- 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). ${CONFIRM_NOTE}`,
157
+ 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. ${CONFIRM_NOTE}`,
138
158
  annotations: { readOnlyHint: false, destructiveHint: true, openWorldHint: true },
139
159
  inputSchema: z.object({
140
160
  expense_id: z.number().describe('ID of the expense to update'),
@@ -149,25 +169,55 @@ export function registerExpenseTools(server, client) {
149
169
  date: z.string().optional(),
150
170
  category_id: z.number().optional(),
151
171
  details: z.string().optional(),
172
+ receipt: receiptParam,
152
173
  confirmToken: confirmTokenParam,
153
174
  }),
154
175
  }, async (args, ctx) => {
155
176
  const { expense_id } = args;
156
177
  const body = buildExpenseBody(args);
178
+ const receipt = args.receipt ? await loadReceipt(args.receipt) : undefined;
157
179
  const gate = await confirmWrite(ctx, {
158
180
  tool: 'sw_update_expense',
159
181
  action: 'expense.update',
160
- summary: `Update Splitwise expense ${expense_id} — notifies group members`,
182
+ summary: `Update Splitwise expense ${expense_id}${receipt ? ` (attach receipt ${receipt.filename})` : ''} — notifies group members`,
161
183
  method: 'POST',
162
184
  path: `/update_expense/${expense_id}`,
163
- body,
185
+ body: previewBody(body, receipt),
186
+ target: expense_id,
187
+ confirmToken: args.confirmToken,
188
+ args,
189
+ });
190
+ if (gate)
191
+ return gate;
192
+ const data = await sendExpenseWrite(client, `/update_expense/${expense_id}`, body, receipt, expense_id);
193
+ return minifiedResult(data);
194
+ });
195
+ server.registerTool('sw_attach_receipt', {
196
+ 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.base64. Fails if Splitwise does not store the file. ${CONFIRM_NOTE}`,
197
+ annotations: { readOnlyHint: false, destructiveHint: true, idempotentHint: true, openWorldHint: true },
198
+ inputSchema: z.object({
199
+ expense_id: z.number().describe('ID of the expense to attach the receipt to'),
200
+ receipt: receiptSchema,
201
+ confirmToken: confirmTokenParam,
202
+ }),
203
+ }, async (args, ctx) => {
204
+ const { expense_id } = args;
205
+ const receipt = await loadReceipt(args.receipt);
206
+ const path = `/update_expense/${expense_id}`;
207
+ const gate = await confirmWrite(ctx, {
208
+ tool: 'sw_attach_receipt',
209
+ action: 'expense.attach_receipt',
210
+ summary: `Attach receipt ${receipt.filename} to Splitwise expense ${expense_id} (replaces any existing receipt)`,
211
+ method: 'POST',
212
+ path,
213
+ body: previewBody({}, receipt),
164
214
  target: expense_id,
165
215
  confirmToken: args.confirmToken,
166
216
  args,
167
217
  });
168
218
  if (gate)
169
219
  return gate;
170
- const data = await client.request('POST', `/update_expense/${expense_id}`, body);
220
+ const data = await sendExpenseWrite(client, path, {}, receipt, expense_id);
171
221
  return minifiedResult(data);
172
222
  });
173
223
  server.registerTool('sw_delete_expense', {
@@ -1,5 +1,5 @@
1
1
  import { z } from 'zod';
2
- import { minifiedResult, imageResult, readEnvVar, resolveOutputDir, writeBinaryOutput, sniffMimeBytes, } from '@chrischall/mcp-utils';
2
+ import { minifiedResult, imageResult, readEnvVar, resolveOutputDir, writeBinaryOutput, sniffMimeBytes, extensionForMime, } from '@chrischall/mcp-utils';
3
3
  import { UNTRUSTED_DESCRIPTION_SUFFIX, swUntrustedEnvelope } from './_untrusted.js';
4
4
  /** Cap on bytes returned as base64 in the tool result. */
5
5
  const MAX_INLINE_BYTES = 4 * 1024 * 1024;
@@ -49,24 +49,6 @@ function resolveMimeType(contentType, bytes) {
49
49
  const declared = (contentType ?? '').split(';')[0].trim().toLowerCase();
50
50
  return declared && !GENERIC_TYPES.has(declared) ? declared : undefined;
51
51
  }
52
- /** Extension from a URL path (`…/receipt.jpg?sig=…` → `jpg`), when it has one. */
53
- function extensionFromUrl(url) {
54
- const match = /\.([a-z0-9]{1,5})$/i.exec(new URL(url).pathname);
55
- return match ? match[1].toLowerCase() : undefined;
56
- }
57
- /**
58
- * File extension for the receipt: the MIME subtype when it looks like a plain
59
- * one (`application/pdf` → `pdf`, `image/heic` → `heic`), else the URL's own
60
- * extension, else `bin`.
61
- */
62
- function extensionFor(mimeType, url) {
63
- const subtype = mimeType?.split('/')[1];
64
- if (subtype === 'jpeg')
65
- return 'jpg';
66
- if (subtype && /^[a-z0-9]{1,5}$/.test(subtype))
67
- return subtype;
68
- return extensionFromUrl(url) ?? 'bin';
69
- }
70
52
  /**
71
53
  * Extract a PDF's text layer. `unpdf` is imported lazily so the cost lands only
72
54
  * on calls that ask for text — it is a sizable pdf.js build, and every other
@@ -162,7 +144,8 @@ export function registerReceiptTools(server, client) {
162
144
  }),
163
145
  baseName: `splitwise-receipt-${id}`,
164
146
  base64,
165
- extension: extensionFor(mimeType, url),
147
+ // The MIME type, else the receipt URL's own extension, else `bin`.
148
+ extension: extensionForMime(mimeType, { url }),
166
149
  mode: RECEIPT_FILE_MODE,
167
150
  });
168
151
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "splitwise-mcp",
3
- "version": "3.3.1",
3
+ "version": "3.4.1",
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,7 +45,7 @@
45
45
  "typecheck": "tsc -p tsconfig.json --noEmit"
46
46
  },
47
47
  "dependencies": {
48
- "@chrischall/mcp-utils": "^3.0.0",
48
+ "@chrischall/mcp-utils": "^3.0.3",
49
49
  "@modelcontextprotocol/server": "^2.3.0",
50
50
  "dotenv": "^18.0.2",
51
51
  "unpdf": "^1.8.1",
package/server.json CHANGED
@@ -6,12 +6,12 @@
6
6
  "url": "https://github.com/chrischall/splitwise-mcp",
7
7
  "source": "github"
8
8
  },
9
- "version": "3.3.1",
9
+ "version": "3.4.1",
10
10
  "packages": [
11
11
  {
12
12
  "registryType": "npm",
13
13
  "identifier": "splitwise-mcp",
14
- "version": "3.3.1",
14
+ "version": "3.4.1",
15
15
  "transport": {
16
16
  "type": "stdio"
17
17
  },
@@ -29,6 +29,13 @@
29
29
  "isRequired": false,
30
30
  "format": "string",
31
31
  "isSecret": false
32
+ },
33
+ {
34
+ "name": "SPLITWISE_INPUT_DIR",
35
+ "description": "Directory receipt.path may read uploads from (unset: receipts are accepted as base64 only)",
36
+ "isRequired": false,
37
+ "format": "string",
38
+ "isSecret": false
32
39
  }
33
40
  ]
34
41
  }
@@ -92,10 +92,11 @@ API key auth — no login flow or token rotation. The key is attached to every r
92
92
  |------|-------------|
93
93
  | `sw_list_expenses(group_id?, friend_id?, dated_after?, dated_before?, limit?, offset?, view?)` | List or search expenses |
94
94
  | `sw_get_expense(id, view?)` | Get full details of a single expense |
95
- | `sw_create_expense(group_id, description, cost, split_equally? \| users?)` | Create an expense — equal split or custom per-person split |
96
- | `sw_update_expense(expense_id, ...)` | Edit an existing expense (custom split requires full `users` array) |
95
+ | `sw_create_expense(group_id, description, cost, split_equally? \| users?, receipt?)` | Create an expense — equal split or custom per-person split; `receipt` attaches an image/PDF in the same call |
96
+ | `sw_update_expense(expense_id, ..., receipt?)` | Edit an existing expense (custom split requires full `users` array). `expense_id` + `receipt` alone attaches or replaces the receipt |
97
97
  | `sw_delete_expense(id)` | Soft-delete an expense |
98
98
  | `sw_undelete_expense(id)` | Restore a soft-deleted expense |
99
+ | `sw_attach_receipt(expense_id, receipt)` | Attach or replace the receipt on an existing expense; changes nothing else |
99
100
 
100
101
  ### Receipts
101
102
  | Tool | Description |
@@ -173,8 +174,8 @@ The other eighteen tools take no `view`, each for its own reason:
173
174
  - **`sw_get_categories` and `sw_get_currencies`** are static reference lists —
174
175
  already narrow, no media, and every field on them is the answer.
175
176
  - **`sw_healthcheck`** answers reachability and auth.
176
- - **The fourteen writes** (`sw_create_expense`, `sw_update_expense`,
177
- `sw_delete_expense`, `sw_undelete_expense`, `sw_create_group`,
177
+ - **The fifteen writes** (`sw_create_expense`, `sw_update_expense`,
178
+ `sw_attach_receipt`, `sw_delete_expense`, `sw_undelete_expense`, `sw_create_group`,
178
179
  `sw_delete_group`, `sw_undelete_group`, `sw_add_user_to_group`,
179
180
  `sw_remove_user_from_group`, `sw_create_friend`, `sw_delete_friend`,
180
181
  `sw_create_comment`, `sw_delete_comment`, `sw_update_user`) return receipts —
@@ -205,6 +206,16 @@ sw_create_expense(group_id, "Hotel", "200.00", users: [
205
206
  ])
206
207
  ```
207
208
 
209
+ **Attach a receipt (proof of purchase):**
210
+
211
+ ```
212
+ sw_create_expense(group_id, "Band camp water jug", "54.11", split_equally: true,
213
+ receipt: { base64: "<png/jpeg/gif/pdf bytes>", filename: "amazon-order.png" })
214
+ sw_attach_receipt(expense_id, receipt: { base64: "<png/jpeg/gif/pdf bytes>", filename: "order.pdf" })
215
+ ```
216
+
217
+ `receipt` takes `filename` plus exactly one of `base64` (the normal input) or `path` (only on a local server with `SPLITWISE_INPUT_DIR` set, and only inside that directory). PNG, JPEG, GIF or PDF, ≤ 10 MiB, type detected from the bytes. It replaces any existing receipt. Use `sw_attach_receipt` to add a file to an existing expense — it sends nothing but the file. If Splitwise saves the expense but drops the file, the tool returns an error naming the expense: do not re-create it, retry with `sw_attach_receipt`.
218
+
208
219
  **Get the receipt for an expense:**
209
220
  ```
210
221
  sw_list_expenses(...) → find expense ID