splitwise-mcp 2.1.5 → 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.5"
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.5",
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.5",
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,9 +3642,14 @@ 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
- const resolved = resolveComponent(parse3(baseURI, schemelessOptions), parse3(relativeURI, schemelessOptions), schemelessOptions, true);
3647
+ const { parsed: baseParsed, malformedAuthorityOrPort: baseMalformed } = parseWithStatus(baseURI, schemelessOptions);
3648
+ const { parsed: relativeParsed, malformedAuthorityOrPort: relativeMalformed } = parseWithStatus(relativeURI, schemelessOptions);
3649
+ if (baseMalformed || relativeMalformed) {
3650
+ throw new Error(baseParsed.error || relativeParsed.error || "URI is malformed.");
3651
+ }
3652
+ const resolved = resolveComponent(baseParsed, relativeParsed, schemelessOptions, true);
3648
3653
  schemelessOptions.skipEscape = true;
3649
3654
  return serialize(resolved, schemelessOptions);
3650
3655
  }
@@ -3770,6 +3775,7 @@ var require_fast_uri = __commonJS({
3770
3775
  }
3771
3776
  var URI_PARSE = /^(?:([^#/:?]+):)?(?:\/\/((?:([^#/?@]*)@)?(\[[^#/?\]]+\]|[^#/:?]*)(?::(\d*))?))?([^#?]*)(?:\?([^#]*))?(?:#((?:.|[\n\r])*))?/u;
3772
3777
  var AUTHORITY_PREFIX = /^(?:[^#/:?]+:)?\/\/([^/?#]*)/;
3778
+ var AUTHORITY_INTRODUCER_REGION = /^(?:[^#/:?]+:)?([/\\\t\n\r]*)/;
3773
3779
  function getParseError(parsed, matches) {
3774
3780
  if (matches[2] !== void 0 && parsed.path && parsed.path[0] !== "/") {
3775
3781
  return 'URI path must start with "/" when authority is present.';
@@ -3804,6 +3810,20 @@ var require_fast_uri = __commonJS({
3804
3810
  parsed.error = "URI authority must not contain a literal backslash.";
3805
3811
  malformedAuthorityOrPort = true;
3806
3812
  }
3813
+ const introducerMatch = uri.match(AUTHORITY_INTRODUCER_REGION);
3814
+ if (introducerMatch !== null) {
3815
+ const region = introducerMatch[1];
3816
+ const normalizedRegion = region.replace(/[\t\n\r]/g, "");
3817
+ if (normalizedRegion.length >= 2) {
3818
+ if (normalizedRegion.slice(0, 2) !== "//") {
3819
+ parsed.error = parsed.error || "URI authority must not contain a literal backslash.";
3820
+ malformedAuthorityOrPort = true;
3821
+ } else if (region.length !== normalizedRegion.length) {
3822
+ parsed.error = parsed.error || "URI authority introducer must not contain whitespace.";
3823
+ malformedAuthorityOrPort = true;
3824
+ }
3825
+ }
3826
+ }
3807
3827
  const matches = uri.match(URI_PARSE);
3808
3828
  if (matches) {
3809
3829
  parsed.scheme = matches[1];
@@ -3906,7 +3926,7 @@ var require_fast_uri = __commonJS({
3906
3926
  var fastUri = {
3907
3927
  SCHEMES,
3908
3928
  normalize,
3909
- resolve,
3929
+ resolve: resolve2,
3910
3930
  resolveComponent,
3911
3931
  equal,
3912
3932
  serialize,
@@ -23048,17 +23068,33 @@ function normalizeObjectSchema(schema) {
23048
23068
  }
23049
23069
  return void 0;
23050
23070
  }
23071
+ function getDotPath(path) {
23072
+ if (path.length === 0) {
23073
+ return "object root";
23074
+ }
23075
+ return path.reduce((acc, seg, index) => {
23076
+ if (index === 0) {
23077
+ return String(seg);
23078
+ }
23079
+ if (typeof seg === "number") {
23080
+ return `${acc}[${seg}]`;
23081
+ }
23082
+ return `${acc}.${seg}`;
23083
+ }, "");
23084
+ }
23051
23085
  function getParseErrorMessage(error51) {
23052
23086
  if (error51 && typeof error51 === "object") {
23087
+ if ("issues" in error51 && Array.isArray(error51.issues) && error51.issues.length > 0) {
23088
+ return error51.issues.map((i) => {
23089
+ if (!i.path?.length) {
23090
+ return i.message;
23091
+ }
23092
+ return `${i.message} at ${getDotPath(i.path)}`;
23093
+ }).join("\n");
23094
+ }
23053
23095
  if ("message" in error51 && typeof error51.message === "string") {
23054
23096
  return error51.message;
23055
23097
  }
23056
- if ("issues" in error51 && Array.isArray(error51.issues) && error51.issues.length > 0) {
23057
- const firstIssue = error51.issues[0];
23058
- if (firstIssue && typeof firstIssue === "object" && "message" in firstIssue) {
23059
- return String(firstIssue.message);
23060
- }
23061
- }
23062
23098
  try {
23063
23099
  return JSON.stringify(error51);
23064
23100
  } catch {
@@ -28837,7 +28873,7 @@ var Protocol = class {
28837
28873
  return;
28838
28874
  }
28839
28875
  const pollInterval = task2.pollInterval ?? this._options?.defaultTaskPollInterval ?? 1e3;
28840
- await new Promise((resolve) => setTimeout(resolve, pollInterval));
28876
+ await new Promise((resolve2) => setTimeout(resolve2, pollInterval));
28841
28877
  options?.signal?.throwIfAborted();
28842
28878
  }
28843
28879
  } catch (error51) {
@@ -28854,7 +28890,7 @@ var Protocol = class {
28854
28890
  */
28855
28891
  request(request, resultSchema, options) {
28856
28892
  const { relatedRequestId, resumptionToken, onresumptiontoken, task, relatedTask } = options ?? {};
28857
- return new Promise((resolve, reject) => {
28893
+ return new Promise((resolve2, reject) => {
28858
28894
  const earlyReject = (error51) => {
28859
28895
  reject(error51);
28860
28896
  };
@@ -28932,7 +28968,7 @@ var Protocol = class {
28932
28968
  if (!parseResult.success) {
28933
28969
  reject(parseResult.error);
28934
28970
  } else {
28935
- resolve(parseResult.data);
28971
+ resolve2(parseResult.data);
28936
28972
  }
28937
28973
  } catch (error51) {
28938
28974
  reject(error51);
@@ -29193,12 +29229,12 @@ var Protocol = class {
29193
29229
  }
29194
29230
  } catch {
29195
29231
  }
29196
- return new Promise((resolve, reject) => {
29232
+ return new Promise((resolve2, reject) => {
29197
29233
  if (signal.aborted) {
29198
29234
  reject(new McpError(ErrorCode.InvalidRequest, "Request cancelled"));
29199
29235
  return;
29200
29236
  }
29201
- const timeoutId = setTimeout(resolve, interval);
29237
+ const timeoutId = setTimeout(resolve2, interval);
29202
29238
  signal.addEventListener("abort", () => {
29203
29239
  clearTimeout(timeoutId);
29204
29240
  reject(new McpError(ErrorCode.InvalidRequest, "Request cancelled"));
@@ -29673,16 +29709,7 @@ var Server = class extends Protocol {
29673
29709
  if (!methodSchema) {
29674
29710
  throw new Error("Schema is missing a method literal");
29675
29711
  }
29676
- let methodValue;
29677
- if (isZ4Schema(methodSchema)) {
29678
- const v4Schema = methodSchema;
29679
- const v4Def = v4Schema._zod?.def;
29680
- methodValue = v4Def?.value ?? v4Schema.value;
29681
- } else {
29682
- const v3Schema = methodSchema;
29683
- const legacyDef = v3Schema._def;
29684
- methodValue = legacyDef?.value ?? v3Schema.value;
29685
- }
29712
+ const methodValue = getLiteralValue(methodSchema);
29686
29713
  if (typeof methodValue !== "string") {
29687
29714
  throw new Error("Schema method literal must be a string");
29688
29715
  }
@@ -30298,7 +30325,7 @@ var McpServer = class {
30298
30325
  let task = createTaskResult.task;
30299
30326
  const pollInterval = task.pollInterval ?? 5e3;
30300
30327
  while (task.status !== "completed" && task.status !== "failed" && task.status !== "cancelled") {
30301
- await new Promise((resolve) => setTimeout(resolve, pollInterval));
30328
+ await new Promise((resolve2) => setTimeout(resolve2, pollInterval));
30302
30329
  const updatedTask = await extra.taskStore.getTask(taskId);
30303
30330
  if (!updatedTask) {
30304
30331
  throw new McpError(ErrorCode.InternalError, `Task ${taskId} not found during polling`);
@@ -30870,8 +30897,17 @@ var EMPTY_COMPLETION_RESULT = {
30870
30897
  import process3 from "node:process";
30871
30898
 
30872
30899
  // node_modules/@modelcontextprotocol/sdk/dist/esm/shared/stdio.js
30900
+ var STDIO_DEFAULT_MAX_BUFFER_SIZE = 10 * 1024 * 1024;
30873
30901
  var ReadBuffer = class {
30902
+ constructor(options) {
30903
+ this._maxBufferSize = options?.maxBufferSize ?? STDIO_DEFAULT_MAX_BUFFER_SIZE;
30904
+ }
30874
30905
  append(chunk) {
30906
+ const newSize = (this._buffer?.length ?? 0) + chunk.length;
30907
+ if (newSize > this._maxBufferSize) {
30908
+ this.clear();
30909
+ throw new Error(`ReadBuffer exceeded maximum size of ${this._maxBufferSize} bytes`);
30910
+ }
30875
30911
  this._buffer = this._buffer ? Buffer.concat([this._buffer, chunk]) : chunk;
30876
30912
  }
30877
30913
  readMessage() {
@@ -30899,18 +30935,24 @@ function serializeMessage(message) {
30899
30935
 
30900
30936
  // node_modules/@modelcontextprotocol/sdk/dist/esm/server/stdio.js
30901
30937
  var StdioServerTransport = class {
30902
- constructor(_stdin = process3.stdin, _stdout = process3.stdout) {
30938
+ constructor(_stdin = process3.stdin, _stdout = process3.stdout, options) {
30903
30939
  this._stdin = _stdin;
30904
30940
  this._stdout = _stdout;
30905
- this._readBuffer = new ReadBuffer();
30906
30941
  this._started = false;
30907
30942
  this._ondata = (chunk) => {
30908
- this._readBuffer.append(chunk);
30909
- this.processReadBuffer();
30943
+ try {
30944
+ this._readBuffer.append(chunk);
30945
+ this.processReadBuffer();
30946
+ } catch (error51) {
30947
+ this.onerror?.(error51);
30948
+ this.close().catch(() => {
30949
+ });
30950
+ }
30910
30951
  };
30911
30952
  this._onerror = (error51) => {
30912
30953
  this.onerror?.(error51);
30913
30954
  };
30955
+ this._readBuffer = new ReadBuffer({ maxBufferSize: options?.maxBufferSize });
30914
30956
  }
30915
30957
  /**
30916
30958
  * Starts listening for messages on stdin.
@@ -30947,12 +30989,12 @@ var StdioServerTransport = class {
30947
30989
  this.onclose?.();
30948
30990
  }
30949
30991
  send(message) {
30950
- return new Promise((resolve) => {
30992
+ return new Promise((resolve2) => {
30951
30993
  const json2 = serializeMessage(message);
30952
30994
  if (this._stdout.write(json2)) {
30953
- resolve();
30995
+ resolve2();
30954
30996
  } else {
30955
- this._stdout.once("drain", resolve);
30997
+ this._stdout.once("drain", resolve2);
30956
30998
  }
30957
30999
  });
30958
31000
  }
@@ -31048,8 +31090,23 @@ function textResult(data) {
31048
31090
  content: [{ type: "text", text: JSON.stringify(data, null, 2) }]
31049
31091
  };
31050
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
+ }
31051
31106
 
31052
31107
  // node_modules/@chrischall/mcp-utils/dist/config/index.js
31108
+ import { homedir } from "node:os";
31109
+ import { isAbsolute, join, resolve } from "node:path";
31053
31110
  var PLACEHOLDER_RE = /^\$\{[^}]*\}$/;
31054
31111
  function readEnvVar(key, opts = {}) {
31055
31112
  const env = opts.env ?? process.env;
@@ -31062,6 +31119,15 @@ function readEnvVar(key, opts = {}) {
31062
31119
  }
31063
31120
  return opts.default;
31064
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
+ }
31065
31131
  async function loadDotenvSafely(opts = {}) {
31066
31132
  try {
31067
31133
  const mod = await import(
@@ -31079,6 +31145,64 @@ async function loadDotenvSafely(opts = {}) {
31079
31145
  }
31080
31146
  }
31081
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
+
31082
31206
  // node_modules/@chrischall/mcp-utils/dist/http/net-atoms.js
31083
31207
  function parseRetryAfterMs(header, opts = {}) {
31084
31208
  const defaultMs = opts.defaultMs ?? 2e3;
@@ -31286,28 +31410,53 @@ var pageSchema = {
31286
31410
  };
31287
31411
 
31288
31412
  // src/client.ts
31289
- import { dirname, join } from "path";
31413
+ import { dirname, join as join3 } from "path";
31290
31414
  import { fileURLToPath } from "url";
31291
31415
  try {
31292
31416
  const dir = dirname(fileURLToPath(import.meta.url));
31293
- await loadDotenvSafely({ path: join(dir, "..", ".env"), override: false });
31417
+ await loadDotenvSafely({ path: join3(dir, "..", ".env"), override: false });
31294
31418
  } catch {
31295
31419
  }
31296
31420
  var BASE_URL = "https://secure.splitwise.com/api/v3.0";
31297
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
+ }
31298
31445
  var SplitwiseClient = class {
31299
31446
  apiKey;
31300
31447
  configError;
31301
31448
  api;
31449
+ /** Per-origin clients for asset hosts, built on first use by `fetchAsset`. */
31450
+ assetClients = /* @__PURE__ */ new Map();
31302
31451
  /**
31303
31452
  * Defer the config error so the server can still start (and respond to the
31304
31453
  * host's install-time smoke test) when SPLITWISE_API_KEY isn't set yet.
31305
31454
  * Tool calls re-raise the error at request time.
31306
31455
  *
31307
- * Optional constructor seam: a hosted per-user deployment (e.g. a Cloudflare
31308
- * Worker "remote connector") builds one client per request with that user's
31309
- * `apiKey` injected. The stdio path passes no options, so the key resolves
31310
- * from the environment exactly as before — byte-for-byte identical behaviour.
31456
+ * Optional constructor seam: a hosted per-user deployment builds one client
31457
+ * per request with that user's `apiKey` injected. The stdio path passes no
31458
+ * options, so the key resolves from the environment exactly as before —
31459
+ * byte-for-byte identical behaviour.
31311
31460
  */
31312
31461
  constructor(opts) {
31313
31462
  const key = opts?.apiKey ?? readEnvVar("SPLITWISE_API_KEY");
@@ -31318,14 +31467,26 @@ var SplitwiseClient = class {
31318
31467
  this.apiKey = key;
31319
31468
  this.configError = null;
31320
31469
  }
31321
- this.api = createApiClient({
31322
- baseUrl: BASE_URL,
31323
- getToken: () => this.requireKey(),
31324
- 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,
31325
31483
  retry: { count: 1, delayMs: 2e3 },
31326
31484
  timeout: 3e4,
31327
- onUnauthorized: () => new Error("SPLITWISE_API_KEY is invalid or missing"),
31328
- 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}`)
31329
31490
  });
31330
31491
  }
31331
31492
  requireKey() {
@@ -31335,6 +31496,31 @@ var SplitwiseClient = class {
31335
31496
  async request(method, path, body) {
31336
31497
  return this.api.fetchJson(method, path, body !== void 0 ? { body } : {});
31337
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
+ }
31338
31524
  };
31339
31525
  var client = new SplitwiseClient();
31340
31526
 
@@ -31383,13 +31569,7 @@ function registerUserTools(server, client2) {
31383
31569
  confirm: schemaConfirm
31384
31570
  }
31385
31571
  }, async ({ id, first_name, last_name, email: email3, password, locale, default_currency, confirm }) => {
31386
- const body = {};
31387
- if (first_name !== void 0) body.first_name = first_name;
31388
- if (last_name !== void 0) body.last_name = last_name;
31389
- if (email3 !== void 0) body.email = email3;
31390
- if (password !== void 0) body.password = password;
31391
- if (locale !== void 0) body.locale = locale;
31392
- if (default_currency !== void 0) body.default_currency = default_currency;
31572
+ const body = pruneUndefined({ first_name, last_name, email: email3, password, locale, default_currency });
31393
31573
  const previewBody = { ...body, ...password !== void 0 ? { password: "[hidden]" } : {} };
31394
31574
  const gate = previewUnlessConfirmed(confirm, `Update current Splitwise user ${id} profile`, "POST", `/update_user/${id}`, previewBody);
31395
31575
  if (gate) return gate;
@@ -31427,9 +31607,7 @@ function registerGroupTools(server, client2) {
31427
31607
  confirm: schemaConfirm
31428
31608
  }
31429
31609
  }, async ({ name, group_type, simplify_by_default, confirm }) => {
31430
- const body = { name };
31431
- if (group_type !== void 0) body.group_type = group_type;
31432
- if (simplify_by_default !== void 0) body.simplify_by_default = simplify_by_default;
31610
+ const body = pruneUndefined({ name, group_type, simplify_by_default });
31433
31611
  const gate = previewUnlessConfirmed(confirm, `Create Splitwise group "${name}"`, "POST", "/create_group", body);
31434
31612
  if (gate) return gate;
31435
31613
  const data = await client2.request("POST", "/create_group", body);
@@ -31517,9 +31695,7 @@ function registerFriendTools(server, client2) {
31517
31695
  confirm: schemaConfirm
31518
31696
  }
31519
31697
  }, async ({ user_email, user_first_name, user_last_name, confirm }) => {
31520
- const body = { user_email };
31521
- if (user_first_name !== void 0) body.user_first_name = user_first_name;
31522
- 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 });
31523
31699
  const gate = previewUnlessConfirmed(confirm, `Add ${user_email} as a Splitwise friend`, "POST", "/create_friend", body);
31524
31700
  if (gate) return gate;
31525
31701
  const data = await client2.request("POST", "/create_friend", body);
@@ -31673,6 +31849,89 @@ function registerExpenseTools(server, client2) {
31673
31849
  });
31674
31850
  }
31675
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
+
31676
31935
  // src/tools/utilities.ts
31677
31936
  function registerUtilityTools(server, client2) {
31678
31937
  server.registerTool("sw_get_notifications", {
@@ -31738,7 +31997,7 @@ function registerUtilityTools(server, client2) {
31738
31997
  // src/index.ts
31739
31998
  await runMcp({
31740
31999
  name: "splitwise-mcp",
31741
- version: "2.1.5",
32000
+ version: "2.2.0",
31742
32001
  // x-release-please-version
31743
32002
  deps: client,
31744
32003
  banner: "[splitwise-mcp] This project was developed and is maintained by AI (Claude Sonnet 4.6). Use at your own discretion.",
@@ -31747,6 +32006,7 @@ await runMcp({
31747
32006
  registerGroupTools,
31748
32007
  registerFriendTools,
31749
32008
  registerExpenseTools,
32009
+ registerReceiptTools,
31750
32010
  registerUtilityTools
31751
32011
  ]
31752
32012
  });
package/dist/client.js CHANGED
@@ -1,34 +1,79 @@
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.
7
- // The try/catch guards the Cloudflare Worker runtime, where `import.meta.url`
8
- // is undefined and `fileURLToPath(undefined)` would throw at module init
9
- // (Worker startup validation) — there is no filesystem / .env to load there.
7
+ // The try/catch guards a runtime where `import.meta.url` is undefined and
8
+ // `fileURLToPath(undefined)` would throw at module init — a sandboxed one has
9
+ // no filesystem or .env to load anyway.
10
10
  try {
11
11
  const dir = dirname(fileURLToPath(import.meta.url));
12
12
  await loadDotenvSafely({ path: join(dir, '..', '.env'), override: false });
13
13
  }
14
14
  catch {
15
- /* non-Node runtime (Workers): no .env to load */
15
+ /* non-Node runtime: no .env to load */
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.
26
71
  * Tool calls re-raise the error at request time.
27
72
  *
28
- * Optional constructor seam: a hosted per-user deployment (e.g. a Cloudflare
29
- * Worker "remote connector") builds one client per request with that user's
30
- * `apiKey` injected. The stdio path passes no options, so the key resolves
31
- * from the environment exactly as before — byte-for-byte identical behaviour.
73
+ * Optional constructor seam: a hosted per-user deployment builds one client
74
+ * per request with that user's `apiKey` injected. The stdio path passes no
75
+ * options, so the key resolves from the environment exactly as before —
76
+ * byte-for-byte identical behaviour.
32
77
  */
33
78
  constructor(opts) {
34
79
  // Injected apiKey (hosted per-user path) takes precedence; otherwise fall
@@ -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
@@ -13,11 +14,11 @@ import { registerUtilityTools } from './tools/utilities.js';
13
14
  // first request. That preserves the deferred-config-error pattern: the server
14
15
  // boots and answers the host's install-time tools/list smoke test even when
15
16
  // SPLITWISE_API_KEY is absent — the configuration error only surfaces on the
16
- // first tool call. A hosted per-user connector (a later task) injects its own
17
- // per-request client into the same registrars instead of this singleton.
17
+ // first tool call. A hosted per-user deployment injects its own per-request
18
+ // client into the same registrars instead of this singleton.
18
19
  await runMcp({
19
20
  name: 'splitwise-mcp',
20
- version: '2.1.5', // 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.5",
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>",
@@ -40,28 +40,19 @@
40
40
  "dev": "node dist/index.js",
41
41
  "test": "vitest run",
42
42
  "test:watch": "vitest",
43
- "test:coverage": "vitest run --coverage",
44
- "worker:dev": "wrangler dev",
45
- "worker:deploy": "wrangler deploy",
46
- "worker:test": "vitest run --config vitest.workers.config.ts"
43
+ "test:coverage": "vitest run --coverage"
47
44
  },
48
45
  "dependencies": {
49
- "@chrischall/mcp-utils": "^0.13.0",
46
+ "@chrischall/mcp-utils": "^0.14.0",
50
47
  "@modelcontextprotocol/sdk": "^1.29.0",
51
48
  "dotenv": "^17.4.0",
52
49
  "zod": "^4.4.2"
53
50
  },
54
51
  "devDependencies": {
55
- "@chrischall/mcp-connector": "^1.1.1",
56
- "@cloudflare/vitest-pool-workers": "^0.18.4",
57
- "@cloudflare/workers-oauth-provider": "^0.8.1",
58
- "@cloudflare/workers-types": "^5.20260708.1",
59
52
  "@types/node": "^26.0.0",
60
53
  "@vitest/coverage-v8": "^4.1.2",
61
- "agents": "^0.19.0",
62
54
  "esbuild": "^0.28.0",
63
55
  "typescript": "^7.0.2",
64
- "vitest": "^4.1.2",
65
- "wrangler": "^4.110.0"
56
+ "vitest": "^4.1.2"
66
57
  }
67
58
  }
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.5",
9
+ "version": "2.2.0",
10
10
  "packages": [
11
11
  {
12
12
  "registryType": "npm",
13
13
  "identifier": "splitwise-mcp",
14
- "version": "2.1.5",
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
@@ -1,23 +0,0 @@
1
- import { SplitwiseClient } from './client.js';
2
- /**
3
- * `ConnectorAuth` for the Splitwise remote connector: the login page collects
4
- * the user's own Splitwise API key (from https://secure.splitwise.com/apps/register),
5
- * verifies it by constructing a `SplitwiseClient` with the key and calling the
6
- * current-user endpoint (a bad key throws, which the connector surfaces back on
7
- * the login page), and stores `{ apiKey }` as the OAuth props that `worker.ts`'s
8
- * `buildClient` turns into a per-user client.
9
- */
10
- export const splitwiseAuth = {
11
- service: 'Splitwise',
12
- accent: '#1CC29F',
13
- privacyNote: 'Your Splitwise API key is stored encrypted and used only to call Splitwise on your behalf.',
14
- fields: [{ name: 'apiKey', label: 'Splitwise API key', type: 'password' }],
15
- async login(fields) {
16
- // Verify the key up front — an invalid key makes /get_current_user throw
17
- // here, which the connector surfaces back on the login page. We discard the
18
- // response: the per-user client is built fresh from the stored key.
19
- const client = new SplitwiseClient({ apiKey: fields.apiKey });
20
- await client.request('GET', '/get_current_user');
21
- return { apiKey: fields.apiKey };
22
- },
23
- };