splitwise-mcp 3.6.0 → 3.7.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": "3.6.0"
10
+ "version": "3.7.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": "3.6.0",
18
+ "version": "3.7.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": "3.6.0",
4
+ "version": "3.7.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/README.md CHANGED
@@ -193,7 +193,7 @@ To save calls on such a client: **`sw_confirm({confirmToken})`** finishes any pr
193
193
  | Tool | What it does |
194
194
  |------|-------------|
195
195
  | `sw_get_receipt` | Download an expense's receipt image/PDF with the server's credentials. Returns it inline (`inline`), as extracted PDF text (`extract_text`), and/or written to a file |
196
- | `sw_get_upload_url` | Hosted only: a short-lived signed URL to `curl -X PUT` a receipt file to, then pass `receipt: {upload_id, filename}` — no base64 through the conversation |
196
+ | `sw_get_upload_url` | Hosted only: a short-lived signed URL to `curl -X PUT` a receipt file to, then pass `receipt: {upload_id, filename}` — no base64 through the conversation. The upload is deleted from the server once Splitwise has the receipt (`upload_deleted` in the reply); a failed write keeps it for a retry |
197
197
 
198
198
  ### Utilities
199
199
 
package/dist/bundle.js CHANGED
@@ -100502,6 +100502,88 @@ function cancelStream(body) {
100502
100502
  }
100503
100503
  }
100504
100504
 
100505
+ // node_modules/@chrischall/mcp-utils/dist/http/fetch-bounded.js
100506
+ async function fetchBounded(url4, init, opts = {}) {
100507
+ const doFetch = opts.fetchImpl ?? fetch;
100508
+ const service = opts.service ?? hostOf(url4);
100509
+ const read = opts.read ?? "text";
100510
+ const timeoutMs = opts.timeoutMs === false ? 0 : opts.timeoutMs ?? DEFAULT_REQUEST_TIMEOUT_MS;
100511
+ const caller = withAmbientCancellation(init?.signal ?? void 0);
100512
+ if (caller?.aborted)
100513
+ throw reasonOf(caller);
100514
+ const timer = timeoutMs > 0 ? new AbortController() : void 0;
100515
+ const signal = combine(timer?.signal, caller);
100516
+ let handle;
100517
+ let detach = () => {
100518
+ };
100519
+ const stop = signal ? new Promise((_resolve, reject) => {
100520
+ const onAbort = () => reject(timer?.signal.aborted ? new RequestTimeoutError(service, timeoutMs) : reasonOf(caller));
100521
+ signal.addEventListener("abort", onAbort, { once: true });
100522
+ detach = () => signal.removeEventListener("abort", onAbort);
100523
+ }) : void 0;
100524
+ stop?.catch(() => {
100525
+ });
100526
+ const bounded = (p2) => stop ? Promise.race([p2, stop]) : p2;
100527
+ if (timer)
100528
+ handle = setTimeout(() => timer.abort(), timeoutMs);
100529
+ try {
100530
+ let res;
100531
+ try {
100532
+ res = await bounded(doFetch(url4, { ...init, ...signal ? { signal } : {} }));
100533
+ } catch (err) {
100534
+ if (timer?.signal.aborted)
100535
+ throw new RequestTimeoutError(service, timeoutMs);
100536
+ if (caller?.aborted)
100537
+ throw reasonOf(caller);
100538
+ throw err;
100539
+ }
100540
+ if (read === "none") {
100541
+ cancelStream(res.body);
100542
+ return done(res, void 0);
100543
+ }
100544
+ const bytes = await readBytesCapped(res, opts.maxBytes, service, bounded);
100545
+ if (read === "bytes")
100546
+ return done(res, bytes);
100547
+ const text = new TextDecoder().decode(bytes);
100548
+ if (read === "text")
100549
+ return done(res, text);
100550
+ if (text.length === 0)
100551
+ return done(res, void 0);
100552
+ try {
100553
+ return done(res, JSON.parse(text));
100554
+ } catch (cause) {
100555
+ const type = res.headers.get("content-type") ?? "no content-type";
100556
+ throw new McpToolError(`${service} returned a non-JSON response (HTTP ${res.status}, ${type}).`, {
100557
+ hint: "The upstream may have served an error or sign-in page instead of data.",
100558
+ cause,
100559
+ status: res.status
100560
+ });
100561
+ }
100562
+ } finally {
100563
+ clearTimeout(handle);
100564
+ detach();
100565
+ }
100566
+ function done(res, body) {
100567
+ return { res, status: res.status, ok: res.ok, headers: res.headers, body };
100568
+ }
100569
+ }
100570
+ function combine(a2, b2) {
100571
+ if (a2 && b2)
100572
+ return AbortSignal.any([a2, b2]);
100573
+ return a2 ?? b2;
100574
+ }
100575
+ function reasonOf(signal) {
100576
+ const reason = signal.reason;
100577
+ return reason instanceof Error ? reason : new Error(String(reason));
100578
+ }
100579
+ function hostOf(url4) {
100580
+ try {
100581
+ return new URL(url4).host;
100582
+ } catch {
100583
+ return String(url4);
100584
+ }
100585
+ }
100586
+
100505
100587
  // node_modules/@chrischall/mcp-utils/dist/http/url-safety.js
100506
100588
  function isDotSegment(segment) {
100507
100589
  const s2 = segment.replace(/%2e/gi, ".");
@@ -100973,7 +101055,7 @@ function encodeBody(opt) {
100973
101055
  return { body: JSON.stringify(opt.body), contentType: "application/json" };
100974
101056
  return { body: void 0, contentType: void 0 };
100975
101057
  }
100976
- function hostOf(baseUrl) {
101058
+ function hostOf2(baseUrl) {
100977
101059
  try {
100978
101060
  return new URL(baseUrl).host;
100979
101061
  } catch {
@@ -100983,7 +101065,7 @@ function hostOf(baseUrl) {
100983
101065
  function createApiClient(opts) {
100984
101066
  const base = opts.baseUrl.replace(/\/+$/, "");
100985
101067
  const retry = opts.retry ?? DEFAULT_RETRY;
100986
- const service = opts.serviceName ?? hostOf(opts.baseUrl);
101068
+ const service = opts.serviceName ?? hostOf2(opts.baseUrl);
100987
101069
  const doFetch = opts.fetchImpl ?? fetch;
100988
101070
  const sleep2 = (ms2) => cancellableSleep(ms2, opts.sleep);
100989
101071
  const unauthorized = () => opts.onUnauthorized ? opts.onUnauthorized() : new UnauthorizedError(service);
@@ -102481,6 +102563,7 @@ import { createHash as createHash4 } from "crypto";
102481
102563
  import { createHmac as createHmac3 } from "node:crypto";
102482
102564
  var BLOB_DEFAULT_TTL_MS = 60 * 60 * 1e3;
102483
102565
  var BLOB_MAX_TTL_MS = 24 * 60 * 60 * 1e3;
102566
+ var BLOB_MAX_OBJECT_BYTES = 100 * 1024 * 1024;
102484
102567
  var MAX_KEY_LENGTH = 1024;
102485
102568
  var SAFE_SEGMENT = /^[A-Za-z0-9._~-]+$/;
102486
102569
  function configError(reason) {
@@ -102586,7 +102669,60 @@ ${exp}`)), expiresAtMs: exp };
102586
102669
  }
102587
102670
  return { key, bytes: bytes.byteLength };
102588
102671
  };
102589
- return { baseUrl: base, prefix, signGetUrl, signPutUrl, put };
102672
+ const signDeleteUrl = (key, o2) => {
102673
+ const encoded = encodeKey(key);
102674
+ const exp = expiry(o2);
102675
+ return { url: link(encoded, exp, sign2(`del\0${prefix}/${key}\0${exp}`)), expiresAtMs: exp };
102676
+ };
102677
+ const exchange = async (what, url4, method, read, o2) => {
102678
+ try {
102679
+ return await fetchBounded(url4, { method, ...o2.signal ? { signal: o2.signal } : {} }, {
102680
+ read,
102681
+ service: "the blob store",
102682
+ ...read === "bytes" ? { maxBytes: o2.maxBytes ?? BLOB_MAX_OBJECT_BYTES } : {},
102683
+ ...o2.timeoutMs !== void 0 ? { timeoutMs: o2.timeoutMs } : {},
102684
+ ...o2.fetchImpl ? { fetchImpl: o2.fetchImpl } : {}
102685
+ });
102686
+ } catch (cause) {
102687
+ if (o2.signal?.aborted || cause instanceof McpToolError || cause instanceof RequestTimeoutError)
102688
+ throw cause;
102689
+ throw new McpToolError(`Blob store ${what} did not complete (network error).`, { kind: "transport" });
102690
+ }
102691
+ };
102692
+ const get = async (key, o2 = {}) => {
102693
+ const { url: url4 } = signGetUrl(key, o2);
102694
+ const res = await exchange(`read of ${key}`, url4, "GET", "bytes", o2);
102695
+ if (!res.ok) {
102696
+ const why = res.status === 404 ? " \u2014 not found (never uploaded, expired, or already consumed)" : "";
102697
+ throw new McpToolError(`Blob store read of ${key} failed (HTTP ${res.status})${why}.`, {
102698
+ kind: "http",
102699
+ status: res.status
102700
+ });
102701
+ }
102702
+ const contentType = res.headers.get("content-type") ?? void 0;
102703
+ return { key, bytes: res.body, ...contentType !== void 0 ? { contentType } : {} };
102704
+ };
102705
+ const del = async (key, o2 = {}) => {
102706
+ const { url: url4 } = signDeleteUrl(key, o2);
102707
+ const res = await exchange(`delete of ${key}`, url4, "DELETE", "none", o2);
102708
+ if (!res.ok) {
102709
+ throw new McpToolError(`Blob store delete of ${key} failed (HTTP ${res.status}).`, {
102710
+ kind: "http",
102711
+ status: res.status
102712
+ });
102713
+ }
102714
+ };
102715
+ const consume = async (key, use, o2 = {}) => {
102716
+ const object5 = await get(key, o2);
102717
+ const result = await use(object5);
102718
+ try {
102719
+ await del(key, o2);
102720
+ return { result, deleted: true };
102721
+ } catch (e2) {
102722
+ return { result, deleted: false, deleteError: e2 instanceof Error ? e2.message : String(e2) };
102723
+ }
102724
+ };
102725
+ return { baseUrl: base, prefix, signGetUrl, signPutUrl, put, signDeleteUrl, get, delete: del, consume };
102590
102726
  }
102591
102727
  function blobStoreFromEnv(env = process.env) {
102592
102728
  const baseUrl = readEnvVar("MCP_BLOB_BASE_URL", { env });
@@ -102611,6 +102747,19 @@ var CRC_TABLE = (() => {
102611
102747
  // node_modules/@chrischall/mcp-utils/dist/export/sink.js
102612
102748
  var EXPORT_BLOB_MAX_BYTES = 256 * 1024 * 1024;
102613
102749
 
102750
+ // node_modules/@chrischall/mcp-utils/dist/export/upload.js
102751
+ var UPLOAD_SLOT_DEFAULT_TTL_MS = 30 * 60 * 1e3;
102752
+ var MIB = 1024 * 1024;
102753
+ var inlineFileSchema = external_exports.object({
102754
+ filename: external_exports.string().describe("Filename the upstream will show \u2014 a bare name, no path, not starting with a dot. Its extension sets the MIME type when mimeType is omitted."),
102755
+ contentBase64: external_exports.string().describe(`The file's bytes, standard base64 (line breaks and a "data:<type>;base64," prefix are tolerated).`),
102756
+ mimeType: external_exports.string().describe('MIME type, e.g. "image/jpeg". Default: inferred from the filename extension.').optional()
102757
+ });
102758
+ var uploadSlotRequestSchema = external_exports.object({
102759
+ filename: external_exports.string().describe("Filename the upstream will show \u2014 a bare name, no path, not starting with a dot."),
102760
+ mimeType: external_exports.string().describe("MIME type. Default: inferred from the filename extension. The PUT must send exactly this Content-Type.").optional()
102761
+ });
102762
+
102614
102763
  // src/tools/_receipt.ts
102615
102764
  var MAX_RECEIPT_BYTES = 10 * 1024 * 1024;
102616
102765
  var LIMIT_LABEL = "10 MiB";
@@ -102626,7 +102775,7 @@ var receiptSchema = external_exports.object({
102626
102775
  path: external_exports.string().min(1).describe(
102627
102776
  "A file inside SPLITWISE_INPUT_DIR on the machine running this server (relative to it, or absolute). Only works when that variable is set \u2014 never on a hosted server; use upload_id (or base64) there."
102628
102777
  ).optional(),
102629
- upload_id: external_exports.string().min(1).describe("The upload_id from sw_get_upload_url, after the file was PUT to its upload_url. The way to send a full-size file to the hosted server without base64.").optional(),
102778
+ upload_id: external_exports.string().min(1).describe("The upload_id from sw_get_upload_url, after the file was PUT to its upload_url. The way to send a full-size file to the hosted server without base64. Single use: the upload is deleted once Splitwise has the receipt (the reply says upload_deleted); a failed or declined write keeps it, so the same upload_id can be retried.").optional(),
102630
102779
  filename: external_exports.string().min(1).max(200).describe('Name to store the file under, e.g. "receipt.pdf".'),
102631
102780
  mime_type: external_exports.string().describe("Optional. If given, it must match the type detected from the file contents.").optional()
102632
102781
  }).describe(
@@ -102709,7 +102858,7 @@ async function readUpload(uploadId, client2) {
102709
102858
  } catch (err) {
102710
102859
  if (err instanceof ApiError && (err.status === 404 || err.status === 403)) {
102711
102860
  throw new Error(
102712
- `No upload found for receipt.upload_id ${uploadId} \u2014 the PUT may not have run, failed, or the upload expired. Get a fresh link with sw_get_upload_url and upload the file again.`
102861
+ `No upload found for receipt.upload_id ${uploadId} \u2014 the PUT may not have run, failed, the upload expired, or it was already used (an upload is deleted once its receipt is attached). Get a fresh link with sw_get_upload_url and upload the file again.`
102713
102862
  );
102714
102863
  }
102715
102864
  if (err.kind === "too_large") {
@@ -102750,9 +102899,23 @@ async function loadReceipt(input2, client2) {
102750
102899
  bytes,
102751
102900
  filename,
102752
102901
  mimeType: type.mime,
102753
- summary: { filename, mime_type: type.mime, bytes: bytes.length, sha256 }
102902
+ summary: { filename, mime_type: type.mime, bytes: bytes.length, sha256 },
102903
+ ...input2.upload_id !== void 0 ? { uploadId: input2.upload_id } : {}
102754
102904
  };
102755
102905
  }
102906
+ var UPLOAD_DELETE_TIMEOUT_MS = 1e4;
102907
+ async function releaseUpload(receipt) {
102908
+ if (receipt.uploadId === void 0) return {};
102909
+ try {
102910
+ const store = blobStoreFromEnv();
102911
+ if (!store) throw new Error("no blob store is configured on this server");
102912
+ await store.delete(receipt.uploadId, { timeoutMs: UPLOAD_DELETE_TIMEOUT_MS });
102913
+ return { upload_deleted: true };
102914
+ } catch (err) {
102915
+ if (err.status === 404) return { upload_deleted: true };
102916
+ return { upload_deleted: false, upload_delete_error: err instanceof Error ? err.message : String(err) };
102917
+ }
102918
+ }
102756
102919
  function buildReceiptForm(fields, receipt) {
102757
102920
  const form = new FormData();
102758
102921
  for (const [key, value] of Object.entries(fields)) {
@@ -103170,7 +103333,8 @@ function buildExpenseBody(args) {
103170
103333
  async function sendExpenseWrite(client2, path, body, receipt, expenseId) {
103171
103334
  if (!receipt) return client2.request("POST", path, body);
103172
103335
  const data = await client2.requestMultipart("POST", path, buildReceiptForm(body, receipt));
103173
- return verifyReceiptStored(data, expenseId);
103336
+ const stored = verifyReceiptStored(data, expenseId);
103337
+ return { ...stored, ...await releaseUpload(receipt) };
103174
103338
  }
103175
103339
  function previewBody(body, receipt) {
103176
103340
  return receipt ? { ...body, receipt: receipt.summary } : body;
@@ -103390,7 +103554,7 @@ function registerExpenseTools(server, client2) {
103390
103554
  })));
103391
103555
  withToolMeta(server, moneyMeta("change"), () => registerWrite(server, defineWrite({
103392
103556
  name: "sw_attach_receipt",
103393
- description: "Attach (or replace) the receipt image or PDF on an existing Splitwise expense. Changes nothing else on the expense \u2014 cost, split and description are not sent. Pass the file as receipt.upload_id (hosted: upload it first via sw_get_upload_url \u2014 no bytes in the tool call), receipt.path (local) or receipt.base64. Fails if Splitwise does not store the file.",
103557
+ description: "Attach (or replace) the receipt image or PDF on an existing Splitwise expense. Changes nothing else on the expense \u2014 cost, split and description are not sent. Pass the file as receipt.upload_id (hosted: upload it first via sw_get_upload_url \u2014 no bytes in the tool call), receipt.path (local) or receipt.base64. Fails if Splitwise does not store the file. An upload_id is deleted from the upload store once the file is stored (upload_deleted in the reply).",
103394
103558
  annotations: { readOnlyHint: false, destructiveHint: true, idempotentHint: true, openWorldHint: true },
103395
103559
  inputSchema: external_exports.object({
103396
103560
  expense_id: external_exports.number().describe("ID of the expense to attach the receipt to"),
@@ -103650,7 +103814,7 @@ function registerUploadTools(server, _client) {
103650
103814
  server.registerTool(
103651
103815
  "sw_get_upload_url",
103652
103816
  {
103653
- description: "Get a short-lived (~10 min) signed URL to upload a receipt file (PNG, JPEG, GIF or PDF, up to 10 MiB) to this server WITHOUT putting its bytes in a tool call: run the returned curl command from a shell or sandbox that has the file and network access to the URL's host, then pass receipt: {upload_id, filename} to sw_attach_receipt, sw_create_expense or sw_update_expense. Send the original file \u2014 no need to shrink it. Only on the hosted server; locally use receipt.path. If you have no shell that can reach the URL, use receipt.base64 instead.",
103817
+ description: "Get a short-lived (~10 min) signed URL to upload a receipt file (PNG, JPEG, GIF or PDF, up to 10 MiB) to this server WITHOUT putting its bytes in a tool call: run the returned curl command from a shell or sandbox that has the file and network access to the URL's host, then pass receipt: {upload_id, filename} to sw_attach_receipt, sw_create_expense or sw_update_expense. Send the original file \u2014 no need to shrink it. The upload is deleted from this server once the receipt is attached (a failed write keeps it for a retry), so each upload_id is used once. Only on the hosted server; locally use receipt.path. If you have no shell that can reach the URL, use receipt.base64 instead.",
103654
103818
  annotations: { readOnlyHint: true, openWorldHint: true },
103655
103819
  _meta: moneyMeta("other"),
103656
103820
  inputSchema: external_exports.object({
@@ -103806,7 +103970,7 @@ function registerUtilityTools(server, client2) {
103806
103970
  // src/index.ts
103807
103971
  await runMcp({
103808
103972
  name: "splitwise-mcp",
103809
- version: "3.6.0",
103973
+ version: "3.7.0",
103810
103974
  // x-release-please-version
103811
103975
  deps: client,
103812
103976
  banner: "[splitwise-mcp] This project was developed and is maintained by AI (Claude Sonnet 4.6). Use at your own discretion.",
package/dist/index.js CHANGED
@@ -21,7 +21,7 @@ import { registerUtilityTools } from './tools/utilities.js';
21
21
  // client into the same registrars instead of this singleton.
22
22
  await runMcp({
23
23
  name: 'splitwise-mcp',
24
- version: '3.6.0', // x-release-please-version
24
+ version: '3.7.0', // x-release-please-version
25
25
  deps: client,
26
26
  banner: '[splitwise-mcp] This project was developed and is maintained by AI (Claude Sonnet 4.6). Use at your own discretion.',
27
27
  tools: [
@@ -33,7 +33,7 @@ export const receiptSchema = z
33
33
  upload_id: z
34
34
  .string()
35
35
  .min(1)
36
- .describe('The upload_id from sw_get_upload_url, after the file was PUT to its upload_url. The way to send a full-size file to the hosted server without base64.')
36
+ .describe('The upload_id from sw_get_upload_url, after the file was PUT to its upload_url. The way to send a full-size file to the hosted server without base64. Single use: the upload is deleted once Splitwise has the receipt (the reply says upload_deleted); a failed or declined write keeps it, so the same upload_id can be retried.')
37
37
  .optional(),
38
38
  filename: z.string().min(1).max(200).describe('Name to store the file under, e.g. "receipt.pdf".'),
39
39
  mime_type: z
@@ -146,7 +146,7 @@ async function readUpload(uploadId, client) {
146
146
  }
147
147
  catch (err) {
148
148
  if (err instanceof ApiError && (err.status === 404 || err.status === 403)) {
149
- throw new Error(`No upload found for receipt.upload_id ${uploadId} — the PUT may not have run, failed, or the upload expired. Get a fresh link with sw_get_upload_url and upload the file again.`);
149
+ throw new Error(`No upload found for receipt.upload_id ${uploadId} — the PUT may not have run, failed, the upload expired, or it was already used (an upload is deleted once its receipt is attached). Get a fresh link with sw_get_upload_url and upload the file again.`);
150
150
  }
151
151
  if (err.kind === 'too_large') {
152
152
  throw new Error(`The uploaded receipt is over the ${LIMIT_LABEL} limit (${MAX_RECEIPT_BYTES} bytes)`);
@@ -198,8 +198,40 @@ export async function loadReceipt(input, client) {
198
198
  filename,
199
199
  mimeType: type.mime,
200
200
  summary: { filename, mime_type: type.mime, bytes: bytes.length, sha256 },
201
+ ...(input.upload_id !== undefined ? { uploadId: input.upload_id } : {}),
201
202
  };
202
203
  }
204
+ /** A delete is housekeeping after a write that already landed: never let it hold the reply for long. */
205
+ const UPLOAD_DELETE_TIMEOUT_MS = 10_000;
206
+ /**
207
+ * Delete the blob-store copy of a receipt Splitwise now holds. Call it only
208
+ * after the write SUCCEEDED (and the file was verified stored): the preview may
209
+ * still be declined, and a failed write must leave the upload_id retryable.
210
+ *
211
+ * Never throws. The expense is already written, so an error here would read as
212
+ * a failed write and invite a duplicate; a delete that fails is reported, and
213
+ * the store's retention prune reclaims the object. A 404 means it is already
214
+ * gone (e.g. two ops in one batch used the same upload), which is the goal.
215
+ * Not `BlobStore.consume`: the read happens in `plan` (before the gate) and
216
+ * the write in `execute`, possibly calls apart, so there is no one callback
217
+ * to wrap.
218
+ */
219
+ export async function releaseUpload(receipt) {
220
+ if (receipt.uploadId === undefined)
221
+ return {};
222
+ try {
223
+ const store = blobStoreFromEnv();
224
+ if (!store)
225
+ throw new Error('no blob store is configured on this server');
226
+ await store.delete(receipt.uploadId, { timeoutMs: UPLOAD_DELETE_TIMEOUT_MS });
227
+ return { upload_deleted: true };
228
+ }
229
+ catch (err) {
230
+ if (err.status === 404)
231
+ return { upload_deleted: true };
232
+ return { upload_deleted: false, upload_delete_error: err instanceof Error ? err.message : String(err) };
233
+ }
234
+ }
203
235
  /**
204
236
  * Build the multipart body Splitwise takes when an expense carries a file:
205
237
  * every expense field as a form field (the same flat `users__N__*` keys the
@@ -2,7 +2,7 @@ import { z } from 'zod';
2
2
  import { EXPENSE_LIST_VIEWS, SW_VIEWS, viewExpense, viewExpenses } from '../project.js';
3
3
  import { buildQueryString, defineWrite, minifiedResult, registerWrite, resolveView, viewParam, } from '@chrischall/mcp-utils';
4
4
  import { UNTRUSTED_DESCRIPTION_SUFFIX, swUntrustedResult } from './_untrusted.js';
5
- import { buildReceiptForm, loadReceipt, receiptParam, receiptSchema, verifyReceiptStored, } from './_receipt.js';
5
+ import { buildReceiptForm, loadReceipt, receiptParam, receiptSchema, releaseUpload, verifyReceiptStored, } from './_receipt.js';
6
6
  import { attachComments, decodeCursor, encodeCursor, hasLocalFilters, projectForList, scanExpenses, MAX_COMMENT_ROWS, SCAN_BUDGET, } from './_list.js';
7
7
  import { findDuplicates, inferCategory, resolveCategory, resolveEqualSplit } from './_create.js';
8
8
  import { assertNotDeleted, explainForbidden, verifyRestored, withPostState } from './_expense-state.js';
@@ -63,13 +63,18 @@ function buildExpenseBody(args) {
63
63
  * Send a create/update. With a receipt the whole expense goes as multipart
64
64
  * (Splitwise only takes a file that way) and the response must show the file
65
65
  * stored; without one it stays JSON and the response passes through, exactly
66
- * as before.
66
+ * as before. A receipt that came from `upload_id` is deleted from the blob
67
+ * store once Splitwise has it (`upload_deleted`), here in `execute` — so a
68
+ * direct call, `sw_confirm` and `sw_batch` all clean up, and the preview never
69
+ * does.
67
70
  */
68
71
  async function sendExpenseWrite(client, path, body, receipt, expenseId) {
69
72
  if (!receipt)
70
73
  return client.request('POST', path, body);
71
74
  const data = await client.requestMultipart('POST', path, buildReceiptForm(body, receipt));
72
- return verifyReceiptStored(data, expenseId);
75
+ // Only past verification: a dropped file keeps the upload for sw_attach_receipt.
76
+ const stored = verifyReceiptStored(data, expenseId);
77
+ return { ...stored, ...(await releaseUpload(receipt)) };
73
78
  }
74
79
  /** The body the confirmation preview shows: the fields, plus the file's identity in place of its bytes. */
75
80
  function previewBody(body, receipt) {
@@ -343,7 +348,7 @@ export function registerExpenseTools(server, client) {
343
348
  })));
344
349
  withToolMeta(server, moneyMeta('change'), () => registerWrite(server, defineWrite({
345
350
  name: 'sw_attach_receipt',
346
- description: 'Attach (or replace) the receipt image or PDF on an existing Splitwise expense. Changes nothing else on the expense — cost, split and description are not sent. Pass the file as receipt.upload_id (hosted: upload it first via sw_get_upload_url — no bytes in the tool call), receipt.path (local) or receipt.base64. Fails if Splitwise does not store the file.',
351
+ description: 'Attach (or replace) the receipt image or PDF on an existing Splitwise expense. Changes nothing else on the expense — cost, split and description are not sent. Pass the file as receipt.upload_id (hosted: upload it first via sw_get_upload_url — no bytes in the tool call), receipt.path (local) or receipt.base64. Fails if Splitwise does not store the file. An upload_id is deleted from the upload store once the file is stored (upload_deleted in the reply).',
347
352
  annotations: { readOnlyHint: false, destructiveHint: true, idempotentHint: true, openWorldHint: true },
348
353
  inputSchema: z.object({
349
354
  expense_id: z.number().describe('ID of the expense to attach the receipt to'),
@@ -16,7 +16,7 @@ export const UPLOAD_URL_TTL_MS = 10 * 60 * 1000;
16
16
  */
17
17
  export function registerUploadTools(server, _client) {
18
18
  server.registerTool('sw_get_upload_url', {
19
- description: 'Get a short-lived (~10 min) signed URL to upload a receipt file (PNG, JPEG, GIF or PDF, up to 10 MiB) to this server WITHOUT putting its bytes in a tool call: run the returned curl command from a shell or sandbox that has the file and network access to the URL\'s host, then pass receipt: {upload_id, filename} to sw_attach_receipt, sw_create_expense or sw_update_expense. Send the original file — no need to shrink it. Only on the hosted server; locally use receipt.path. If you have no shell that can reach the URL, use receipt.base64 instead.',
19
+ description: 'Get a short-lived (~10 min) signed URL to upload a receipt file (PNG, JPEG, GIF or PDF, up to 10 MiB) to this server WITHOUT putting its bytes in a tool call: run the returned curl command from a shell or sandbox that has the file and network access to the URL\'s host, then pass receipt: {upload_id, filename} to sw_attach_receipt, sw_create_expense or sw_update_expense. Send the original file — no need to shrink it. The upload is deleted from this server once the receipt is attached (a failed write keeps it for a retry), so each upload_id is used once. Only on the hosted server; locally use receipt.path. If you have no shell that can reach the URL, use receipt.base64 instead.',
20
20
  annotations: { readOnlyHint: true, openWorldHint: true },
21
21
  _meta: moneyMeta('other'),
22
22
  inputSchema: z.object({
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "splitwise-mcp",
3
- "version": "3.6.0",
3
+ "version": "3.7.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>",
@@ -45,7 +45,7 @@
45
45
  "typecheck": "tsc -p tsconfig.json --noEmit"
46
46
  },
47
47
  "dependencies": {
48
- "@chrischall/mcp-utils": "^3.3.0",
48
+ "@chrischall/mcp-utils": "^3.6.0",
49
49
  "@modelcontextprotocol/server": "^2.3.0",
50
50
  "dotenv": "^18.0.2",
51
51
  "unpdf": "^2.0.0",
package/server.json CHANGED
@@ -6,12 +6,12 @@
6
6
  "url": "https://github.com/chrischall/splitwise-mcp",
7
7
  "source": "github"
8
8
  },
9
- "version": "3.6.0",
9
+ "version": "3.7.0",
10
10
  "packages": [
11
11
  {
12
12
  "registryType": "npm",
13
13
  "identifier": "splitwise-mcp",
14
- "version": "3.6.0",
14
+ "version": "3.7.0",
15
15
  "transport": {
16
16
  "type": "stdio"
17
17
  },
@@ -237,7 +237,7 @@ sw_create_expense(group_id, "Band camp water jug", "54.11", split_equally: true,
237
237
  sw_attach_receipt(expense_id, receipt: { base64: "<png/jpeg/gif/pdf bytes>", filename: "order.pdf" })
238
238
  ```
239
239
 
240
- `receipt` takes `filename` plus exactly one of `upload_id` (hosted server: from `sw_get_upload_url`, after `curl -X PUT` of the file — keeps the bytes out of the conversation and out of the second, confirming call), `path` (only on a local server with `SPLITWISE_INPUT_DIR` set, and only inside that directory) or `base64` (fallback when you have no shell that can reach the upload URL). PNG, JPEG, GIF or PDF, ≤ 10 MiB, type detected from the bytes. It replaces any existing receipt. Use `sw_attach_receipt` to add a file to an existing expense — it sends nothing but the file. If Splitwise saves the expense but drops the file, the tool returns an error naming the expense: do not re-create it, retry with `sw_attach_receipt`.
240
+ `receipt` takes `filename` plus exactly one of `upload_id` (hosted server: from `sw_get_upload_url`, after `curl -X PUT` of the file — keeps the bytes out of the conversation and out of the second, confirming call), `path` (only on a local server with `SPLITWISE_INPUT_DIR` set, and only inside that directory) or `base64` (fallback when you have no shell that can reach the upload URL). PNG, JPEG, GIF or PDF, ≤ 10 MiB, type detected from the bytes. It replaces any existing receipt. Use `sw_attach_receipt` to add a file to an existing expense — it sends nothing but the file. If Splitwise saves the expense but drops the file, the tool returns an error naming the expense: do not re-create it, retry with `sw_attach_receipt`. An `upload_id` is single use: once the receipt is stored the upload is deleted (`upload_deleted: true`), so attaching the same file again needs a fresh `sw_get_upload_url`. A declined preview or a failed write keeps it — retry with the same `upload_id`. `upload_deleted: false` (with `upload_delete_error`) is not a failure: the receipt is attached; the copy just waits for the server's retention cleanup.
241
241
 
242
242
  **Log a 50/50 expense the other person paid:**
243
243
  ```