splitwise-mcp 2.1.6 → 2.2.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.
@@ -7,7 +7,7 @@
7
7
  },
8
8
  "metadata": {
9
9
  "description": "MCP server for Splitwise — natural-language expense and group management via the Splitwise API",
10
- "version": "2.1.6"
10
+ "version": "2.2.0"
11
11
  },
12
12
  "plugins": [
13
13
  {
@@ -15,7 +15,7 @@
15
15
  "displayName": "Splitwise",
16
16
  "source": "./",
17
17
  "description": "MCP server for Splitwise — manage expenses, groups, and friends via natural language",
18
- "version": "2.1.6",
18
+ "version": "2.2.0",
19
19
  "author": {
20
20
  "name": "Chris Hall"
21
21
  },
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "splitwise-mcp",
3
3
  "displayName": "Splitwise",
4
- "version": "2.1.6",
4
+ "version": "2.2.0",
5
5
  "description": "MCP server for Splitwise — natural-language expense and group management via the Splitwise API",
6
6
  "author": {
7
7
  "name": "Chris Hall",
package/.mcp.json CHANGED
@@ -2,9 +2,12 @@
2
2
  "mcpServers": {
3
3
  "splitwise": {
4
4
  "command": "node",
5
- "args": ["${CLAUDE_PLUGIN_ROOT}/dist/index.js"],
5
+ "args": [
6
+ "${CLAUDE_PLUGIN_ROOT}/dist/index.js"
7
+ ],
6
8
  "env": {
7
- "SPLITWISE_API_KEY": "${SPLITWISE_API_KEY}"
9
+ "SPLITWISE_API_KEY": "${SPLITWISE_API_KEY}",
10
+ "SPLITWISE_OUTPUT_DIR": "${SPLITWISE_OUTPUT_DIR}"
8
11
  }
9
12
  }
10
13
  }
package/README.md CHANGED
@@ -20,6 +20,7 @@ Ask Claude things like:
20
20
  - *"Add Meredith to the household group"*
21
21
  - *"Show me recent expenses"*
22
22
  - *"Delete that duplicate expense"*
23
+ - *"Download the receipt from that hotel expense"*
23
24
 
24
25
  ## Requirements
25
26
 
@@ -112,10 +113,11 @@ Add to Claude Desktop config:
112
113
  | Env var | Required | Notes |
113
114
  |---------|----------|-------|
114
115
  | `SPLITWISE_API_KEY` | Yes | API key from [splitwise.com/apps/register](https://secure.splitwise.com/apps/register) |
116
+ | `SPLITWISE_OUTPUT_DIR` | No | Where `sw_get_receipt` writes downloaded receipts. Defaults to the current working directory. |
115
117
 
116
118
  ## Available tools
117
119
 
118
- 20 tools across 5 domains. All tools are prefixed `sw_`.
120
+ 26 tools across 6 domains. All tools are prefixed `sw_`.
119
121
 
120
122
  ### User
121
123
 
@@ -123,6 +125,7 @@ Add to Claude Desktop config:
123
125
  |------|-------------|
124
126
  | `sw_get_current_user` | Get the authenticated user's profile |
125
127
  | `sw_get_user` | Get another user's profile by ID |
128
+ | `sw_update_user` | Update the current user's profile fields |
126
129
 
127
130
  ### Groups
128
131
 
@@ -158,6 +161,12 @@ Add to Claude Desktop config:
158
161
  | `sw_create_comment` | Add a comment to an expense |
159
162
  | `sw_delete_comment` | Delete a comment |
160
163
 
164
+ ### Receipts
165
+
166
+ | Tool | What it does |
167
+ |------|-------------|
168
+ | `sw_get_receipt` | Download an expense's receipt image/PDF with the server's credentials and write it to a file |
169
+
161
170
  ### Utilities
162
171
 
163
172
  | Tool | What it does |
@@ -170,6 +179,8 @@ Add to Claude Desktop config:
170
179
 
171
180
  **"SPLITWISE_API_KEY is required"** -- set the environment variable in your MCP config or a `.env` file.
172
181
 
182
+ **401 when opening a receipt URL** -- the `receipt.original` / `receipt.large` URLs on an expense are not public; they need the API key. Use `sw_get_receipt` instead of fetching them directly.
183
+
173
184
  **429 rate limit** -- Splitwise has undocumented rate limits. Wait a moment and retry.
174
185
 
175
186
  **Tools not appearing in Claude** -- go to **Claude Desktop > Settings > Developer** to see connected servers. Make sure you fully quit and relaunched after editing the config.
@@ -191,8 +202,9 @@ src/
191
202
  user.ts sw_get_current_user, sw_get_user
192
203
  groups.ts group CRUD and membership
193
204
  friends.ts friend list and management
194
- expenses.ts expense CRUD, comments
195
- utilities.ts notifications, categories, currencies
205
+ expenses.ts expense CRUD
206
+ receipts.ts authenticated receipt download
207
+ utilities.ts notifications, categories, currencies, comments
196
208
  ```
197
209
 
198
210
  ## License
package/dist/bundle.js CHANGED
@@ -2984,7 +2984,7 @@ var require_compile = __commonJS({
2984
2984
  const schOrFunc = root.refs[ref];
2985
2985
  if (schOrFunc)
2986
2986
  return schOrFunc;
2987
- let _sch = resolve.call(this, root, ref);
2987
+ let _sch = resolve2.call(this, root, ref);
2988
2988
  if (_sch === void 0) {
2989
2989
  const schema = (_a3 = root.localRefs) === null || _a3 === void 0 ? void 0 : _a3[ref];
2990
2990
  const { schemaId } = this.opts;
@@ -3011,7 +3011,7 @@ var require_compile = __commonJS({
3011
3011
  function sameSchemaEnv(s1, s2) {
3012
3012
  return s1.schema === s2.schema && s1.root === s2.root && s1.baseId === s2.baseId;
3013
3013
  }
3014
- function resolve(root, ref) {
3014
+ function resolve2(root, ref) {
3015
3015
  let sch;
3016
3016
  while (typeof (sch = this.refs[ref]) == "string")
3017
3017
  ref = sch;
@@ -3642,7 +3642,7 @@ var require_fast_uri = __commonJS({
3642
3642
  }
3643
3643
  return uri;
3644
3644
  }
3645
- function resolve(baseURI, relativeURI, options) {
3645
+ function resolve2(baseURI, relativeURI, options) {
3646
3646
  const schemelessOptions = options ? Object.assign({ scheme: "null" }, options) : { scheme: "null" };
3647
3647
  const { parsed: baseParsed, malformedAuthorityOrPort: baseMalformed } = parseWithStatus(baseURI, schemelessOptions);
3648
3648
  const { parsed: relativeParsed, malformedAuthorityOrPort: relativeMalformed } = parseWithStatus(relativeURI, schemelessOptions);
@@ -3926,7 +3926,7 @@ var require_fast_uri = __commonJS({
3926
3926
  var fastUri = {
3927
3927
  SCHEMES,
3928
3928
  normalize,
3929
- resolve,
3929
+ resolve: resolve2,
3930
3930
  resolveComponent,
3931
3931
  equal,
3932
3932
  serialize,
@@ -28873,7 +28873,7 @@ var Protocol = class {
28873
28873
  return;
28874
28874
  }
28875
28875
  const pollInterval = task2.pollInterval ?? this._options?.defaultTaskPollInterval ?? 1e3;
28876
- await new Promise((resolve) => setTimeout(resolve, pollInterval));
28876
+ await new Promise((resolve2) => setTimeout(resolve2, pollInterval));
28877
28877
  options?.signal?.throwIfAborted();
28878
28878
  }
28879
28879
  } catch (error51) {
@@ -28890,7 +28890,7 @@ var Protocol = class {
28890
28890
  */
28891
28891
  request(request, resultSchema, options) {
28892
28892
  const { relatedRequestId, resumptionToken, onresumptiontoken, task, relatedTask } = options ?? {};
28893
- return new Promise((resolve, reject) => {
28893
+ return new Promise((resolve2, reject) => {
28894
28894
  const earlyReject = (error51) => {
28895
28895
  reject(error51);
28896
28896
  };
@@ -28968,7 +28968,7 @@ var Protocol = class {
28968
28968
  if (!parseResult.success) {
28969
28969
  reject(parseResult.error);
28970
28970
  } else {
28971
- resolve(parseResult.data);
28971
+ resolve2(parseResult.data);
28972
28972
  }
28973
28973
  } catch (error51) {
28974
28974
  reject(error51);
@@ -29229,12 +29229,12 @@ var Protocol = class {
29229
29229
  }
29230
29230
  } catch {
29231
29231
  }
29232
- return new Promise((resolve, reject) => {
29232
+ return new Promise((resolve2, reject) => {
29233
29233
  if (signal.aborted) {
29234
29234
  reject(new McpError(ErrorCode.InvalidRequest, "Request cancelled"));
29235
29235
  return;
29236
29236
  }
29237
- const timeoutId = setTimeout(resolve, interval);
29237
+ const timeoutId = setTimeout(resolve2, interval);
29238
29238
  signal.addEventListener("abort", () => {
29239
29239
  clearTimeout(timeoutId);
29240
29240
  reject(new McpError(ErrorCode.InvalidRequest, "Request cancelled"));
@@ -30325,7 +30325,7 @@ var McpServer = class {
30325
30325
  let task = createTaskResult.task;
30326
30326
  const pollInterval = task.pollInterval ?? 5e3;
30327
30327
  while (task.status !== "completed" && task.status !== "failed" && task.status !== "cancelled") {
30328
- await new Promise((resolve) => setTimeout(resolve, pollInterval));
30328
+ await new Promise((resolve2) => setTimeout(resolve2, pollInterval));
30329
30329
  const updatedTask = await extra.taskStore.getTask(taskId);
30330
30330
  if (!updatedTask) {
30331
30331
  throw new McpError(ErrorCode.InternalError, `Task ${taskId} not found during polling`);
@@ -30989,12 +30989,12 @@ var StdioServerTransport = class {
30989
30989
  this.onclose?.();
30990
30990
  }
30991
30991
  send(message) {
30992
- return new Promise((resolve) => {
30992
+ return new Promise((resolve2) => {
30993
30993
  const json2 = serializeMessage(message);
30994
30994
  if (this._stdout.write(json2)) {
30995
- resolve();
30995
+ resolve2();
30996
30996
  } else {
30997
- this._stdout.once("drain", resolve);
30997
+ this._stdout.once("drain", resolve2);
30998
30998
  }
30999
30999
  });
31000
31000
  }
@@ -31090,8 +31090,23 @@ function textResult(data) {
31090
31090
  content: [{ type: "text", text: JSON.stringify(data, null, 2) }]
31091
31091
  };
31092
31092
  }
31093
+ function imageResult(base643, mimeType) {
31094
+ return {
31095
+ content: [{ type: "image", data: base643, mimeType }]
31096
+ };
31097
+ }
31098
+ function pruneUndefined(obj) {
31099
+ const out = {};
31100
+ for (const key of Object.keys(obj)) {
31101
+ if (obj[key] !== void 0)
31102
+ out[key] = obj[key];
31103
+ }
31104
+ return out;
31105
+ }
31093
31106
 
31094
31107
  // node_modules/@chrischall/mcp-utils/dist/config/index.js
31108
+ import { homedir } from "node:os";
31109
+ import { isAbsolute, join, resolve } from "node:path";
31095
31110
  var PLACEHOLDER_RE = /^\$\{[^}]*\}$/;
31096
31111
  function readEnvVar(key, opts = {}) {
31097
31112
  const env = opts.env ?? process.env;
@@ -31104,6 +31119,15 @@ function readEnvVar(key, opts = {}) {
31104
31119
  }
31105
31120
  return opts.default;
31106
31121
  }
31122
+ function expandPath(p) {
31123
+ let expanded = p;
31124
+ if (p === "~") {
31125
+ expanded = homedir();
31126
+ } else if (p.startsWith("~/")) {
31127
+ expanded = join(homedir(), p.slice(2));
31128
+ }
31129
+ return isAbsolute(expanded) ? expanded : resolve(expanded);
31130
+ }
31107
31131
  async function loadDotenvSafely(opts = {}) {
31108
31132
  try {
31109
31133
  const mod = await import(
@@ -31121,6 +31145,64 @@ async function loadDotenvSafely(opts = {}) {
31121
31145
  }
31122
31146
  }
31123
31147
 
31148
+ // node_modules/@chrischall/mcp-utils/dist/fs/output.js
31149
+ import { existsSync, mkdirSync, writeFileSync } from "node:fs";
31150
+ import { join as join2 } from "node:path";
31151
+ function resolveOutputDir(perCall, envVar, opts = {}) {
31152
+ const raw = perCall ?? readEnvVar(envVar, opts.env ? { env: opts.env } : {});
31153
+ if (!raw)
31154
+ return process.cwd();
31155
+ const dir = expandPath(raw);
31156
+ mkdirSync(dir, { recursive: true });
31157
+ return dir;
31158
+ }
31159
+ function sanitizeBaseName(base) {
31160
+ const safe = base.replace(/[/\\]+/g, "_").replace(/\.\.+/g, "_").replace(/^\.+/, "").trim();
31161
+ return safe.length > 0 ? safe : "file";
31162
+ }
31163
+ function uniquePath(dir, base, ext) {
31164
+ const safe = sanitizeBaseName(base);
31165
+ const first = join2(dir, `${safe}.${ext}`);
31166
+ if (!existsSync(first))
31167
+ return first;
31168
+ for (let n = 2; ; n++) {
31169
+ const candidate = join2(dir, `${safe}-${n}.${ext}`);
31170
+ if (!existsSync(candidate))
31171
+ return candidate;
31172
+ }
31173
+ }
31174
+ var MIME_EXT = {
31175
+ "image/png": "png",
31176
+ "image/jpeg": "jpg",
31177
+ "image/webp": "webp",
31178
+ "image/gif": "gif",
31179
+ "application/pdf": "pdf",
31180
+ "application/json": "json"
31181
+ };
31182
+ function writeBinaryOutput(opts) {
31183
+ const ext = opts.extension ?? (opts.mimeType ? MIME_EXT[opts.mimeType] : void 0) ?? "bin";
31184
+ mkdirSync(opts.dir, { recursive: true });
31185
+ const path = uniquePath(opts.dir, opts.baseName, ext);
31186
+ writeFileSync(path, Buffer.from(opts.base64, "base64"));
31187
+ return path;
31188
+ }
31189
+ function sniffMimeBytes(bytes) {
31190
+ const buf = Buffer.from(bytes.buffer, bytes.byteOffset, bytes.byteLength);
31191
+ if (buf.length >= 8 && buf.subarray(0, 8).equals(Buffer.from([137, 80, 78, 71, 13, 10, 26, 10]))) {
31192
+ return "image/png";
31193
+ }
31194
+ if (buf.length >= 3 && buf[0] === 255 && buf[1] === 216 && buf[2] === 255) {
31195
+ return "image/jpeg";
31196
+ }
31197
+ if (buf.length >= 12 && buf.subarray(0, 4).toString("latin1") === "RIFF" && buf.subarray(8, 12).toString("latin1") === "WEBP") {
31198
+ return "image/webp";
31199
+ }
31200
+ if (buf.length >= 4 && buf.subarray(0, 4).toString("latin1") === "GIF8") {
31201
+ return "image/gif";
31202
+ }
31203
+ return void 0;
31204
+ }
31205
+
31124
31206
  // node_modules/@chrischall/mcp-utils/dist/http/net-atoms.js
31125
31207
  function parseRetryAfterMs(header, opts = {}) {
31126
31208
  const defaultMs = opts.defaultMs ?? 2e3;
@@ -31328,19 +31410,44 @@ var pageSchema = {
31328
31410
  };
31329
31411
 
31330
31412
  // src/client.ts
31331
- import { dirname, join } from "path";
31413
+ import { dirname, join as join3 } from "path";
31332
31414
  import { fileURLToPath } from "url";
31333
31415
  try {
31334
31416
  const dir = dirname(fileURLToPath(import.meta.url));
31335
- await loadDotenvSafely({ path: join(dir, "..", ".env"), override: false });
31417
+ await loadDotenvSafely({ path: join3(dir, "..", ".env"), override: false });
31336
31418
  } catch {
31337
31419
  }
31338
31420
  var BASE_URL = "https://secure.splitwise.com/api/v3.0";
31339
31421
  var SERVICE_NAME = "Splitwise";
31422
+ var SPLITWISE_DOMAIN = "splitwise.com";
31423
+ function isSplitwiseHost(hostname3) {
31424
+ const host = hostname3.toLowerCase();
31425
+ return host === SPLITWISE_DOMAIN || host.endsWith(`.${SPLITWISE_DOMAIN}`);
31426
+ }
31427
+ function parseAssetUrl(url2) {
31428
+ let parsed;
31429
+ try {
31430
+ parsed = new URL(url2);
31431
+ } catch {
31432
+ throw new Error("Splitwise returned an asset URL that could not be parsed");
31433
+ }
31434
+ if (parsed.protocol !== "https:") {
31435
+ throw new Error(`Refusing to fetch an asset over ${parsed.protocol}// \u2014 https only`);
31436
+ }
31437
+ return parsed;
31438
+ }
31439
+ function redactAssetQuery(err, search) {
31440
+ if (!(err instanceof Error) || !search) return err;
31441
+ const message = err.message.split(search).join("?<redacted>");
31442
+ if (message === err.message) return err;
31443
+ return err instanceof ApiError ? new ApiError(err.status, message) : new Error(message);
31444
+ }
31340
31445
  var SplitwiseClient = class {
31341
31446
  apiKey;
31342
31447
  configError;
31343
31448
  api;
31449
+ /** Per-origin clients for asset hosts, built on first use by `fetchAsset`. */
31450
+ assetClients = /* @__PURE__ */ new Map();
31344
31451
  /**
31345
31452
  * Defer the config error so the server can still start (and respond to the
31346
31453
  * host's install-time smoke test) when SPLITWISE_API_KEY isn't set yet.
@@ -31360,14 +31467,26 @@ var SplitwiseClient = class {
31360
31467
  this.apiKey = key;
31361
31468
  this.configError = null;
31362
31469
  }
31363
- this.api = createApiClient({
31364
- baseUrl: BASE_URL,
31365
- getToken: () => this.requireKey(),
31366
- serviceName: SERVICE_NAME,
31470
+ this.api = this.buildApiClient(BASE_URL, true);
31471
+ }
31472
+ /**
31473
+ * One API client per origin, sharing the fleet's retry/timeout/error policy.
31474
+ * `authenticated` decides whether the API key is attached: `getToken` defers
31475
+ * config errors to request time, and on* handlers preserve Splitwise's
31476
+ * documented 401/429 messages.
31477
+ */
31478
+ buildApiClient(baseUrl, authenticated) {
31479
+ const service = authenticated ? SERVICE_NAME : new URL(baseUrl).host;
31480
+ return createApiClient({
31481
+ baseUrl,
31482
+ serviceName: service,
31367
31483
  retry: { count: 1, delayMs: 2e3 },
31368
31484
  timeout: 3e4,
31369
- onUnauthorized: () => new Error("SPLITWISE_API_KEY is invalid or missing"),
31370
- onRateLimited: () => new Error("Rate limited by Splitwise API")
31485
+ ...authenticated ? { getToken: () => this.requireKey() } : {},
31486
+ onUnauthorized: () => new Error(
31487
+ authenticated ? "SPLITWISE_API_KEY is invalid or missing" : "The asset URL was rejected (401) \u2014 presigned URLs are short-lived, so re-run to get a fresh one"
31488
+ ),
31489
+ onRateLimited: () => new Error(`Rate limited by ${authenticated ? "Splitwise API" : service}`)
31371
31490
  });
31372
31491
  }
31373
31492
  requireKey() {
@@ -31377,6 +31496,31 @@ var SplitwiseClient = class {
31377
31496
  async request(method, path, body) {
31378
31497
  return this.api.fetchJson(method, path, body !== void 0 ? { body } : {});
31379
31498
  }
31499
+ /**
31500
+ * Fetch the raw bytes behind an absolute asset URL the API handed back (an
31501
+ * expense receipt). Splitwise serves receipts two ways and this covers both:
31502
+ * from its own API (`https://www.splitwise.com/api/v4.0/expenses/{id}/receipt`),
31503
+ * which 401s without the key, and from a presigned S3 URL, which carries its
31504
+ * own signature and must NOT be sent the key. `isSplitwiseHost` picks.
31505
+ *
31506
+ * A cross-origin redirect (API → S3) is safe: per the Fetch standard the
31507
+ * `Authorization` header is dropped when a redirect changes origin.
31508
+ */
31509
+ async fetchAsset(url2) {
31510
+ const target = parseAssetUrl(url2);
31511
+ const authenticated = isSplitwiseHost(target.hostname);
31512
+ const cacheKey = `${target.origin}|${authenticated ? "auth" : "anon"}`;
31513
+ let api = this.assetClients.get(cacheKey);
31514
+ if (!api) {
31515
+ api = this.buildApiClient(target.origin, authenticated);
31516
+ this.assetClients.set(cacheKey, api);
31517
+ }
31518
+ try {
31519
+ return await api.fetchRaw("GET", `${target.pathname}${target.search}`);
31520
+ } catch (err) {
31521
+ throw redactAssetQuery(err, target.search);
31522
+ }
31523
+ }
31380
31524
  };
31381
31525
  var client = new SplitwiseClient();
31382
31526
 
@@ -31425,13 +31569,7 @@ function registerUserTools(server, client2) {
31425
31569
  confirm: schemaConfirm
31426
31570
  }
31427
31571
  }, async ({ id, first_name, last_name, email: email3, password, locale, default_currency, confirm }) => {
31428
- const body = {};
31429
- if (first_name !== void 0) body.first_name = first_name;
31430
- if (last_name !== void 0) body.last_name = last_name;
31431
- if (email3 !== void 0) body.email = email3;
31432
- if (password !== void 0) body.password = password;
31433
- if (locale !== void 0) body.locale = locale;
31434
- if (default_currency !== void 0) body.default_currency = default_currency;
31572
+ const body = pruneUndefined({ first_name, last_name, email: email3, password, locale, default_currency });
31435
31573
  const previewBody = { ...body, ...password !== void 0 ? { password: "[hidden]" } : {} };
31436
31574
  const gate = previewUnlessConfirmed(confirm, `Update current Splitwise user ${id} profile`, "POST", `/update_user/${id}`, previewBody);
31437
31575
  if (gate) return gate;
@@ -31469,9 +31607,7 @@ function registerGroupTools(server, client2) {
31469
31607
  confirm: schemaConfirm
31470
31608
  }
31471
31609
  }, async ({ name, group_type, simplify_by_default, confirm }) => {
31472
- const body = { name };
31473
- if (group_type !== void 0) body.group_type = group_type;
31474
- if (simplify_by_default !== void 0) body.simplify_by_default = simplify_by_default;
31610
+ const body = pruneUndefined({ name, group_type, simplify_by_default });
31475
31611
  const gate = previewUnlessConfirmed(confirm, `Create Splitwise group "${name}"`, "POST", "/create_group", body);
31476
31612
  if (gate) return gate;
31477
31613
  const data = await client2.request("POST", "/create_group", body);
@@ -31559,9 +31695,7 @@ function registerFriendTools(server, client2) {
31559
31695
  confirm: schemaConfirm
31560
31696
  }
31561
31697
  }, async ({ user_email, user_first_name, user_last_name, confirm }) => {
31562
- const body = { user_email };
31563
- if (user_first_name !== void 0) body.user_first_name = user_first_name;
31564
- if (user_last_name !== void 0) body.user_last_name = user_last_name;
31698
+ const body = pruneUndefined({ user_email, user_first_name, user_last_name });
31565
31699
  const gate = previewUnlessConfirmed(confirm, `Add ${user_email} as a Splitwise friend`, "POST", "/create_friend", body);
31566
31700
  if (gate) return gate;
31567
31701
  const data = await client2.request("POST", "/create_friend", body);
@@ -31715,6 +31849,89 @@ function registerExpenseTools(server, client2) {
31715
31849
  });
31716
31850
  }
31717
31851
 
31852
+ // src/tools/receipts.ts
31853
+ var MAX_INLINE_BYTES = 4 * 1024 * 1024;
31854
+ var GENERIC_TYPES = /* @__PURE__ */ new Set(["application/octet-stream", "binary/octet-stream"]);
31855
+ function isPdf(bytes) {
31856
+ return bytes.length >= 5 && Buffer.from(bytes.subarray(0, 5)).toString("latin1") === "%PDF-";
31857
+ }
31858
+ function resolveMimeType(contentType, bytes) {
31859
+ const declared = (contentType ?? "").split(";")[0].trim().toLowerCase();
31860
+ if (declared && !GENERIC_TYPES.has(declared)) return declared;
31861
+ return sniffMimeBytes(bytes) ?? (isPdf(bytes) ? "application/pdf" : void 0);
31862
+ }
31863
+ function extensionFromUrl(url2) {
31864
+ const match = /\.([a-z0-9]{1,5})$/i.exec(new URL(url2).pathname);
31865
+ return match ? match[1].toLowerCase() : void 0;
31866
+ }
31867
+ function extensionFor(mimeType, url2) {
31868
+ const subtype = mimeType?.split("/")[1];
31869
+ if (subtype === "jpeg") return "jpg";
31870
+ if (subtype && /^[a-z0-9]{1,5}$/.test(subtype)) return subtype;
31871
+ return extensionFromUrl(url2) ?? "bin";
31872
+ }
31873
+ function registerReceiptTools(server, client2) {
31874
+ server.registerTool("sw_get_receipt", {
31875
+ 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 \u2014 fetching them directly returns 401 \u2014 so use this tool instead. It writes the authenticated bytes to a file and returns the path; set inline:true to also get an image receipt back in the result. Files go to output_dir, else $SPLITWISE_OUTPUT_DIR, else the current directory, and an existing file is never overwritten.",
31876
+ // Not read-only: this writes a file into a caller-supplied directory, and
31877
+ // `readOnlyHint` is what a host reads when deciding to skip its approval
31878
+ // prompt. Not destructive either — `uniquePath` always picks a filename
31879
+ // that doesn't exist, so an existing file is never touched.
31880
+ annotations: { readOnlyHint: false, destructiveHint: false },
31881
+ inputSchema: {
31882
+ id: external_exports.number().describe("Expense ID (the same id sw_get_expense takes)"),
31883
+ size: external_exports.enum(["original", "large"]).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.").optional(),
31884
+ output_dir: external_exports.string().describe("Directory to write the receipt into. Defaults to $SPLITWISE_OUTPUT_DIR, else the current working directory.").optional(),
31885
+ inline: external_exports.boolean().describe(`Also return the receipt as a base64 image in the result. Image receipts only, and only under ${MAX_INLINE_BYTES} bytes.`).optional()
31886
+ }
31887
+ }, async ({ id, size, output_dir, inline }) => {
31888
+ const wanted = size ?? "original";
31889
+ const fallback = wanted === "original" ? "large" : "original";
31890
+ const expense = await client2.request(
31891
+ "GET",
31892
+ `/get_expense/${id}`
31893
+ );
31894
+ const receipt = expense?.expense?.receipt;
31895
+ const url2 = receipt?.[wanted] || receipt?.[fallback];
31896
+ if (!url2) {
31897
+ throw new Error(`Splitwise expense ${id} has no receipt attached.`);
31898
+ }
31899
+ const served = receipt?.[wanted] ? wanted : fallback;
31900
+ const asset = await client2.fetchAsset(url2);
31901
+ if (asset.bytes.length === 0) {
31902
+ throw new Error(`Splitwise returned an empty receipt body for expense ${id}.`);
31903
+ }
31904
+ const mimeType = resolveMimeType(asset.contentType, asset.bytes);
31905
+ const base643 = Buffer.from(asset.bytes).toString("base64");
31906
+ const path = writeBinaryOutput({
31907
+ dir: resolveOutputDir(output_dir, "SPLITWISE_OUTPUT_DIR"),
31908
+ baseName: `splitwise-receipt-${id}`,
31909
+ base64: base643,
31910
+ extension: extensionFor(mimeType, url2)
31911
+ });
31912
+ const imageMime = mimeType?.startsWith("image/") ? mimeType : void 0;
31913
+ const inlined = inline === true && imageMime !== void 0 && asset.bytes.length <= MAX_INLINE_BYTES;
31914
+ const result = textResult({
31915
+ expense_id: id,
31916
+ size: served,
31917
+ ...served !== wanted ? { requested_size: wanted, note: `Expense ${id} has no ${wanted} rendition; fetched ${served}.` } : {},
31918
+ path,
31919
+ bytes: asset.bytes.length,
31920
+ content_type: mimeType ?? "unknown",
31921
+ // The receipt URL itself is a signed capability, so report only its host.
31922
+ source_host: new URL(url2).host,
31923
+ inline: inlined,
31924
+ ...inline === true && !inlined ? {
31925
+ inline_skipped: imageMime ? `Receipt is ${asset.bytes.length} bytes, over the ${MAX_INLINE_BYTES}-byte inline limit \u2014 open the file at the path above.` : "Only image receipts can be returned inline \u2014 open the file at the path above."
31926
+ } : {}
31927
+ });
31928
+ if (inlined && imageMime) {
31929
+ result.content.push(...imageResult(base643, imageMime).content);
31930
+ }
31931
+ return result;
31932
+ });
31933
+ }
31934
+
31718
31935
  // src/tools/utilities.ts
31719
31936
  function registerUtilityTools(server, client2) {
31720
31937
  server.registerTool("sw_get_notifications", {
@@ -31780,7 +31997,7 @@ function registerUtilityTools(server, client2) {
31780
31997
  // src/index.ts
31781
31998
  await runMcp({
31782
31999
  name: "splitwise-mcp",
31783
- version: "2.1.6",
32000
+ version: "2.2.0",
31784
32001
  // x-release-please-version
31785
32002
  deps: client,
31786
32003
  banner: "[splitwise-mcp] This project was developed and is maintained by AI (Claude Sonnet 4.6). Use at your own discretion.",
@@ -31789,6 +32006,7 @@ await runMcp({
31789
32006
  registerGroupTools,
31790
32007
  registerFriendTools,
31791
32008
  registerExpenseTools,
32009
+ registerReceiptTools,
31792
32010
  registerUtilityTools
31793
32011
  ]
31794
32012
  });
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.0', // 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,111 @@
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; the file is written regardless. */
4
+ const MAX_INLINE_BYTES = 4 * 1024 * 1024;
5
+ /** Content types that carry no information about the actual format. */
6
+ const GENERIC_TYPES = new Set(['application/octet-stream', 'binary/octet-stream']);
7
+ /** `%PDF-` magic bytes — `sniffMimeBytes` only knows the image formats. */
8
+ function isPdf(bytes) {
9
+ return bytes.length >= 5 && Buffer.from(bytes.subarray(0, 5)).toString('latin1') === '%PDF-';
10
+ }
11
+ /**
12
+ * Best-effort MIME type for the downloaded bytes: the response's Content-Type
13
+ * when it says something useful, else the magic bytes, else undefined.
14
+ */
15
+ function resolveMimeType(contentType, bytes) {
16
+ const declared = (contentType ?? '').split(';')[0].trim().toLowerCase();
17
+ if (declared && !GENERIC_TYPES.has(declared))
18
+ return declared;
19
+ return sniffMimeBytes(bytes) ?? (isPdf(bytes) ? 'application/pdf' : undefined);
20
+ }
21
+ /** Extension from a URL path (`…/receipt.jpg?sig=…` → `jpg`), when it has one. */
22
+ function extensionFromUrl(url) {
23
+ const match = /\.([a-z0-9]{1,5})$/i.exec(new URL(url).pathname);
24
+ return match ? match[1].toLowerCase() : undefined;
25
+ }
26
+ /**
27
+ * File extension for the receipt: the MIME subtype when it looks like a plain
28
+ * one (`application/pdf` → `pdf`, `image/heic` → `heic`), else the URL's own
29
+ * extension, else `bin`.
30
+ */
31
+ function extensionFor(mimeType, url) {
32
+ const subtype = mimeType?.split('/')[1];
33
+ if (subtype === 'jpeg')
34
+ return 'jpg';
35
+ if (subtype && /^[a-z0-9]{1,5}$/.test(subtype))
36
+ return subtype;
37
+ return extensionFromUrl(url) ?? 'bin';
38
+ }
39
+ export function registerReceiptTools(server, client) {
40
+ server.registerTool('sw_get_receipt', {
41
+ 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. It writes the authenticated bytes to a file and returns the path; set inline:true to also get an image receipt back in the result. Files go to output_dir, else $SPLITWISE_OUTPUT_DIR, else the current directory, and an existing file is never overwritten.",
42
+ // Not read-only: this writes a file into a caller-supplied directory, and
43
+ // `readOnlyHint` is what a host reads when deciding to skip its approval
44
+ // prompt. Not destructive either — `uniquePath` always picks a filename
45
+ // that doesn't exist, so an existing file is never touched.
46
+ annotations: { readOnlyHint: false, destructiveHint: false },
47
+ inputSchema: {
48
+ id: z.number().describe('Expense ID (the same id sw_get_expense takes)'),
49
+ size: z
50
+ .enum(['original', 'large'])
51
+ .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.")
52
+ .optional(),
53
+ output_dir: z
54
+ .string()
55
+ .describe('Directory to write the receipt into. Defaults to $SPLITWISE_OUTPUT_DIR, else the current working directory.')
56
+ .optional(),
57
+ inline: z
58
+ .boolean()
59
+ .describe(`Also return the receipt as a base64 image in the result. Image receipts only, and only under ${MAX_INLINE_BYTES} bytes.`)
60
+ .optional(),
61
+ },
62
+ }, async ({ id, size, output_dir, inline }) => {
63
+ const wanted = size ?? 'original';
64
+ const fallback = wanted === 'original' ? 'large' : 'original';
65
+ const expense = await client.request('GET', `/get_expense/${id}`);
66
+ const receipt = expense?.expense?.receipt;
67
+ const url = receipt?.[wanted] || receipt?.[fallback];
68
+ if (!url) {
69
+ throw new Error(`Splitwise expense ${id} has no receipt attached.`);
70
+ }
71
+ const served = receipt?.[wanted] ? wanted : fallback;
72
+ const asset = await client.fetchAsset(url);
73
+ if (asset.bytes.length === 0) {
74
+ throw new Error(`Splitwise returned an empty receipt body for expense ${id}.`);
75
+ }
76
+ const mimeType = resolveMimeType(asset.contentType, asset.bytes);
77
+ const base64 = Buffer.from(asset.bytes).toString('base64');
78
+ const path = writeBinaryOutput({
79
+ dir: resolveOutputDir(output_dir, 'SPLITWISE_OUTPUT_DIR'),
80
+ baseName: `splitwise-receipt-${id}`,
81
+ base64,
82
+ extension: extensionFor(mimeType, url),
83
+ });
84
+ const imageMime = mimeType?.startsWith('image/') ? mimeType : undefined;
85
+ const inlined = inline === true && imageMime !== undefined && asset.bytes.length <= MAX_INLINE_BYTES;
86
+ const result = textResult({
87
+ expense_id: id,
88
+ size: served,
89
+ ...(served !== wanted
90
+ ? { requested_size: wanted, note: `Expense ${id} has no ${wanted} rendition; fetched ${served}.` }
91
+ : {}),
92
+ path,
93
+ bytes: asset.bytes.length,
94
+ content_type: mimeType ?? 'unknown',
95
+ // The receipt URL itself is a signed capability, so report only its host.
96
+ source_host: new URL(url).host,
97
+ inline: inlined,
98
+ ...(inline === true && !inlined
99
+ ? {
100
+ inline_skipped: imageMime
101
+ ? `Receipt is ${asset.bytes.length} bytes, over the ${MAX_INLINE_BYTES}-byte inline limit — open the file at the path above.`
102
+ : 'Only image receipts can be returned inline — open the file at the path above.',
103
+ }
104
+ : {}),
105
+ });
106
+ if (inlined && imageMime) {
107
+ result.content.push(...imageResult(base64, imageMime).content);
108
+ }
109
+ return result;
110
+ });
111
+ }
@@ -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.0",
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>",
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.0",
10
10
  "packages": [
11
11
  {
12
12
  "registryType": "npm",
13
13
  "identifier": "splitwise-mcp",
14
- "version": "2.1.6",
14
+ "version": "2.2.0",
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?, output_dir?, inline?)` | Download the receipt attached to an expense and write it to a file. Returns the path, byte count, and content type; `inline: true` also returns an image receipt in the result |
98
104
 
99
105
  ### Utilities
100
106
  | Tool | Description |
@@ -128,6 +134,12 @@ 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) → writes e.g. ./splitwise-receipt-4644814211.pdf
141
+ ```
142
+
131
143
  **Search and edit an expense:**
132
144
  ```
133
145
  sw_list_expenses(group_id, dated_after: "2026-01-01") → find expense ID
@@ -139,5 +151,7 @@ sw_update_expense(expense_id, description: "Corrected description", cost: "95.00
139
151
  - `cost` is always a decimal string (e.g. `"25.00"`)
140
152
  - `split_equally: true` and `users` array are mutually exclusive
141
153
  - 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
154
+ - `sw_delete_expense` is a soft delete — restore with `sw_undelete_expense`
155
+ - 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
156
+ - `sw_get_receipt` writes into `output_dir`, else `$SPLITWISE_OUTPUT_DIR`, else the working directory, and never overwrites an existing file
143
157
  - API default for `sw_list_expenses` is 20 results when `limit` is omitted