splitwise-mcp 3.3.1 → 3.4.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/.mcp.json +2 -1
- package/README.md +7 -2
- package/dist/bundle.js +201 -22
- package/dist/client.js +14 -2
- package/dist/index.js +1 -1
- package/dist/tools/_receipt.js +173 -0
- package/dist/tools/expenses.js +60 -10
- package/package.json +1 -1
- package/server.json +9 -2
- package/skills/splitwise-mcp/SKILL.md +15 -4
|
@@ -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.4.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.4.0",
|
|
19
19
|
"author": {
|
|
20
20
|
"name": "Chris Hall"
|
|
21
21
|
},
|
package/.mcp.json
CHANGED
package/README.md
CHANGED
|
@@ -21,6 +21,7 @@ Ask Claude things like:
|
|
|
21
21
|
- *"Show me recent expenses"*
|
|
22
22
|
- *"Delete that duplicate expense"*
|
|
23
23
|
- *"Download the receipt from that hotel expense"*
|
|
24
|
+
- *"Add that Amazon order screenshot as the receipt on the water-jug expense"*
|
|
24
25
|
|
|
25
26
|
## Requirements
|
|
26
27
|
|
|
@@ -114,6 +115,7 @@ Add to Claude Desktop config:
|
|
|
114
115
|
|---------|----------|-------|
|
|
115
116
|
| `SPLITWISE_API_KEY` | Yes | API key from [splitwise.com/apps/register](https://secure.splitwise.com/apps/register) |
|
|
116
117
|
| `SPLITWISE_OUTPUT_DIR` | No | Where `sw_get_receipt` writes downloaded receipts (owner-only, mode 0600). Defaults to `~/Downloads/splitwise-mcp`, never the working directory. When set, a per-call `output_dir` must be inside it. |
|
|
118
|
+
| `SPLITWISE_INPUT_DIR` | No | The only directory `receipt.path` may read from (`..` and symlinks out of it are refused). Unset — always the case on a hosted server — means receipts are accepted as `base64` only. |
|
|
117
119
|
|
|
118
120
|
## Confirmations
|
|
119
121
|
|
|
@@ -165,10 +167,11 @@ Every Splitwise write (creating, editing or deleting expenses, groups, friends,
|
|
|
165
167
|
|------|-------------|
|
|
166
168
|
| `sw_list_expenses` | List or search expenses with filters |
|
|
167
169
|
| `sw_get_expense` | Full details of a single expense |
|
|
168
|
-
| `sw_create_expense` | Create an expense (equal or custom split) |
|
|
169
|
-
| `sw_update_expense` | Edit an existing expense |
|
|
170
|
+
| `sw_create_expense` | Create an expense (equal or custom split), optionally with a receipt image/PDF |
|
|
171
|
+
| `sw_update_expense` | Edit an existing expense, or attach/replace its receipt |
|
|
170
172
|
| `sw_delete_expense` | Soft-delete an expense |
|
|
171
173
|
| `sw_undelete_expense` | Restore a deleted expense |
|
|
174
|
+
| `sw_attach_receipt` | Attach or replace the receipt on an existing expense, changing nothing else |
|
|
172
175
|
| `sw_get_comments` | Get comments on an expense |
|
|
173
176
|
| `sw_create_comment` | Add a comment to an expense |
|
|
174
177
|
| `sw_delete_comment` | Delete a comment |
|
|
@@ -191,6 +194,8 @@ Every Splitwise write (creating, editing or deleting expenses, groups, friends,
|
|
|
191
194
|
|
|
192
195
|
**"SPLITWISE_API_KEY is required"** -- set the environment variable in your MCP config or a `.env` file.
|
|
193
196
|
|
|
197
|
+
**Attaching a receipt** -- `sw_create_expense`, `sw_update_expense` and `sw_attach_receipt` take `receipt: { base64, filename, mime_type? }` (works everywhere, including a hosted server) or, on a local server with `SPLITWISE_INPUT_DIR` set, `receipt: { path, filename }` for a file inside that directory. PNG, JPEG, GIF or PDF up to 10 MiB (Splitwise documents no limit); the type is detected from the bytes, and a `mime_type` that disagrees is refused. The confirmation preview shows the file's name, type, size and SHA-256 instead of its contents, and the token is bound to those bytes. After the write the tool checks Splitwise actually stored the file (`receipt.original`) and returns an error naming the expense if it did not. A receipt replaces any receipt already on the expense.
|
|
198
|
+
|
|
194
199
|
**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.
|
|
195
200
|
|
|
196
201
|
**Receipt lands somewhere you can't read it** -- `sw_get_receipt` writes to the *server's* filesystem, which is not the caller's when the server is hosted or containerised. Pass `inline: true` for the bytes (an image block for images, an embedded resource for PDFs) or `extract_text: true` for a PDF's text. With `inline` or `extract_text` and no `output_dir`, nothing is written unless you also pass `write: true`. Pass `write: false` to skip the write in any case; if it fails on its own (read-only filesystem), the call still succeeds and reports `write_error` as long as you asked for content.
|
package/dist/bundle.js
CHANGED
|
@@ -1948,10 +1948,10 @@ var require_checks = __commonJS({
|
|
|
1948
1948
|
const size = input2.size;
|
|
1949
1949
|
if (size === def.size)
|
|
1950
1950
|
return;
|
|
1951
|
-
const
|
|
1951
|
+
const tooBig2 = size > def.size;
|
|
1952
1952
|
payload.issues.push({
|
|
1953
1953
|
origin: util.getSizableOrigin(input2),
|
|
1954
|
-
...
|
|
1954
|
+
...tooBig2 ? { code: "too_big", maximum: def.size } : { code: "too_small", minimum: def.size },
|
|
1955
1955
|
inclusive: true,
|
|
1956
1956
|
exact: true,
|
|
1957
1957
|
input: payload.value,
|
|
@@ -2015,10 +2015,10 @@ var require_checks = __commonJS({
|
|
|
2015
2015
|
if (length === def.length)
|
|
2016
2016
|
return;
|
|
2017
2017
|
const origin = util.getLengthableOrigin(input2);
|
|
2018
|
-
const
|
|
2018
|
+
const tooBig2 = length > def.length;
|
|
2019
2019
|
payload.issues.push({
|
|
2020
2020
|
origin,
|
|
2021
|
-
...
|
|
2021
|
+
...tooBig2 ? { code: "too_big", maximum: def.length } : { code: "too_small", minimum: def.length },
|
|
2022
2022
|
inclusive: true,
|
|
2023
2023
|
exact: true,
|
|
2024
2024
|
input: payload.value,
|
|
@@ -63308,7 +63308,7 @@ function declaredDialect(schema, remedy) {
|
|
|
63308
63308
|
throw new Error(`JSON Schema declares an unsupported dialect ("$schema": "${schema.$schema.slice(0, 200)}"). The default validator supports JSON Schema 2020-12, 2019-09, draft-07, and draft-06; ${remedy}`);
|
|
63309
63309
|
}
|
|
63310
63310
|
|
|
63311
|
-
// node_modules/@modelcontextprotocol/core/dist/auth-BNDyLTqp.mjs
|
|
63311
|
+
// node_modules/@modelcontextprotocol/server/node_modules/@modelcontextprotocol/core/dist/auth-BNDyLTqp.mjs
|
|
63312
63312
|
var z = __toESM(require_v4(), 1);
|
|
63313
63313
|
var LATEST_PROTOCOL_VERSION = "2025-11-25";
|
|
63314
63314
|
var SUPPORTED_PROTOCOL_VERSIONS = [
|
|
@@ -80524,10 +80524,10 @@ var $ZodCheckSizeEquals = /* @__PURE__ */ $constructor("$ZodCheckSizeEquals", (i
|
|
|
80524
80524
|
const size = input2.size;
|
|
80525
80525
|
if (size === def.size)
|
|
80526
80526
|
return;
|
|
80527
|
-
const
|
|
80527
|
+
const tooBig2 = size > def.size;
|
|
80528
80528
|
payload.issues.push({
|
|
80529
80529
|
origin: getSizableOrigin(input2),
|
|
80530
|
-
...
|
|
80530
|
+
...tooBig2 ? { code: "too_big", maximum: def.size } : { code: "too_small", minimum: def.size },
|
|
80531
80531
|
inclusive: true,
|
|
80532
80532
|
exact: true,
|
|
80533
80533
|
input: payload.value,
|
|
@@ -80591,10 +80591,10 @@ var $ZodCheckLengthEquals = /* @__PURE__ */ $constructor("$ZodCheckLengthEquals"
|
|
|
80591
80591
|
if (length === def.length)
|
|
80592
80592
|
return;
|
|
80593
80593
|
const origin = getLengthableOrigin(input2);
|
|
80594
|
-
const
|
|
80594
|
+
const tooBig2 = length > def.length;
|
|
80595
80595
|
payload.issues.push({
|
|
80596
80596
|
origin,
|
|
80597
|
-
...
|
|
80597
|
+
...tooBig2 ? { code: "too_big", maximum: def.length } : { code: "too_small", minimum: def.length },
|
|
80598
80598
|
inclusive: true,
|
|
80599
80599
|
exact: true,
|
|
80600
80600
|
input: payload.value,
|
|
@@ -99842,7 +99842,9 @@ var SplitwiseClient = class {
|
|
|
99842
99842
|
* Optional constructor seam: a hosted per-user deployment builds one client
|
|
99843
99843
|
* per request with that user's `apiKey` injected. The stdio path passes no
|
|
99844
99844
|
* options, so the key resolves from the environment exactly as before —
|
|
99845
|
-
* byte-for-byte identical behaviour.
|
|
99845
|
+
* byte-for-byte identical behaviour. `baseUrl` points the API at another
|
|
99846
|
+
* origin (the HTTP integration tests' fake Splitwise); it defaults to the
|
|
99847
|
+
* real API.
|
|
99846
99848
|
*/
|
|
99847
99849
|
constructor(opts) {
|
|
99848
99850
|
const injected = opts?.apiKey;
|
|
@@ -99855,7 +99857,7 @@ var SplitwiseClient = class {
|
|
|
99855
99857
|
this.apiKey = key;
|
|
99856
99858
|
this.configError = null;
|
|
99857
99859
|
}
|
|
99858
|
-
this.api = this.buildApiClient(BASE_URL, true);
|
|
99860
|
+
this.api = this.buildApiClient(opts?.baseUrl ?? BASE_URL, true);
|
|
99859
99861
|
}
|
|
99860
99862
|
/**
|
|
99861
99863
|
* Which source supplied the API key, for `sw_healthcheck`. Returns a LABEL
|
|
@@ -99895,6 +99897,16 @@ var SplitwiseClient = class {
|
|
|
99895
99897
|
if (method.toUpperCase() !== "GET") assertWriteSucceeded(data);
|
|
99896
99898
|
return data;
|
|
99897
99899
|
}
|
|
99900
|
+
/**
|
|
99901
|
+
* A write sent as multipart/form-data — the only encoding Splitwise accepts
|
|
99902
|
+
* for an expense that carries a receipt file. Same auth, retry and
|
|
99903
|
+
* write-rejection handling as `request`.
|
|
99904
|
+
*/
|
|
99905
|
+
async requestMultipart(method, path, formData) {
|
|
99906
|
+
const data = await this.api.fetchJson(method, path, { formData });
|
|
99907
|
+
assertWriteSucceeded(data);
|
|
99908
|
+
return data;
|
|
99909
|
+
}
|
|
99898
99910
|
/**
|
|
99899
99911
|
* Fetch the raw bytes behind an absolute asset URL the API handed back (an
|
|
99900
99912
|
* expense receipt). Splitwise serves receipts two ways and this covers both:
|
|
@@ -100617,6 +100629,129 @@ function swUntrustedEnvelope(payload) {
|
|
|
100617
100629
|
return untrustedEnvelope(payload, { note: SW_UNTRUSTED_NOTE });
|
|
100618
100630
|
}
|
|
100619
100631
|
|
|
100632
|
+
// src/tools/_receipt.ts
|
|
100633
|
+
import { createHash as createHash4 } from "crypto";
|
|
100634
|
+
var MAX_RECEIPT_BYTES = 10 * 1024 * 1024;
|
|
100635
|
+
var LIMIT_LABEL = "10 MiB";
|
|
100636
|
+
var RECEIPT_TYPES = [
|
|
100637
|
+
{ mime: "image/png", ext: "png", magic: [137, 80, 78, 71, 13, 10, 26, 10] },
|
|
100638
|
+
{ mime: "image/jpeg", ext: "jpg", magic: [255, 216, 255] },
|
|
100639
|
+
{ mime: "image/gif", ext: "gif", magic: [71, 73, 70, 56] },
|
|
100640
|
+
{ mime: "application/pdf", ext: "pdf", magic: [37, 80, 68, 70, 45] }
|
|
100641
|
+
];
|
|
100642
|
+
var MIME_ALIASES = { "image/jpg": "image/jpeg", "image/pjpeg": "image/jpeg" };
|
|
100643
|
+
var receiptSchema = external_exports.object({
|
|
100644
|
+
base64: external_exports.string().min(1).describe("The file bytes, base64-encoded (a data: URL prefix is accepted and stripped). The normal way to pass a receipt.").optional(),
|
|
100645
|
+
path: external_exports.string().min(1).describe(
|
|
100646
|
+
"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 base64 there."
|
|
100647
|
+
).optional(),
|
|
100648
|
+
filename: external_exports.string().min(1).max(200).describe('Name to store the file under, e.g. "receipt.pdf".'),
|
|
100649
|
+
mime_type: external_exports.string().describe("Optional. If given, it must match the type detected from the file contents.").optional()
|
|
100650
|
+
}).describe(
|
|
100651
|
+
`Receipt to attach: PNG, JPEG, GIF or PDF, up to ${LIMIT_LABEL}. Give exactly one of base64 or path. The type is detected from the file contents. Replaces any receipt already on the expense.`
|
|
100652
|
+
);
|
|
100653
|
+
var receiptParam = receiptSchema.optional();
|
|
100654
|
+
var tooBig = (size) => new Error(`Receipt is ${size} bytes, over the ${LIMIT_LABEL} limit (${MAX_RECEIPT_BYTES} bytes)`);
|
|
100655
|
+
function sniff(bytes) {
|
|
100656
|
+
return RECEIPT_TYPES.find((t2) => t2.magic.every((b2, i2) => bytes[i2] === b2));
|
|
100657
|
+
}
|
|
100658
|
+
function decodeBase64(input2) {
|
|
100659
|
+
const stripped = input2.replace(/^data:[^;,]*;base64,/i, "").replace(/\s+/g, "");
|
|
100660
|
+
const decodedSize = Math.floor(stripped.length * 3 / 4) - (stripped.match(/=*$/)?.[0].length ?? 0);
|
|
100661
|
+
if (decodedSize > MAX_RECEIPT_BYTES) throw tooBig(decodedSize);
|
|
100662
|
+
if (!/^[A-Za-z0-9+/]*={0,2}$/.test(stripped) || stripped.length % 4 === 1) {
|
|
100663
|
+
throw new Error("receipt.base64 is not valid base64");
|
|
100664
|
+
}
|
|
100665
|
+
return Uint8Array.from(Buffer.from(stripped, "base64"));
|
|
100666
|
+
}
|
|
100667
|
+
function cleanFilename(name) {
|
|
100668
|
+
const base = name.split(/[\\/]/).pop() ?? "";
|
|
100669
|
+
return base.replace(/[\u0000-\u001f\u007f"]/g, "").trim();
|
|
100670
|
+
}
|
|
100671
|
+
async function readSandboxed(requested) {
|
|
100672
|
+
const dir = readEnvVar("SPLITWISE_INPUT_DIR");
|
|
100673
|
+
if (!dir) {
|
|
100674
|
+
throw new Error(
|
|
100675
|
+
"receipt.path is disabled: SPLITWISE_INPUT_DIR is not set on this server (and a hosted server cannot see your files). Pass the file as receipt.base64 instead."
|
|
100676
|
+
);
|
|
100677
|
+
}
|
|
100678
|
+
const { open: open2, realpath } = await import("fs/promises");
|
|
100679
|
+
const { constants } = await import("fs");
|
|
100680
|
+
const { isAbsolute: isAbsolute4, relative: relative2, resolve: resolve4, sep: sep2 } = await import("path");
|
|
100681
|
+
let root;
|
|
100682
|
+
try {
|
|
100683
|
+
root = await realpath(dir);
|
|
100684
|
+
} catch (err) {
|
|
100685
|
+
throw new Error(`SPLITWISE_INPUT_DIR (${dir}) is not readable: ${err.message}`);
|
|
100686
|
+
}
|
|
100687
|
+
let target;
|
|
100688
|
+
try {
|
|
100689
|
+
target = await realpath(resolve4(root, requested));
|
|
100690
|
+
} catch (err) {
|
|
100691
|
+
throw new Error(`Cannot read receipt file ${requested}: ${err.code ?? err.message}`);
|
|
100692
|
+
}
|
|
100693
|
+
const rel = relative2(root, target);
|
|
100694
|
+
if (rel === "" || rel === ".." || rel.startsWith(`..${sep2}`) || isAbsolute4(rel)) {
|
|
100695
|
+
throw new Error(`receipt.path ${requested} resolves outside SPLITWISE_INPUT_DIR`);
|
|
100696
|
+
}
|
|
100697
|
+
const handle = await open2(target, constants.O_RDONLY | constants.O_NOFOLLOW);
|
|
100698
|
+
try {
|
|
100699
|
+
const info2 = await handle.stat();
|
|
100700
|
+
if (!info2.isFile()) throw new Error(`Cannot read receipt file ${requested}: not a regular file`);
|
|
100701
|
+
if (info2.size > MAX_RECEIPT_BYTES) throw tooBig(info2.size);
|
|
100702
|
+
return Uint8Array.from(await handle.readFile());
|
|
100703
|
+
} finally {
|
|
100704
|
+
await handle.close();
|
|
100705
|
+
}
|
|
100706
|
+
}
|
|
100707
|
+
async function loadReceipt(input2) {
|
|
100708
|
+
const hasPath = input2.path !== void 0;
|
|
100709
|
+
const hasBase64 = input2.base64 !== void 0;
|
|
100710
|
+
if (hasPath === hasBase64) {
|
|
100711
|
+
throw new Error("receipt needs exactly one of path or base64");
|
|
100712
|
+
}
|
|
100713
|
+
const bytes = hasPath ? await readSandboxed(input2.path) : decodeBase64(input2.base64);
|
|
100714
|
+
if (bytes.length === 0) throw new Error("Receipt file is empty");
|
|
100715
|
+
if (bytes.length > MAX_RECEIPT_BYTES) throw tooBig(bytes.length);
|
|
100716
|
+
const type = sniff(bytes);
|
|
100717
|
+
if (!type) {
|
|
100718
|
+
throw new Error("Receipt must be a PNG, JPEG, GIF or PDF (detected from the file contents)");
|
|
100719
|
+
}
|
|
100720
|
+
if (input2.mime_type !== void 0) {
|
|
100721
|
+
const claimed = input2.mime_type.trim().toLowerCase();
|
|
100722
|
+
if ((MIME_ALIASES[claimed] ?? claimed) !== type.mime) {
|
|
100723
|
+
throw new Error(`receipt.mime_type ${claimed} does not match the file contents (${type.mime})`);
|
|
100724
|
+
}
|
|
100725
|
+
}
|
|
100726
|
+
const filename = cleanFilename(input2.filename) || `receipt.${type.ext}`;
|
|
100727
|
+
const sha256 = createHash4("sha256").update(bytes).digest("hex");
|
|
100728
|
+
return {
|
|
100729
|
+
bytes,
|
|
100730
|
+
filename,
|
|
100731
|
+
mimeType: type.mime,
|
|
100732
|
+
summary: { filename, mime_type: type.mime, bytes: bytes.length, sha256 }
|
|
100733
|
+
};
|
|
100734
|
+
}
|
|
100735
|
+
function buildReceiptForm(fields, receipt) {
|
|
100736
|
+
const form = new FormData();
|
|
100737
|
+
for (const [key, value] of Object.entries(fields)) {
|
|
100738
|
+
if (value === void 0 || value === null) continue;
|
|
100739
|
+
form.append(key, String(value));
|
|
100740
|
+
}
|
|
100741
|
+
form.append("receipt", new Blob([receipt.bytes], { type: receipt.mimeType }), receipt.filename);
|
|
100742
|
+
return form;
|
|
100743
|
+
}
|
|
100744
|
+
function verifyReceiptStored(data, expenseId) {
|
|
100745
|
+
const expense = data?.expenses?.[0];
|
|
100746
|
+
const id = expense?.id ?? expenseId;
|
|
100747
|
+
if (!expense?.receipt?.original) {
|
|
100748
|
+
throw new Error(
|
|
100749
|
+
`Splitwise saved expense ${id ?? "(unknown id)"} but did not attach the receipt (receipt.original is empty). The expense itself was written \u2014 do not re-create it; retry the file with sw_attach_receipt on expense ${id ?? "<id>"}.`
|
|
100750
|
+
);
|
|
100751
|
+
}
|
|
100752
|
+
return { receipt_attached: true, receipt: expense.receipt, ...data };
|
|
100753
|
+
}
|
|
100754
|
+
|
|
100620
100755
|
// src/tools/expenses.ts
|
|
100621
100756
|
function flattenUsers(users) {
|
|
100622
100757
|
const flat = {};
|
|
@@ -100633,6 +100768,7 @@ function buildExpenseBody(args) {
|
|
|
100633
100768
|
users,
|
|
100634
100769
|
expense_id: _id,
|
|
100635
100770
|
confirmToken: _confirmToken,
|
|
100771
|
+
receipt: _receipt,
|
|
100636
100772
|
...rest
|
|
100637
100773
|
} = args;
|
|
100638
100774
|
if (split_equally && users) {
|
|
@@ -100646,6 +100782,14 @@ function buildExpenseBody(args) {
|
|
|
100646
100782
|
}
|
|
100647
100783
|
return body;
|
|
100648
100784
|
}
|
|
100785
|
+
async function sendExpenseWrite(client2, path, body, receipt, expenseId) {
|
|
100786
|
+
if (!receipt) return client2.request("POST", path, body);
|
|
100787
|
+
const data = await client2.requestMultipart("POST", path, buildReceiptForm(body, receipt));
|
|
100788
|
+
return verifyReceiptStored(data, expenseId);
|
|
100789
|
+
}
|
|
100790
|
+
function previewBody(body, receipt) {
|
|
100791
|
+
return receipt ? { ...body, receipt: receipt.summary } : body;
|
|
100792
|
+
}
|
|
100649
100793
|
var userShareSchema = external_exports.object({
|
|
100650
100794
|
user_id: external_exports.number(),
|
|
100651
100795
|
paid_share: external_exports.string().describe('Amount this user paid, e.g. "25.00"'),
|
|
@@ -100708,7 +100852,7 @@ function registerExpenseTools(server, client2) {
|
|
|
100708
100852
|
server.registerTool(
|
|
100709
100853
|
"sw_create_expense",
|
|
100710
100854
|
{
|
|
100711
|
-
description: `Create a Splitwise expense. Use split_equally:true to split evenly among group members, or provide a users array for custom per-person splits (paid_share and owed_share as decimal strings like "25.00"). cost must be a decimal string. This puts charges on every participant's balance and notifies them. ${CONFIRM_NOTE}`,
|
|
100855
|
+
description: `Create a Splitwise expense. Use split_equally:true to split evenly among group members, or provide a users array for custom per-person splits (paid_share and owed_share as decimal strings like "25.00"). cost must be a decimal string. Pass receipt to attach an image or PDF in the same call. This puts charges on every participant's balance and notifies them. ${CONFIRM_NOTE}`,
|
|
100712
100856
|
annotations: { readOnlyHint: false, destructiveHint: true, openWorldHint: true },
|
|
100713
100857
|
inputSchema: external_exports.object({
|
|
100714
100858
|
group_id: external_exports.number().describe("Group to add expense to (use 0 for no group)"),
|
|
@@ -100722,31 +100866,33 @@ function registerExpenseTools(server, client2) {
|
|
|
100722
100866
|
date: external_exports.string().describe("ISO 8601 datetime").optional(),
|
|
100723
100867
|
category_id: external_exports.number().describe("Category id from sw_get_categories").optional(),
|
|
100724
100868
|
details: external_exports.string().describe("Notes").optional(),
|
|
100869
|
+
receipt: receiptParam,
|
|
100725
100870
|
confirmToken: confirmTokenParam
|
|
100726
100871
|
})
|
|
100727
100872
|
},
|
|
100728
100873
|
async (args, ctx) => {
|
|
100729
100874
|
const body = buildExpenseBody(args);
|
|
100875
|
+
const receipt = args.receipt ? await loadReceipt(args.receipt) : void 0;
|
|
100730
100876
|
const gate = await confirmWrite(ctx, {
|
|
100731
100877
|
tool: "sw_create_expense",
|
|
100732
100878
|
action: "expense.create",
|
|
100733
|
-
summary: `Create a Splitwise expense "${args.description}" (${args.cost}) \u2014 notifies group members`,
|
|
100879
|
+
summary: `Create a Splitwise expense "${args.description}" (${args.cost})${receipt ? ` with receipt ${receipt.filename}` : ""} \u2014 notifies group members`,
|
|
100734
100880
|
method: "POST",
|
|
100735
100881
|
path: "/create_expense",
|
|
100736
|
-
body,
|
|
100882
|
+
body: previewBody(body, receipt),
|
|
100737
100883
|
target: args.group_id,
|
|
100738
100884
|
confirmToken: args.confirmToken,
|
|
100739
100885
|
args
|
|
100740
100886
|
});
|
|
100741
100887
|
if (gate) return gate;
|
|
100742
|
-
const data = await client2
|
|
100888
|
+
const data = await sendExpenseWrite(client2, "/create_expense", body, receipt);
|
|
100743
100889
|
return minifiedResult(data);
|
|
100744
100890
|
}
|
|
100745
100891
|
);
|
|
100746
100892
|
server.registerTool(
|
|
100747
100893
|
"sw_update_expense",
|
|
100748
100894
|
{
|
|
100749
|
-
description: `Edit an existing Splitwise expense. Provide expense_id and any fields to change. For custom split updates, the full users array must be provided (the API replaces the entire split). ${CONFIRM_NOTE}`,
|
|
100895
|
+
description: `Edit an existing Splitwise expense. Provide expense_id and any fields to change. For custom split updates, the full users array must be provided (the API replaces the entire split). To attach or replace the receipt on an existing expense, pass expense_id and receipt alone. ${CONFIRM_NOTE}`,
|
|
100750
100896
|
annotations: { readOnlyHint: false, destructiveHint: true, openWorldHint: true },
|
|
100751
100897
|
inputSchema: external_exports.object({
|
|
100752
100898
|
expense_id: external_exports.number().describe("ID of the expense to update"),
|
|
@@ -100760,25 +100906,58 @@ function registerExpenseTools(server, client2) {
|
|
|
100760
100906
|
date: external_exports.string().optional(),
|
|
100761
100907
|
category_id: external_exports.number().optional(),
|
|
100762
100908
|
details: external_exports.string().optional(),
|
|
100909
|
+
receipt: receiptParam,
|
|
100763
100910
|
confirmToken: confirmTokenParam
|
|
100764
100911
|
})
|
|
100765
100912
|
},
|
|
100766
100913
|
async (args, ctx) => {
|
|
100767
100914
|
const { expense_id } = args;
|
|
100768
100915
|
const body = buildExpenseBody(args);
|
|
100916
|
+
const receipt = args.receipt ? await loadReceipt(args.receipt) : void 0;
|
|
100769
100917
|
const gate = await confirmWrite(ctx, {
|
|
100770
100918
|
tool: "sw_update_expense",
|
|
100771
100919
|
action: "expense.update",
|
|
100772
|
-
summary: `Update Splitwise expense ${expense_id} \u2014 notifies group members`,
|
|
100920
|
+
summary: `Update Splitwise expense ${expense_id}${receipt ? ` (attach receipt ${receipt.filename})` : ""} \u2014 notifies group members`,
|
|
100773
100921
|
method: "POST",
|
|
100774
100922
|
path: `/update_expense/${expense_id}`,
|
|
100775
|
-
body,
|
|
100923
|
+
body: previewBody(body, receipt),
|
|
100924
|
+
target: expense_id,
|
|
100925
|
+
confirmToken: args.confirmToken,
|
|
100926
|
+
args
|
|
100927
|
+
});
|
|
100928
|
+
if (gate) return gate;
|
|
100929
|
+
const data = await sendExpenseWrite(client2, `/update_expense/${expense_id}`, body, receipt, expense_id);
|
|
100930
|
+
return minifiedResult(data);
|
|
100931
|
+
}
|
|
100932
|
+
);
|
|
100933
|
+
server.registerTool(
|
|
100934
|
+
"sw_attach_receipt",
|
|
100935
|
+
{
|
|
100936
|
+
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.base64. Fails if Splitwise does not store the file. ${CONFIRM_NOTE}`,
|
|
100937
|
+
annotations: { readOnlyHint: false, destructiveHint: true, idempotentHint: true, openWorldHint: true },
|
|
100938
|
+
inputSchema: external_exports.object({
|
|
100939
|
+
expense_id: external_exports.number().describe("ID of the expense to attach the receipt to"),
|
|
100940
|
+
receipt: receiptSchema,
|
|
100941
|
+
confirmToken: confirmTokenParam
|
|
100942
|
+
})
|
|
100943
|
+
},
|
|
100944
|
+
async (args, ctx) => {
|
|
100945
|
+
const { expense_id } = args;
|
|
100946
|
+
const receipt = await loadReceipt(args.receipt);
|
|
100947
|
+
const path = `/update_expense/${expense_id}`;
|
|
100948
|
+
const gate = await confirmWrite(ctx, {
|
|
100949
|
+
tool: "sw_attach_receipt",
|
|
100950
|
+
action: "expense.attach_receipt",
|
|
100951
|
+
summary: `Attach receipt ${receipt.filename} to Splitwise expense ${expense_id} (replaces any existing receipt)`,
|
|
100952
|
+
method: "POST",
|
|
100953
|
+
path,
|
|
100954
|
+
body: previewBody({}, receipt),
|
|
100776
100955
|
target: expense_id,
|
|
100777
100956
|
confirmToken: args.confirmToken,
|
|
100778
100957
|
args
|
|
100779
100958
|
});
|
|
100780
100959
|
if (gate) return gate;
|
|
100781
|
-
const data = await client2
|
|
100960
|
+
const data = await sendExpenseWrite(client2, path, {}, receipt, expense_id);
|
|
100782
100961
|
return minifiedResult(data);
|
|
100783
100962
|
}
|
|
100784
100963
|
);
|
|
@@ -100845,7 +101024,7 @@ var MAX_INLINE_BYTES = 4 * 1024 * 1024;
|
|
|
100845
101024
|
var MAX_TEXT_CHARS = 1e5;
|
|
100846
101025
|
var PDF_MIME = "application/pdf";
|
|
100847
101026
|
var OUTPUT_DIR_NAME = "splitwise-mcp";
|
|
100848
|
-
var
|
|
101027
|
+
var MAX_RECEIPT_BYTES2 = 25 * 1024 * 1024;
|
|
100849
101028
|
var RECEIPT_FILE_MODE = 384;
|
|
100850
101029
|
var GENERIC_TYPES = /* @__PURE__ */ new Set(["application/octet-stream", "binary/octet-stream"]);
|
|
100851
101030
|
function isPdf(bytes) {
|
|
@@ -100918,7 +101097,7 @@ function registerReceiptTools(server, client2) {
|
|
|
100918
101097
|
throw new Error(`Splitwise expense ${id} has no receipt attached.`);
|
|
100919
101098
|
}
|
|
100920
101099
|
const served = receipt?.[wanted] ? wanted : fallback;
|
|
100921
|
-
const asset = await client2.fetchAsset(url4, { maxBytes:
|
|
101100
|
+
const asset = await client2.fetchAsset(url4, { maxBytes: MAX_RECEIPT_BYTES2 });
|
|
100922
101101
|
if (asset.bytes.length === 0) {
|
|
100923
101102
|
throw new Error(`Splitwise returned an empty receipt body for expense ${id}.`);
|
|
100924
101103
|
}
|
|
@@ -101144,7 +101323,7 @@ function registerUtilityTools(server, client2) {
|
|
|
101144
101323
|
// src/index.ts
|
|
101145
101324
|
await runMcp({
|
|
101146
101325
|
name: "splitwise-mcp",
|
|
101147
|
-
version: "3.
|
|
101326
|
+
version: "3.4.0",
|
|
101148
101327
|
// x-release-please-version
|
|
101149
101328
|
deps: client,
|
|
101150
101329
|
banner: "[splitwise-mcp] This project was developed and is maintained by AI (Claude Sonnet 4.6). Use at your own discretion.",
|
package/dist/client.js
CHANGED
|
@@ -128,7 +128,9 @@ export class SplitwiseClient {
|
|
|
128
128
|
* Optional constructor seam: a hosted per-user deployment builds one client
|
|
129
129
|
* per request with that user's `apiKey` injected. The stdio path passes no
|
|
130
130
|
* options, so the key resolves from the environment exactly as before —
|
|
131
|
-
* byte-for-byte identical behaviour.
|
|
131
|
+
* byte-for-byte identical behaviour. `baseUrl` points the API at another
|
|
132
|
+
* origin (the HTTP integration tests' fake Splitwise); it defaults to the
|
|
133
|
+
* real API.
|
|
132
134
|
*/
|
|
133
135
|
constructor(opts) {
|
|
134
136
|
// Injected apiKey (hosted per-user path) takes precedence; otherwise fall
|
|
@@ -153,7 +155,7 @@ export class SplitwiseClient {
|
|
|
153
155
|
this.apiKey = key;
|
|
154
156
|
this.configError = null;
|
|
155
157
|
}
|
|
156
|
-
this.api = this.buildApiClient(BASE_URL, true);
|
|
158
|
+
this.api = this.buildApiClient(opts?.baseUrl ?? BASE_URL, true);
|
|
157
159
|
}
|
|
158
160
|
/**
|
|
159
161
|
* Which source supplied the API key, for `sw_healthcheck`. Returns a LABEL
|
|
@@ -197,6 +199,16 @@ export class SplitwiseClient {
|
|
|
197
199
|
assertWriteSucceeded(data);
|
|
198
200
|
return data;
|
|
199
201
|
}
|
|
202
|
+
/**
|
|
203
|
+
* A write sent as multipart/form-data — the only encoding Splitwise accepts
|
|
204
|
+
* for an expense that carries a receipt file. Same auth, retry and
|
|
205
|
+
* write-rejection handling as `request`.
|
|
206
|
+
*/
|
|
207
|
+
async requestMultipart(method, path, formData) {
|
|
208
|
+
const data = await this.api.fetchJson(method, path, { formData });
|
|
209
|
+
assertWriteSucceeded(data);
|
|
210
|
+
return data;
|
|
211
|
+
}
|
|
200
212
|
/**
|
|
201
213
|
* Fetch the raw bytes behind an absolute asset URL the API handed back (an
|
|
202
214
|
* expense receipt). Splitwise serves receipts two ways and this covers both:
|
package/dist/index.js
CHANGED
|
@@ -19,7 +19,7 @@ import { registerUtilityTools } from './tools/utilities.js';
|
|
|
19
19
|
// client into the same registrars instead of this singleton.
|
|
20
20
|
await runMcp({
|
|
21
21
|
name: 'splitwise-mcp',
|
|
22
|
-
version: '3.
|
|
22
|
+
version: '3.4.0', // x-release-please-version
|
|
23
23
|
deps: client,
|
|
24
24
|
banner: '[splitwise-mcp] This project was developed and is maintained by AI (Claude Sonnet 4.6). Use at your own discretion.',
|
|
25
25
|
tools: [
|
|
@@ -0,0 +1,173 @@
|
|
|
1
|
+
import { createHash } from 'crypto';
|
|
2
|
+
import { readEnvVar } from '@chrischall/mcp-utils';
|
|
3
|
+
import { z } from 'zod';
|
|
4
|
+
/**
|
|
5
|
+
* Splitwise does not document an upload cap, so this is a conservative 10 MiB:
|
|
6
|
+
* comfortably above a phone photo or a multi-page PDF receipt, and small
|
|
7
|
+
* enough that the base64 form (≈13.4 MiB) stays a reasonable tool argument.
|
|
8
|
+
*/
|
|
9
|
+
export const MAX_RECEIPT_BYTES = 10 * 1024 * 1024;
|
|
10
|
+
const LIMIT_LABEL = '10 MiB';
|
|
11
|
+
/** The types the Splitwise web UI offers ("Attach an image or PDF"). */
|
|
12
|
+
const RECEIPT_TYPES = [
|
|
13
|
+
{ mime: 'image/png', ext: 'png', magic: [0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a] },
|
|
14
|
+
{ mime: 'image/jpeg', ext: 'jpg', magic: [0xff, 0xd8, 0xff] },
|
|
15
|
+
{ mime: 'image/gif', ext: 'gif', magic: [0x47, 0x49, 0x46, 0x38] },
|
|
16
|
+
{ mime: 'application/pdf', ext: 'pdf', magic: [0x25, 0x50, 0x44, 0x46, 0x2d] },
|
|
17
|
+
];
|
|
18
|
+
/** Spellings a caller may reasonably send for a detected type. */
|
|
19
|
+
const MIME_ALIASES = { 'image/jpg': 'image/jpeg', 'image/pjpeg': 'image/jpeg' };
|
|
20
|
+
export const receiptSchema = z
|
|
21
|
+
.object({
|
|
22
|
+
base64: z
|
|
23
|
+
.string()
|
|
24
|
+
.min(1)
|
|
25
|
+
.describe('The file bytes, base64-encoded (a data: URL prefix is accepted and stripped). The normal way to pass a receipt.')
|
|
26
|
+
.optional(),
|
|
27
|
+
path: z
|
|
28
|
+
.string()
|
|
29
|
+
.min(1)
|
|
30
|
+
.describe('A file inside SPLITWISE_INPUT_DIR on the machine running this server (relative to it, or absolute). Only works when that variable is set — never on a hosted server; use base64 there.')
|
|
31
|
+
.optional(),
|
|
32
|
+
filename: z.string().min(1).max(200).describe('Name to store the file under, e.g. "receipt.pdf".'),
|
|
33
|
+
mime_type: z
|
|
34
|
+
.string()
|
|
35
|
+
.describe('Optional. If given, it must match the type detected from the file contents.')
|
|
36
|
+
.optional(),
|
|
37
|
+
})
|
|
38
|
+
.describe(`Receipt to attach: PNG, JPEG, GIF or PDF, up to ${LIMIT_LABEL}. Give exactly one of base64 or path. The type is detected from the file contents. Replaces any receipt already on the expense.`);
|
|
39
|
+
export const receiptParam = receiptSchema.optional();
|
|
40
|
+
const tooBig = (size) => new Error(`Receipt is ${size} bytes, over the ${LIMIT_LABEL} limit (${MAX_RECEIPT_BYTES} bytes)`);
|
|
41
|
+
function sniff(bytes) {
|
|
42
|
+
return RECEIPT_TYPES.find((t) => t.magic.every((b, i) => bytes[i] === b));
|
|
43
|
+
}
|
|
44
|
+
function decodeBase64(input) {
|
|
45
|
+
const stripped = input.replace(/^data:[^;,]*;base64,/i, '').replace(/\s+/g, '');
|
|
46
|
+
// Refuse on the encoded length so an oversized argument is never decoded.
|
|
47
|
+
const decodedSize = Math.floor((stripped.length * 3) / 4) - (stripped.match(/=*$/)?.[0].length ?? 0);
|
|
48
|
+
if (decodedSize > MAX_RECEIPT_BYTES)
|
|
49
|
+
throw tooBig(decodedSize);
|
|
50
|
+
// Never echo the input: it is the caller's file.
|
|
51
|
+
if (!/^[A-Za-z0-9+/]*={0,2}$/.test(stripped) || stripped.length % 4 === 1) {
|
|
52
|
+
throw new Error('receipt.base64 is not valid base64');
|
|
53
|
+
}
|
|
54
|
+
return Uint8Array.from(Buffer.from(stripped, 'base64'));
|
|
55
|
+
}
|
|
56
|
+
/** Keep the stored name to a plain basename: no directories, no control characters. */
|
|
57
|
+
function cleanFilename(name) {
|
|
58
|
+
const base = name.split(/[\\/]/).pop() ?? '';
|
|
59
|
+
return base.replace(/[\u0000-\u001f\u007f"]/g, '').trim();
|
|
60
|
+
}
|
|
61
|
+
/**
|
|
62
|
+
* Read a receipt from SPLITWISE_INPUT_DIR. Unset means no filesystem access at
|
|
63
|
+
* all — the hosted case, where the caller's files are not on this machine and
|
|
64
|
+
* the server's own files must not be reachable. Both the dir and the target
|
|
65
|
+
* are realpath-resolved, so `..` and symlinks (to a file or through a
|
|
66
|
+
* directory) that lead out of the dir are refused; the bytes are then read
|
|
67
|
+
* through the handle that was checked, not by re-opening the name.
|
|
68
|
+
*/
|
|
69
|
+
async function readSandboxed(requested) {
|
|
70
|
+
const dir = readEnvVar('SPLITWISE_INPUT_DIR');
|
|
71
|
+
if (!dir) {
|
|
72
|
+
throw new Error('receipt.path is disabled: SPLITWISE_INPUT_DIR is not set on this server (and a hosted server cannot see your files). Pass the file as receipt.base64 instead.');
|
|
73
|
+
}
|
|
74
|
+
const { open, realpath } = await import('fs/promises');
|
|
75
|
+
const { constants } = await import('fs');
|
|
76
|
+
const { isAbsolute, relative, resolve, sep } = await import('path');
|
|
77
|
+
let root;
|
|
78
|
+
try {
|
|
79
|
+
root = await realpath(dir);
|
|
80
|
+
}
|
|
81
|
+
catch (err) {
|
|
82
|
+
throw new Error(`SPLITWISE_INPUT_DIR (${dir}) is not readable: ${err.message}`);
|
|
83
|
+
}
|
|
84
|
+
let target;
|
|
85
|
+
try {
|
|
86
|
+
target = await realpath(resolve(root, requested));
|
|
87
|
+
}
|
|
88
|
+
catch (err) {
|
|
89
|
+
throw new Error(`Cannot read receipt file ${requested}: ${err.code ?? err.message}`);
|
|
90
|
+
}
|
|
91
|
+
const rel = relative(root, target);
|
|
92
|
+
if (rel === '' || rel === '..' || rel.startsWith(`..${sep}`) || isAbsolute(rel)) {
|
|
93
|
+
throw new Error(`receipt.path ${requested} resolves outside SPLITWISE_INPUT_DIR`);
|
|
94
|
+
}
|
|
95
|
+
// O_NOFOLLOW: a symlink swapped in after the realpath check is refused.
|
|
96
|
+
const handle = await open(target, constants.O_RDONLY | constants.O_NOFOLLOW);
|
|
97
|
+
try {
|
|
98
|
+
const info = await handle.stat();
|
|
99
|
+
if (!info.isFile())
|
|
100
|
+
throw new Error(`Cannot read receipt file ${requested}: not a regular file`);
|
|
101
|
+
if (info.size > MAX_RECEIPT_BYTES)
|
|
102
|
+
throw tooBig(info.size);
|
|
103
|
+
return Uint8Array.from(await handle.readFile());
|
|
104
|
+
}
|
|
105
|
+
finally {
|
|
106
|
+
await handle.close();
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
/**
|
|
110
|
+
* Read and validate a receipt before the confirmation gate, so a bad file is
|
|
111
|
+
* refused on the first call and the preview can name exactly what will be sent.
|
|
112
|
+
*/
|
|
113
|
+
export async function loadReceipt(input) {
|
|
114
|
+
const hasPath = input.path !== undefined;
|
|
115
|
+
const hasBase64 = input.base64 !== undefined;
|
|
116
|
+
if (hasPath === hasBase64) {
|
|
117
|
+
throw new Error('receipt needs exactly one of path or base64');
|
|
118
|
+
}
|
|
119
|
+
const bytes = hasPath ? await readSandboxed(input.path) : decodeBase64(input.base64);
|
|
120
|
+
if (bytes.length === 0)
|
|
121
|
+
throw new Error('Receipt file is empty');
|
|
122
|
+
if (bytes.length > MAX_RECEIPT_BYTES)
|
|
123
|
+
throw tooBig(bytes.length);
|
|
124
|
+
const type = sniff(bytes);
|
|
125
|
+
if (!type) {
|
|
126
|
+
throw new Error('Receipt must be a PNG, JPEG, GIF or PDF (detected from the file contents)');
|
|
127
|
+
}
|
|
128
|
+
if (input.mime_type !== undefined) {
|
|
129
|
+
const claimed = input.mime_type.trim().toLowerCase();
|
|
130
|
+
if ((MIME_ALIASES[claimed] ?? claimed) !== type.mime) {
|
|
131
|
+
throw new Error(`receipt.mime_type ${claimed} does not match the file contents (${type.mime})`);
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
const filename = cleanFilename(input.filename) || `receipt.${type.ext}`;
|
|
135
|
+
const sha256 = createHash('sha256').update(bytes).digest('hex');
|
|
136
|
+
return {
|
|
137
|
+
bytes,
|
|
138
|
+
filename,
|
|
139
|
+
mimeType: type.mime,
|
|
140
|
+
summary: { filename, mime_type: type.mime, bytes: bytes.length, sha256 },
|
|
141
|
+
};
|
|
142
|
+
}
|
|
143
|
+
/**
|
|
144
|
+
* Build the multipart body Splitwise takes when an expense carries a file:
|
|
145
|
+
* every expense field as a form field (the same flat `users__N__*` keys the
|
|
146
|
+
* JSON path sends), plus the file under `receipt`.
|
|
147
|
+
*/
|
|
148
|
+
export function buildReceiptForm(fields, receipt) {
|
|
149
|
+
const form = new FormData();
|
|
150
|
+
for (const [key, value] of Object.entries(fields)) {
|
|
151
|
+
if (value === undefined || value === null)
|
|
152
|
+
continue;
|
|
153
|
+
form.append(key, String(value));
|
|
154
|
+
}
|
|
155
|
+
form.append('receipt', new Blob([receipt.bytes], { type: receipt.mimeType }), receipt.filename);
|
|
156
|
+
return form;
|
|
157
|
+
}
|
|
158
|
+
/**
|
|
159
|
+
* After a receipt write, confirm Splitwise actually stored the file. It can
|
|
160
|
+
* answer 200 with the expense saved and `receipt.original` still null; that is
|
|
161
|
+
* a failure the caller must hear about, not a success. Returns the response
|
|
162
|
+
* with `receipt_attached` and the expense's `receipt` lifted to the top.
|
|
163
|
+
*/
|
|
164
|
+
export function verifyReceiptStored(data, expenseId) {
|
|
165
|
+
const expense = data
|
|
166
|
+
?.expenses?.[0];
|
|
167
|
+
const id = expense?.id ?? expenseId;
|
|
168
|
+
if (!expense?.receipt?.original) {
|
|
169
|
+
throw new Error(`Splitwise saved expense ${id ?? '(unknown id)'} but did not attach the receipt (receipt.original is empty). ` +
|
|
170
|
+
`The expense itself was written — do not re-create it; retry the file with sw_attach_receipt on expense ${id ?? '<id>'}.`);
|
|
171
|
+
}
|
|
172
|
+
return { receipt_attached: true, receipt: expense.receipt, ...data };
|
|
173
|
+
}
|
package/dist/tools/expenses.js
CHANGED
|
@@ -3,6 +3,7 @@ import { SW_VIEWS, viewExpense, viewExpenses } from '../project.js';
|
|
|
3
3
|
import { buildQueryString, minifiedResult, resolveView, viewParam } from '@chrischall/mcp-utils';
|
|
4
4
|
import { CONFIRM_NOTE, confirmTokenParam, confirmWrite } from './_confirm.js';
|
|
5
5
|
import { UNTRUSTED_DESCRIPTION_SUFFIX, swUntrustedResult } from './_untrusted.js';
|
|
6
|
+
import { buildReceiptForm, loadReceipt, receiptParam, receiptSchema, verifyReceiptStored, } from './_receipt.js';
|
|
6
7
|
/** Flattens a users array into Splitwise's flat-param JSON format. */
|
|
7
8
|
export function flattenUsers(users) {
|
|
8
9
|
const flat = {};
|
|
@@ -14,8 +15,9 @@ export function flattenUsers(users) {
|
|
|
14
15
|
return flat;
|
|
15
16
|
}
|
|
16
17
|
function buildExpenseBody(args) {
|
|
17
|
-
// `confirmToken` is a tool-gate input
|
|
18
|
-
|
|
18
|
+
// `confirmToken` is a tool-gate input and `receipt` is sent as a file part,
|
|
19
|
+
// so neither belongs in the expense fields.
|
|
20
|
+
const { split_equally, users, expense_id: _id, confirmToken: _confirmToken, receipt: _receipt, ...rest } = args;
|
|
19
21
|
if (split_equally && users) {
|
|
20
22
|
throw new Error('Provide either split_equally or users, not both');
|
|
21
23
|
}
|
|
@@ -28,6 +30,22 @@ function buildExpenseBody(args) {
|
|
|
28
30
|
}
|
|
29
31
|
return body;
|
|
30
32
|
}
|
|
33
|
+
/**
|
|
34
|
+
* Send a create/update. With a receipt the whole expense goes as multipart
|
|
35
|
+
* (Splitwise only takes a file that way) and the response must show the file
|
|
36
|
+
* stored; without one it stays JSON and the response passes through, exactly
|
|
37
|
+
* as before.
|
|
38
|
+
*/
|
|
39
|
+
async function sendExpenseWrite(client, path, body, receipt, expenseId) {
|
|
40
|
+
if (!receipt)
|
|
41
|
+
return client.request('POST', path, body);
|
|
42
|
+
const data = await client.requestMultipart('POST', path, buildReceiptForm(body, receipt));
|
|
43
|
+
return verifyReceiptStored(data, expenseId);
|
|
44
|
+
}
|
|
45
|
+
/** The body the confirmation preview shows: the fields, plus the file's identity in place of its bytes. */
|
|
46
|
+
function previewBody(body, receipt) {
|
|
47
|
+
return receipt ? { ...body, receipt: receipt.summary } : body;
|
|
48
|
+
}
|
|
31
49
|
const userShareSchema = z.object({
|
|
32
50
|
user_id: z.number(),
|
|
33
51
|
paid_share: z.string().describe('Amount this user paid, e.g. "25.00"'),
|
|
@@ -92,7 +110,7 @@ export function registerExpenseTools(server, client) {
|
|
|
92
110
|
return swUntrustedResult(viewExpense(resolveView(view, SW_VIEWS), data));
|
|
93
111
|
});
|
|
94
112
|
server.registerTool('sw_create_expense', {
|
|
95
|
-
description: `Create a Splitwise expense. Use split_equally:true to split evenly among group members, or provide a users array for custom per-person splits (paid_share and owed_share as decimal strings like "25.00"). cost must be a decimal string. This puts charges on every participant's balance and notifies them. ${CONFIRM_NOTE}`,
|
|
113
|
+
description: `Create a Splitwise expense. Use split_equally:true to split evenly among group members, or provide a users array for custom per-person splits (paid_share and owed_share as decimal strings like "25.00"). cost must be a decimal string. Pass receipt to attach an image or PDF in the same call. This puts charges on every participant's balance and notifies them. ${CONFIRM_NOTE}`,
|
|
96
114
|
annotations: { readOnlyHint: false, destructiveHint: true, openWorldHint: true },
|
|
97
115
|
inputSchema: z.object({
|
|
98
116
|
group_id: z.number().describe('Group to add expense to (use 0 for no group)'),
|
|
@@ -113,28 +131,30 @@ export function registerExpenseTools(server, client) {
|
|
|
113
131
|
date: z.string().describe('ISO 8601 datetime').optional(),
|
|
114
132
|
category_id: z.number().describe('Category id from sw_get_categories').optional(),
|
|
115
133
|
details: z.string().describe('Notes').optional(),
|
|
134
|
+
receipt: receiptParam,
|
|
116
135
|
confirmToken: confirmTokenParam,
|
|
117
136
|
}),
|
|
118
137
|
}, async (args, ctx) => {
|
|
119
138
|
const body = buildExpenseBody(args);
|
|
139
|
+
const receipt = args.receipt ? await loadReceipt(args.receipt) : undefined;
|
|
120
140
|
const gate = await confirmWrite(ctx, {
|
|
121
141
|
tool: 'sw_create_expense',
|
|
122
142
|
action: 'expense.create',
|
|
123
|
-
summary: `Create a Splitwise expense "${args.description}" (${args.cost}) — notifies group members`,
|
|
143
|
+
summary: `Create a Splitwise expense "${args.description}" (${args.cost})${receipt ? ` with receipt ${receipt.filename}` : ''} — notifies group members`,
|
|
124
144
|
method: 'POST',
|
|
125
145
|
path: '/create_expense',
|
|
126
|
-
body,
|
|
146
|
+
body: previewBody(body, receipt),
|
|
127
147
|
target: args.group_id,
|
|
128
148
|
confirmToken: args.confirmToken,
|
|
129
149
|
args,
|
|
130
150
|
});
|
|
131
151
|
if (gate)
|
|
132
152
|
return gate;
|
|
133
|
-
const data = await client
|
|
153
|
+
const data = await sendExpenseWrite(client, '/create_expense', body, receipt);
|
|
134
154
|
return minifiedResult(data);
|
|
135
155
|
});
|
|
136
156
|
server.registerTool('sw_update_expense', {
|
|
137
|
-
description: `Edit an existing Splitwise expense. Provide expense_id and any fields to change. For custom split updates, the full users array must be provided (the API replaces the entire split). ${CONFIRM_NOTE}`,
|
|
157
|
+
description: `Edit an existing Splitwise expense. Provide expense_id and any fields to change. For custom split updates, the full users array must be provided (the API replaces the entire split). To attach or replace the receipt on an existing expense, pass expense_id and receipt alone. ${CONFIRM_NOTE}`,
|
|
138
158
|
annotations: { readOnlyHint: false, destructiveHint: true, openWorldHint: true },
|
|
139
159
|
inputSchema: z.object({
|
|
140
160
|
expense_id: z.number().describe('ID of the expense to update'),
|
|
@@ -149,25 +169,55 @@ export function registerExpenseTools(server, client) {
|
|
|
149
169
|
date: z.string().optional(),
|
|
150
170
|
category_id: z.number().optional(),
|
|
151
171
|
details: z.string().optional(),
|
|
172
|
+
receipt: receiptParam,
|
|
152
173
|
confirmToken: confirmTokenParam,
|
|
153
174
|
}),
|
|
154
175
|
}, async (args, ctx) => {
|
|
155
176
|
const { expense_id } = args;
|
|
156
177
|
const body = buildExpenseBody(args);
|
|
178
|
+
const receipt = args.receipt ? await loadReceipt(args.receipt) : undefined;
|
|
157
179
|
const gate = await confirmWrite(ctx, {
|
|
158
180
|
tool: 'sw_update_expense',
|
|
159
181
|
action: 'expense.update',
|
|
160
|
-
summary: `Update Splitwise expense ${expense_id} — notifies group members`,
|
|
182
|
+
summary: `Update Splitwise expense ${expense_id}${receipt ? ` (attach receipt ${receipt.filename})` : ''} — notifies group members`,
|
|
161
183
|
method: 'POST',
|
|
162
184
|
path: `/update_expense/${expense_id}`,
|
|
163
|
-
body,
|
|
185
|
+
body: previewBody(body, receipt),
|
|
186
|
+
target: expense_id,
|
|
187
|
+
confirmToken: args.confirmToken,
|
|
188
|
+
args,
|
|
189
|
+
});
|
|
190
|
+
if (gate)
|
|
191
|
+
return gate;
|
|
192
|
+
const data = await sendExpenseWrite(client, `/update_expense/${expense_id}`, body, receipt, expense_id);
|
|
193
|
+
return minifiedResult(data);
|
|
194
|
+
});
|
|
195
|
+
server.registerTool('sw_attach_receipt', {
|
|
196
|
+
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.base64. Fails if Splitwise does not store the file. ${CONFIRM_NOTE}`,
|
|
197
|
+
annotations: { readOnlyHint: false, destructiveHint: true, idempotentHint: true, openWorldHint: true },
|
|
198
|
+
inputSchema: z.object({
|
|
199
|
+
expense_id: z.number().describe('ID of the expense to attach the receipt to'),
|
|
200
|
+
receipt: receiptSchema,
|
|
201
|
+
confirmToken: confirmTokenParam,
|
|
202
|
+
}),
|
|
203
|
+
}, async (args, ctx) => {
|
|
204
|
+
const { expense_id } = args;
|
|
205
|
+
const receipt = await loadReceipt(args.receipt);
|
|
206
|
+
const path = `/update_expense/${expense_id}`;
|
|
207
|
+
const gate = await confirmWrite(ctx, {
|
|
208
|
+
tool: 'sw_attach_receipt',
|
|
209
|
+
action: 'expense.attach_receipt',
|
|
210
|
+
summary: `Attach receipt ${receipt.filename} to Splitwise expense ${expense_id} (replaces any existing receipt)`,
|
|
211
|
+
method: 'POST',
|
|
212
|
+
path,
|
|
213
|
+
body: previewBody({}, receipt),
|
|
164
214
|
target: expense_id,
|
|
165
215
|
confirmToken: args.confirmToken,
|
|
166
216
|
args,
|
|
167
217
|
});
|
|
168
218
|
if (gate)
|
|
169
219
|
return gate;
|
|
170
|
-
const data = await client
|
|
220
|
+
const data = await sendExpenseWrite(client, path, {}, receipt, expense_id);
|
|
171
221
|
return minifiedResult(data);
|
|
172
222
|
});
|
|
173
223
|
server.registerTool('sw_delete_expense', {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "splitwise-mcp",
|
|
3
|
-
"version": "3.
|
|
3
|
+
"version": "3.4.0",
|
|
4
4
|
"mcpName": "io.github.chrischall/splitwise-mcp",
|
|
5
5
|
"description": "Splitwise MCP server for Claude — developed and maintained by AI (Claude Code)",
|
|
6
6
|
"author": "Claude Code (AI) <https://www.anthropic.com/claude>",
|
package/server.json
CHANGED
|
@@ -6,12 +6,12 @@
|
|
|
6
6
|
"url": "https://github.com/chrischall/splitwise-mcp",
|
|
7
7
|
"source": "github"
|
|
8
8
|
},
|
|
9
|
-
"version": "3.
|
|
9
|
+
"version": "3.4.0",
|
|
10
10
|
"packages": [
|
|
11
11
|
{
|
|
12
12
|
"registryType": "npm",
|
|
13
13
|
"identifier": "splitwise-mcp",
|
|
14
|
-
"version": "3.
|
|
14
|
+
"version": "3.4.0",
|
|
15
15
|
"transport": {
|
|
16
16
|
"type": "stdio"
|
|
17
17
|
},
|
|
@@ -29,6 +29,13 @@
|
|
|
29
29
|
"isRequired": false,
|
|
30
30
|
"format": "string",
|
|
31
31
|
"isSecret": false
|
|
32
|
+
},
|
|
33
|
+
{
|
|
34
|
+
"name": "SPLITWISE_INPUT_DIR",
|
|
35
|
+
"description": "Directory receipt.path may read uploads from (unset: receipts are accepted as base64 only)",
|
|
36
|
+
"isRequired": false,
|
|
37
|
+
"format": "string",
|
|
38
|
+
"isSecret": false
|
|
32
39
|
}
|
|
33
40
|
]
|
|
34
41
|
}
|
|
@@ -92,10 +92,11 @@ API key auth — no login flow or token rotation. The key is attached to every r
|
|
|
92
92
|
|------|-------------|
|
|
93
93
|
| `sw_list_expenses(group_id?, friend_id?, dated_after?, dated_before?, limit?, offset?, view?)` | List or search expenses |
|
|
94
94
|
| `sw_get_expense(id, view?)` | Get full details of a single expense |
|
|
95
|
-
| `sw_create_expense(group_id, description, cost, split_equally? \| users?)` | Create an expense — equal split or custom per-person split |
|
|
96
|
-
| `sw_update_expense(expense_id,
|
|
95
|
+
| `sw_create_expense(group_id, description, cost, split_equally? \| users?, receipt?)` | Create an expense — equal split or custom per-person split; `receipt` attaches an image/PDF in the same call |
|
|
96
|
+
| `sw_update_expense(expense_id, ..., receipt?)` | Edit an existing expense (custom split requires full `users` array). `expense_id` + `receipt` alone attaches or replaces the receipt |
|
|
97
97
|
| `sw_delete_expense(id)` | Soft-delete an expense |
|
|
98
98
|
| `sw_undelete_expense(id)` | Restore a soft-deleted expense |
|
|
99
|
+
| `sw_attach_receipt(expense_id, receipt)` | Attach or replace the receipt on an existing expense; changes nothing else |
|
|
99
100
|
|
|
100
101
|
### Receipts
|
|
101
102
|
| Tool | Description |
|
|
@@ -173,8 +174,8 @@ The other eighteen tools take no `view`, each for its own reason:
|
|
|
173
174
|
- **`sw_get_categories` and `sw_get_currencies`** are static reference lists —
|
|
174
175
|
already narrow, no media, and every field on them is the answer.
|
|
175
176
|
- **`sw_healthcheck`** answers reachability and auth.
|
|
176
|
-
- **The
|
|
177
|
-
`sw_delete_expense`, `sw_undelete_expense`, `sw_create_group`,
|
|
177
|
+
- **The fifteen writes** (`sw_create_expense`, `sw_update_expense`,
|
|
178
|
+
`sw_attach_receipt`, `sw_delete_expense`, `sw_undelete_expense`, `sw_create_group`,
|
|
178
179
|
`sw_delete_group`, `sw_undelete_group`, `sw_add_user_to_group`,
|
|
179
180
|
`sw_remove_user_from_group`, `sw_create_friend`, `sw_delete_friend`,
|
|
180
181
|
`sw_create_comment`, `sw_delete_comment`, `sw_update_user`) return receipts —
|
|
@@ -205,6 +206,16 @@ sw_create_expense(group_id, "Hotel", "200.00", users: [
|
|
|
205
206
|
])
|
|
206
207
|
```
|
|
207
208
|
|
|
209
|
+
**Attach a receipt (proof of purchase):**
|
|
210
|
+
|
|
211
|
+
```
|
|
212
|
+
sw_create_expense(group_id, "Band camp water jug", "54.11", split_equally: true,
|
|
213
|
+
receipt: { base64: "<png/jpeg/gif/pdf bytes>", filename: "amazon-order.png" })
|
|
214
|
+
sw_attach_receipt(expense_id, receipt: { base64: "<png/jpeg/gif/pdf bytes>", filename: "order.pdf" })
|
|
215
|
+
```
|
|
216
|
+
|
|
217
|
+
`receipt` takes `filename` plus exactly one of `base64` (the normal input) or `path` (only on a local server with `SPLITWISE_INPUT_DIR` set, and only inside that directory). 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`.
|
|
218
|
+
|
|
208
219
|
**Get the receipt for an expense:**
|
|
209
220
|
```
|
|
210
221
|
sw_list_expenses(...) → find expense ID
|