splitwise-mcp 2.1.6 → 2.2.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
@@ -1,6 +1,6 @@
1
1
  import { dirname, join } from 'path';
2
2
  import { fileURLToPath } from 'url';
3
- import { loadDotenvSafely, readEnvVar, createApiClient } from '@chrischall/mcp-utils';
3
+ import { loadDotenvSafely, readEnvVar, createApiClient, ApiError, } from '@chrischall/mcp-utils';
4
4
  // Load .env for local dev; silently skip if dotenv is unavailable (e.g. mcpb
5
5
  // bundle). `loadDotenvSafely` swallows a missing dotenv module and never lets
6
6
  // .env override a host-provided value.
@@ -16,10 +16,55 @@ catch {
16
16
  }
17
17
  const BASE_URL = 'https://secure.splitwise.com/api/v3.0';
18
18
  const SERVICE_NAME = 'Splitwise';
19
+ const SPLITWISE_DOMAIN = 'splitwise.com';
20
+ /**
21
+ * Whether a hostname belongs to Splitwise, and may therefore be sent the API
22
+ * key. Receipt bytes are served either from a Splitwise host (which requires
23
+ * the key) or from a presigned third-party URL (S3), which must never see it.
24
+ */
25
+ export function isSplitwiseHost(hostname) {
26
+ const host = hostname.toLowerCase();
27
+ return host === SPLITWISE_DOMAIN || host.endsWith(`.${SPLITWISE_DOMAIN}`);
28
+ }
29
+ /** Parse an asset URL handed back by the API, rejecting anything not https. */
30
+ function parseAssetUrl(url) {
31
+ let parsed;
32
+ try {
33
+ parsed = new URL(url);
34
+ }
35
+ catch {
36
+ // Deliberately not echoing the URL: it can carry a signed token.
37
+ throw new Error('Splitwise returned an asset URL that could not be parsed');
38
+ }
39
+ if (parsed.protocol !== 'https:') {
40
+ throw new Error(`Refusing to fetch an asset over ${parsed.protocol}// — https only`);
41
+ }
42
+ return parsed;
43
+ }
44
+ /**
45
+ * Strip the asset URL's signed query string out of an error message. A failed
46
+ * `fetchRaw` names the path it requested, and a receipt URL's query carries a
47
+ * signed, capability-bearing token that must not reach a tool result.
48
+ *
49
+ * Matches the exact query bytes we sent rather than a pattern for the message
50
+ * shape: that keeps working whatever an upstream formatter does with the URL
51
+ * (relative path today, absolute URL tomorrow), and it can't mangle an error
52
+ * body that happens to contain a `?`.
53
+ */
54
+ function redactAssetQuery(err, search) {
55
+ if (!(err instanceof Error) || !search)
56
+ return err;
57
+ const message = err.message.split(search).join('?<redacted>');
58
+ if (message === err.message)
59
+ return err;
60
+ return err instanceof ApiError ? new ApiError(err.status, message) : new Error(message);
61
+ }
19
62
  export class SplitwiseClient {
20
63
  apiKey;
21
64
  configError;
22
65
  api;
66
+ /** Per-origin clients for asset hosts, built on first use by `fetchAsset`. */
67
+ assetClients = new Map();
23
68
  /**
24
69
  * Defer the config error so the server can still start (and respond to the
25
70
  * host's install-time smoke test) when SPLITWISE_API_KEY isn't set yet.
@@ -44,15 +89,28 @@ export class SplitwiseClient {
44
89
  this.apiKey = key;
45
90
  this.configError = null;
46
91
  }
47
- // `getToken` defers config errors to request time; on* handlers preserve Splitwise's documented 401/429 messages.
48
- this.api = createApiClient({
49
- baseUrl: BASE_URL,
50
- getToken: () => this.requireKey(),
51
- serviceName: SERVICE_NAME,
92
+ this.api = this.buildApiClient(BASE_URL, true);
93
+ }
94
+ /**
95
+ * One API client per origin, sharing the fleet's retry/timeout/error policy.
96
+ * `authenticated` decides whether the API key is attached: `getToken` defers
97
+ * config errors to request time, and on* handlers preserve Splitwise's
98
+ * documented 401/429 messages.
99
+ */
100
+ buildApiClient(baseUrl, authenticated) {
101
+ // An anonymous asset origin isn't Splitwise, so name it accurately in
102
+ // errors rather than blaming the Splitwise API for a third party's 429.
103
+ const service = authenticated ? SERVICE_NAME : new URL(baseUrl).host;
104
+ return createApiClient({
105
+ baseUrl,
106
+ serviceName: service,
52
107
  retry: { count: 1, delayMs: 2000 },
53
108
  timeout: 30_000,
54
- onUnauthorized: () => new Error('SPLITWISE_API_KEY is invalid or missing'),
55
- onRateLimited: () => new Error('Rate limited by Splitwise API'),
109
+ ...(authenticated ? { getToken: () => this.requireKey() } : {}),
110
+ onUnauthorized: () => new Error(authenticated
111
+ ? 'SPLITWISE_API_KEY is invalid or missing'
112
+ : 'The asset URL was rejected (401) — presigned URLs are short-lived, so re-run to get a fresh one'),
113
+ onRateLimited: () => new Error(`Rate limited by ${authenticated ? 'Splitwise API' : service}`),
56
114
  });
57
115
  }
58
116
  requireKey() {
@@ -63,6 +121,35 @@ export class SplitwiseClient {
63
121
  async request(method, path, body) {
64
122
  return this.api.fetchJson(method, path, body !== undefined ? { body } : {});
65
123
  }
124
+ /**
125
+ * Fetch the raw bytes behind an absolute asset URL the API handed back (an
126
+ * expense receipt). Splitwise serves receipts two ways and this covers both:
127
+ * from its own API (`https://www.splitwise.com/api/v4.0/expenses/{id}/receipt`),
128
+ * which 401s without the key, and from a presigned S3 URL, which carries its
129
+ * own signature and must NOT be sent the key. `isSplitwiseHost` picks.
130
+ *
131
+ * A cross-origin redirect (API → S3) is safe: per the Fetch standard the
132
+ * `Authorization` header is dropped when a redirect changes origin.
133
+ */
134
+ async fetchAsset(url) {
135
+ const target = parseAssetUrl(url);
136
+ const authenticated = isSplitwiseHost(target.hostname);
137
+ const cacheKey = `${target.origin}|${authenticated ? 'auth' : 'anon'}`;
138
+ let api = this.assetClients.get(cacheKey);
139
+ if (!api) {
140
+ api = this.buildApiClient(target.origin, authenticated);
141
+ this.assetClients.set(cacheKey, api);
142
+ }
143
+ try {
144
+ // `pathname + search` verbatim rather than the client's `query` option:
145
+ // a presigned signature covers the exact encoding, so re-encoding it
146
+ // would invalidate the URL.
147
+ return await api.fetchRaw('GET', `${target.pathname}${target.search}`);
148
+ }
149
+ catch (err) {
150
+ throw redactAssetQuery(err, target.search);
151
+ }
152
+ }
66
153
  }
67
154
  /**
68
155
  * Module-level singleton shared by every tool module. Constructing it here (not
package/dist/index.js CHANGED
@@ -5,6 +5,7 @@ import { registerUserTools } from './tools/user.js';
5
5
  import { registerGroupTools } from './tools/groups.js';
6
6
  import { registerFriendTools } from './tools/friends.js';
7
7
  import { registerExpenseTools } from './tools/expenses.js';
8
+ import { registerReceiptTools } from './tools/receipts.js';
8
9
  import { registerUtilityTools } from './tools/utilities.js';
9
10
  // runMcp builds the McpServer, applies the registrars (threading `client`
10
11
  // through as deps), prints the banner to stderr, wires graceful shutdown, and
@@ -17,7 +18,7 @@ import { registerUtilityTools } from './tools/utilities.js';
17
18
  // client into the same registrars instead of this singleton.
18
19
  await runMcp({
19
20
  name: 'splitwise-mcp',
20
- version: '2.1.6', // x-release-please-version
21
+ version: '2.2.1', // x-release-please-version
21
22
  deps: client,
22
23
  banner: '[splitwise-mcp] This project was developed and is maintained by AI (Claude Sonnet 4.6). Use at your own discretion.',
23
24
  tools: [
@@ -25,6 +26,7 @@ await runMcp({
25
26
  registerGroupTools,
26
27
  registerFriendTools,
27
28
  registerExpenseTools,
29
+ registerReceiptTools,
28
30
  registerUtilityTools,
29
31
  ],
30
32
  });
@@ -1,5 +1,5 @@
1
1
  import { z } from 'zod';
2
- import { textResult } from '@chrischall/mcp-utils';
2
+ import { textResult, pruneUndefined } from '@chrischall/mcp-utils';
3
3
  import { previewUnlessConfirmed, schemaConfirm } from './_confirm.js';
4
4
  export function registerFriendTools(server, client) {
5
5
  server.registerTool('sw_list_friends', {
@@ -19,11 +19,7 @@ export function registerFriendTools(server, client) {
19
19
  confirm: schemaConfirm,
20
20
  },
21
21
  }, async ({ user_email, user_first_name, user_last_name, confirm }) => {
22
- const body = { user_email };
23
- if (user_first_name !== undefined)
24
- body.user_first_name = user_first_name;
25
- if (user_last_name !== undefined)
26
- body.user_last_name = user_last_name;
22
+ const body = pruneUndefined({ user_email, user_first_name, user_last_name });
27
23
  const gate = previewUnlessConfirmed(confirm, `Add ${user_email} as a Splitwise friend`, 'POST', '/create_friend', body);
28
24
  if (gate)
29
25
  return gate;
@@ -1,5 +1,5 @@
1
1
  import { z } from 'zod';
2
- import { textResult } from '@chrischall/mcp-utils';
2
+ import { textResult, pruneUndefined } from '@chrischall/mcp-utils';
3
3
  import { previewUnlessConfirmed, schemaConfirm } from './_confirm.js';
4
4
  export function registerGroupTools(server, client) {
5
5
  server.registerTool('sw_list_groups', {
@@ -29,11 +29,7 @@ export function registerGroupTools(server, client) {
29
29
  confirm: schemaConfirm,
30
30
  },
31
31
  }, async ({ name, group_type, simplify_by_default, confirm }) => {
32
- const body = { name };
33
- if (group_type !== undefined)
34
- body.group_type = group_type;
35
- if (simplify_by_default !== undefined)
36
- body.simplify_by_default = simplify_by_default;
32
+ const body = pruneUndefined({ name, group_type, simplify_by_default });
37
33
  const gate = previewUnlessConfirmed(confirm, `Create Splitwise group "${name}"`, 'POST', '/create_group', body);
38
34
  if (gate)
39
35
  return gate;
@@ -0,0 +1,247 @@
1
+ import { z } from 'zod';
2
+ import { textResult, imageResult, resolveOutputDir, writeBinaryOutput, sniffMimeBytes, } from '@chrischall/mcp-utils';
3
+ /** Cap on bytes returned as base64 in the tool result. */
4
+ const MAX_INLINE_BYTES = 4 * 1024 * 1024;
5
+ /** Cap on extracted characters, so a long PDF can't flood the result. */
6
+ const MAX_TEXT_CHARS = 100_000;
7
+ const PDF_MIME = 'application/pdf';
8
+ /** Content types that carry no information about the actual format. */
9
+ const GENERIC_TYPES = new Set(['application/octet-stream', 'binary/octet-stream']);
10
+ /** `%PDF-` magic bytes — `sniffMimeBytes` only knows the image formats. */
11
+ function isPdf(bytes) {
12
+ return bytes.length >= 5 && Buffer.from(bytes.subarray(0, 5)).toString('latin1') === '%PDF-';
13
+ }
14
+ /**
15
+ * Best-effort MIME type for the downloaded bytes.
16
+ *
17
+ * The magic bytes win over the declared Content-Type: the header is a hint an
18
+ * object store often gets wrong (`application/force-download`, `application/
19
+ * x-pdf`, `application/octet-stream`), while the signature describes what the
20
+ * bytes actually are. Sniffing only recognises the handful of formats a
21
+ * receipt arrives in, so anything it can't place falls back to what the server
22
+ * claimed — and a claim of nothing-in-particular falls back to undefined.
23
+ *
24
+ * Everything downstream reads this one value, so getting it right here is what
25
+ * keeps the file extension, the inline block's type, and the text-extraction
26
+ * gate agreeing with each other.
27
+ */
28
+ function resolveMimeType(contentType, bytes) {
29
+ const sniffed = sniffMimeBytes(bytes) ?? (isPdf(bytes) ? PDF_MIME : undefined);
30
+ if (sniffed)
31
+ return sniffed;
32
+ const declared = (contentType ?? '').split(';')[0].trim().toLowerCase();
33
+ return declared && !GENERIC_TYPES.has(declared) ? declared : undefined;
34
+ }
35
+ /** Extension from a URL path (`…/receipt.jpg?sig=…` → `jpg`), when it has one. */
36
+ function extensionFromUrl(url) {
37
+ const match = /\.([a-z0-9]{1,5})$/i.exec(new URL(url).pathname);
38
+ return match ? match[1].toLowerCase() : undefined;
39
+ }
40
+ /**
41
+ * File extension for the receipt: the MIME subtype when it looks like a plain
42
+ * one (`application/pdf` → `pdf`, `image/heic` → `heic`), else the URL's own
43
+ * extension, else `bin`.
44
+ */
45
+ function extensionFor(mimeType, url) {
46
+ const subtype = mimeType?.split('/')[1];
47
+ if (subtype === 'jpeg')
48
+ return 'jpg';
49
+ if (subtype && /^[a-z0-9]{1,5}$/.test(subtype))
50
+ return subtype;
51
+ return extensionFromUrl(url) ?? 'bin';
52
+ }
53
+ /**
54
+ * Extract a PDF's text layer. `unpdf` is imported lazily so the cost lands only
55
+ * on calls that ask for text — it is a sizable pdf.js build, and every other
56
+ * receipt path (download, inline, write) must not pay for it at startup.
57
+ *
58
+ * Returns `''` for a PDF with no text layer, which is the normal case for a
59
+ * photographed or scanned receipt.
60
+ */
61
+ async function extractPdfText(bytes) {
62
+ const { extractText, getDocumentProxy } = await import('unpdf');
63
+ // Copy: pdf.js transfers/detaches the buffer it is handed, and the caller
64
+ // still needs these bytes for the base64 and the file write.
65
+ const doc = await getDocumentProxy(Uint8Array.from(bytes));
66
+ const { text } = await extractText(doc, { mergePages: true });
67
+ return (Array.isArray(text) ? text.join('\n') : text).trim();
68
+ }
69
+ /** An MCP embedded-resource block — how non-image bytes ride back in a result. */
70
+ function resourceBlock(uri, mimeType, base64) {
71
+ return { type: 'resource', resource: { uri, mimeType, blob: base64 } };
72
+ }
73
+ function describeError(err) {
74
+ return err instanceof Error ? err.message : String(err);
75
+ }
76
+ export function registerReceiptTools(server, client) {
77
+ server.registerTool('sw_get_receipt', {
78
+ description: "Download the receipt image or PDF attached to a Splitwise expense. The receipt URLs returned by sw_get_expense need the server's credentials — fetching them directly returns 401 — so use this tool instead. Set inline:true to get the bytes back in the result (images AND PDFs), or extract_text:true to get a PDF's text without the binary at all — both work when the caller can't see this server's filesystem. It also writes the file and returns the path, unless write:false or the filesystem is read-only.",
79
+ // Not read-only: this writes a file into a caller-supplied directory, and
80
+ // `readOnlyHint` is what a host reads when deciding to skip its approval
81
+ // prompt. Not destructive either — `uniquePath` always picks a filename
82
+ // that doesn't exist, so an existing file is never touched.
83
+ annotations: { readOnlyHint: false, destructiveHint: false },
84
+ inputSchema: {
85
+ id: z.number().describe('Expense ID (the same id sw_get_expense takes)'),
86
+ size: z
87
+ .enum(['original', 'large'])
88
+ .describe("Which stored rendition to fetch. Defaults to 'original' (full quality, and the only one a PDF receipt has). Falls back to the other rendition when the requested one is absent.")
89
+ .optional(),
90
+ inline: z
91
+ .boolean()
92
+ .describe(`Return the receipt bytes in the result — an image block for images, an embedded resource for PDFs and everything else. Only for receipts under ${MAX_INLINE_BYTES} bytes.`)
93
+ .optional(),
94
+ extract_text: z
95
+ .boolean()
96
+ .describe("Also return the PDF's text layer as `text`. Ideal for looking up line items or totals without transferring the file. PDFs only; a scanned receipt has no text layer to extract.")
97
+ .optional(),
98
+ output_dir: z
99
+ .string()
100
+ .describe('Directory to write the receipt into. Defaults to $SPLITWISE_OUTPUT_DIR, else the current working directory.')
101
+ .optional(),
102
+ write: z
103
+ .boolean()
104
+ .describe('Write the receipt to disk. Defaults to true. Pass false when the caller cannot reach this server\'s filesystem, or when the server runs read-only.')
105
+ .optional(),
106
+ },
107
+ }, async ({ id, size, inline, extract_text, output_dir, write }) => {
108
+ const wanted = size ?? 'original';
109
+ const fallback = wanted === 'original' ? 'large' : 'original';
110
+ const expense = await client.request('GET', `/get_expense/${id}`);
111
+ const receipt = expense?.expense?.receipt;
112
+ const url = receipt?.[wanted] || receipt?.[fallback];
113
+ if (!url) {
114
+ throw new Error(`Splitwise expense ${id} has no receipt attached.`);
115
+ }
116
+ const served = receipt?.[wanted] ? wanted : fallback;
117
+ const asset = await client.fetchAsset(url);
118
+ if (asset.bytes.length === 0) {
119
+ throw new Error(`Splitwise returned an empty receipt body for expense ${id}.`);
120
+ }
121
+ const mimeType = resolveMimeType(asset.contentType, asset.bytes);
122
+ const base64 = Buffer.from(asset.bytes).toString('base64');
123
+ // The write is best-effort: a hosted server often runs on a read-only
124
+ // filesystem, and failing the whole call there would strand a caller who
125
+ // only wanted the content. Report it and carry on — unless nothing else
126
+ // was requested, in which case there is no result to return (below).
127
+ let path;
128
+ let writeError;
129
+ if (write !== false) {
130
+ try {
131
+ path = writeBinaryOutput({
132
+ dir: resolveOutputDir(output_dir, 'SPLITWISE_OUTPUT_DIR'),
133
+ baseName: `splitwise-receipt-${id}`,
134
+ base64,
135
+ extension: extensionFor(mimeType, url),
136
+ });
137
+ }
138
+ catch (err) {
139
+ writeError = describeError(err);
140
+ }
141
+ }
142
+ const oversized = asset.bytes.length > MAX_INLINE_BYTES;
143
+ const inlined = inline === true && !oversized;
144
+ // Diagnosis and suggestion are kept apart: the diagnosis is always true,
145
+ // while the suggestion is only worth making if that route is still open.
146
+ // Folding them into one string is how a caller ends up being told to use a
147
+ // flag they already passed.
148
+ let text;
149
+ let textDiagnosis;
150
+ let textSuggestion;
151
+ if (extract_text === true) {
152
+ if (mimeType !== PDF_MIME) {
153
+ textDiagnosis = `Text extraction covers PDFs only; this receipt is ${mimeType ?? 'of an unknown type'}.`;
154
+ textSuggestion = 'Use inline:true to get the bytes.';
155
+ }
156
+ else {
157
+ try {
158
+ const extracted = await extractPdfText(asset.bytes);
159
+ if (extracted.length === 0) {
160
+ textDiagnosis = 'This PDF has no text layer — it is probably a scan or photo.';
161
+ textSuggestion = 'Use inline:true to read the receipt itself.';
162
+ }
163
+ else if (extracted.length > MAX_TEXT_CHARS) {
164
+ text = extracted.slice(0, MAX_TEXT_CHARS);
165
+ textDiagnosis = `Text truncated to the first ${MAX_TEXT_CHARS} characters.`;
166
+ }
167
+ else {
168
+ text = extracted;
169
+ }
170
+ }
171
+ catch (err) {
172
+ textDiagnosis = `Could not extract text: ${describeError(err)}.`;
173
+ textSuggestion = 'Use inline:true to get the bytes.';
174
+ }
175
+ }
176
+ }
177
+ // Gate the suggestion on the FLAG, not the outcome — the same test the
178
+ // throw path's `routes` uses below. `inlined` conflates "never asked for
179
+ // inline" with "asked, and it was refused for size"; suggesting it in the
180
+ // second case contradicts the `inline_skipped` note in the same result.
181
+ const textNote = [textDiagnosis, inline === true ? undefined : textSuggestion].filter(Boolean).join(' ') ||
182
+ undefined;
183
+ // Nothing reached the caller: no file, no bytes, no text. Fail loudly and
184
+ // name the way out rather than reporting a success with an empty result.
185
+ if (path === undefined && !inlined && text === undefined) {
186
+ const reason = writeError
187
+ ? `could not be written (${writeError})`
188
+ : 'was not written (write:false)';
189
+ // Name every blocker — more than one can apply at once — then offer only
190
+ // the routes still open. A flag the caller already passed is not a
191
+ // suggestion; when `inline` was requested it can only have been rejected
192
+ // for size, so it never belongs in the list.
193
+ const blockers = [
194
+ textDiagnosis,
195
+ inline === true && oversized
196
+ ? `The receipt is ${asset.bytes.length} bytes, over the ${MAX_INLINE_BYTES}-byte inline limit.`
197
+ : undefined,
198
+ ].filter((b) => b !== undefined);
199
+ const routes = [];
200
+ if (inline !== true)
201
+ routes.push('inline:true for the bytes');
202
+ if (extract_text !== true && mimeType === PDF_MIME)
203
+ routes.push("extract_text:true for the PDF's text");
204
+ const advice = routes.length > 0
205
+ ? `Re-run with ${routes.join(' or ')}.`
206
+ : 'Point output_dir somewhere this server can write.';
207
+ throw new Error([
208
+ `The receipt for expense ${id} ${reason}, and nothing was returned in its place.`,
209
+ ...blockers,
210
+ advice,
211
+ ].join(' '));
212
+ }
213
+ const result = textResult({
214
+ expense_id: id,
215
+ size: served,
216
+ ...(served !== wanted
217
+ ? { requested_size: wanted, note: `Expense ${id} has no ${wanted} rendition; fetched ${served}.` }
218
+ : {}),
219
+ ...(path !== undefined ? { path } : {}),
220
+ ...(writeError !== undefined ? { write_error: writeError } : {}),
221
+ bytes: asset.bytes.length,
222
+ content_type: mimeType ?? 'unknown',
223
+ // The receipt URL itself is a signed capability, so report only its host.
224
+ source_host: new URL(url).host,
225
+ inline: inlined,
226
+ ...(inline === true && oversized
227
+ ? {
228
+ // The write outcome is already known, so don't advise `write:true`
229
+ // to someone who either has the file or just watched the write fail.
230
+ inline_skipped: `Receipt is ${asset.bytes.length} bytes, over the ${MAX_INLINE_BYTES}-byte inline limit — ` +
231
+ (path !== undefined
232
+ ? 'read it from the path above.'
233
+ : 'get it on disk instead, with write:true and an output_dir this server can write to.'),
234
+ }
235
+ : {}),
236
+ ...(text !== undefined ? { text } : {}),
237
+ ...(textNote !== undefined ? { text_note: textNote } : {}),
238
+ });
239
+ if (inlined) {
240
+ const type = mimeType ?? 'application/octet-stream';
241
+ result.content.push(type.startsWith('image/')
242
+ ? imageResult(base64, type).content[0]
243
+ : resourceBlock(`splitwise://expenses/${id}/receipt`, type, base64));
244
+ }
245
+ return result;
246
+ });
247
+ }
@@ -1,5 +1,5 @@
1
1
  import { z } from 'zod';
2
- import { textResult } from '@chrischall/mcp-utils';
2
+ import { textResult, pruneUndefined } from '@chrischall/mcp-utils';
3
3
  import { previewUnlessConfirmed, schemaConfirm } from './_confirm.js';
4
4
  export function registerUserTools(server, client) {
5
5
  server.registerTool('sw_get_current_user', {
@@ -32,19 +32,7 @@ export function registerUserTools(server, client) {
32
32
  confirm: schemaConfirm,
33
33
  },
34
34
  }, async ({ id, first_name, last_name, email, password, locale, default_currency, confirm }) => {
35
- const body = {};
36
- if (first_name !== undefined)
37
- body.first_name = first_name;
38
- if (last_name !== undefined)
39
- body.last_name = last_name;
40
- if (email !== undefined)
41
- body.email = email;
42
- if (password !== undefined)
43
- body.password = password;
44
- if (locale !== undefined)
45
- body.locale = locale;
46
- if (default_currency !== undefined)
47
- body.default_currency = default_currency;
35
+ const body = pruneUndefined({ first_name, last_name, email, password, locale, default_currency });
48
36
  // Never echo the password in the dry-run preview.
49
37
  const previewBody = { ...body, ...(password !== undefined ? { password: '[hidden]' } : {}) };
50
38
  const gate = previewUnlessConfirmed(confirm, `Update current Splitwise user ${id} profile`, 'POST', `/update_user/${id}`, previewBody);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "splitwise-mcp",
3
- "version": "2.1.6",
3
+ "version": "2.2.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>",
@@ -46,6 +46,7 @@
46
46
  "@chrischall/mcp-utils": "^0.14.0",
47
47
  "@modelcontextprotocol/sdk": "^1.29.0",
48
48
  "dotenv": "^17.4.0",
49
+ "unpdf": "^1.8.1",
49
50
  "zod": "^4.4.2"
50
51
  },
51
52
  "devDependencies": {
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": "2.1.6",
9
+ "version": "2.2.1",
10
10
  "packages": [
11
11
  {
12
12
  "registryType": "npm",
13
13
  "identifier": "splitwise-mcp",
14
- "version": "2.1.6",
14
+ "version": "2.2.1",
15
15
  "transport": {
16
16
  "type": "stdio"
17
17
  },
@@ -22,6 +22,13 @@
22
22
  "isRequired": true,
23
23
  "format": "string",
24
24
  "isSecret": true
25
+ },
26
+ {
27
+ "name": "SPLITWISE_OUTPUT_DIR",
28
+ "description": "Directory sw_get_receipt writes downloaded receipts into (default: the working directory)",
29
+ "isRequired": false,
30
+ "format": "string",
31
+ "isSecret": false
25
32
  }
26
33
  ]
27
34
  }
@@ -95,6 +95,12 @@ API key auth — no login flow or token rotation. The key is attached to every r
95
95
  | `sw_create_expense(group_id, description, cost, split_equally? \| users?)` | Create an expense — equal split or custom per-person split |
96
96
  | `sw_update_expense(expense_id, ...)` | Edit an existing expense (custom split requires full `users` array) |
97
97
  | `sw_delete_expense(id)` | Soft-delete an expense |
98
+ | `sw_undelete_expense(id)` | Restore a soft-deleted expense |
99
+
100
+ ### Receipts
101
+ | Tool | Description |
102
+ |------|-------------|
103
+ | `sw_get_receipt(id, size?, inline?, extract_text?, output_dir?, write?)` | Download the receipt attached to an expense. `inline: true` returns the bytes in the result (images **and** PDFs); `extract_text: true` returns a PDF's text layer; by default it also writes the file and returns the path |
98
104
 
99
105
  ### Utilities
100
106
  | Tool | Description |
@@ -128,6 +134,14 @@ sw_create_expense(group_id, "Hotel", "200.00", users: [
128
134
  ])
129
135
  ```
130
136
 
137
+ **Get the receipt for an expense:**
138
+ ```
139
+ sw_list_expenses(...) → find expense ID
140
+ sw_get_receipt(id, extract_text: true) → line items and totals as text
141
+ sw_get_receipt(id, inline: true) → the actual bytes, when you need to see it
142
+ sw_get_receipt(id) → writes e.g. ./splitwise-receipt-4644814211.pdf
143
+ ```
144
+
131
145
  **Search and edit an expense:**
132
146
  ```
133
147
  sw_list_expenses(group_id, dated_after: "2026-01-01") → find expense ID
@@ -139,5 +153,9 @@ sw_update_expense(expense_id, description: "Corrected description", cost: "95.00
139
153
  - `cost` is always a decimal string (e.g. `"25.00"`)
140
154
  - `split_equally: true` and `users` array are mutually exclusive
141
155
  - For custom split updates, the **full `users` array is required** — the API replaces the entire split
142
- - `sw_delete_expense` is a soft delete; restoration requires the Splitwise web app
156
+ - `sw_delete_expense` is a soft delete — restore with `sw_undelete_expense`
157
+ - The `receipt.original` / `receipt.large` URLs on an expense are **not public** — fetching them without the API key returns 401. Always use `sw_get_receipt`, which fetches them with the server's own credentials
158
+ - `sw_get_receipt` writes into `output_dir`, else `$SPLITWISE_OUTPUT_DIR`, else the working directory, and never overwrites an existing file
159
+ - That path is on the **server's** filesystem. If you can't read it — a hosted or containerised server — use `inline: true` (bytes) or `extract_text: true` (PDF text) instead of the path
160
+ - `extract_text` only works on PDFs, and only when the PDF has a text layer; a scanned or photographed receipt returns `text_note` instead, and needs `inline: true` to read
143
161
  - API default for `sw_list_expenses` is 20 results when `limit` is omitted