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.
- package/.claude-plugin/marketplace.json +2 -2
- package/.claude-plugin/plugin.json +1 -1
- package/README.md +1 -1
- package/dist/bundle.js +174 -10
- package/dist/index.js +1 -1
- package/dist/tools/_receipt.js +34 -2
- package/dist/tools/expenses.js +9 -4
- package/dist/tools/uploads.js +1 -1
- package/package.json +2 -2
- package/server.json +2 -2
- package/skills/splitwise-mcp/SKILL.md +1 -1
|
@@ -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.
|
|
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.
|
|
18
|
+
"version": "3.7.0",
|
|
19
19
|
"author": {
|
|
20
20
|
"name": "Chris Hall"
|
|
21
21
|
},
|
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
|
|
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 ??
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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.
|
|
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.
|
|
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: [
|
package/dist/tools/_receipt.js
CHANGED
|
@@ -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
|
|
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
|
package/dist/tools/expenses.js
CHANGED
|
@@ -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
|
-
|
|
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'),
|
package/dist/tools/uploads.js
CHANGED
|
@@ -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.
|
|
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.
|
|
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.
|
|
9
|
+
"version": "3.7.0",
|
|
10
10
|
"packages": [
|
|
11
11
|
{
|
|
12
12
|
"registryType": "npm",
|
|
13
13
|
"identifier": "splitwise-mcp",
|
|
14
|
-
"version": "3.
|
|
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
|
```
|