splitwise-mcp 3.3.0 → 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 +2 -2
- package/.mcp.json +2 -1
- package/README.md +9 -4
- package/dist/bundle.js +880 -171
- package/dist/client.js +45 -7
- package/dist/index.js +1 -1
- package/dist/tools/_confirm.js +4 -0
- package/dist/tools/_receipt.js +173 -0
- package/dist/tools/_untrusted.js +16 -0
- package/dist/tools/expenses.js +104 -22
- package/dist/tools/friends.js +9 -5
- package/dist/tools/groups.js +37 -11
- package/dist/tools/receipts.js +37 -9
- package/dist/tools/user.js +6 -4
- package/dist/tools/utilities.js +17 -12
- package/package.json +2 -2
- package/server.json +10 -3
- package/skills/splitwise-mcp/SKILL.md +21 -9
package/dist/client.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { dirname, join } from 'path';
|
|
2
2
|
import { fileURLToPath } from 'url';
|
|
3
|
-
import { loadDotenvSafely,
|
|
3
|
+
import { loadDotenvSafely, requireEnvVar, createApiClient, ApiError, } from '@chrischall/mcp-utils';
|
|
4
4
|
// Load .env for local dev; silently skip if dotenv is unavailable (e.g. mcpb
|
|
5
5
|
// bundle). `loadDotenvSafely` swallows a missing dotenv module and never lets
|
|
6
6
|
// .env override a host-provided value.
|
|
@@ -17,6 +17,13 @@ catch {
|
|
|
17
17
|
const BASE_URL = 'https://secure.splitwise.com/api/v3.0';
|
|
18
18
|
const SERVICE_NAME = 'Splitwise';
|
|
19
19
|
const SPLITWISE_DOMAIN = 'splitwise.com';
|
|
20
|
+
/**
|
|
21
|
+
* Client-wide cap on any body read (fleet-audit #733). Generous for the
|
|
22
|
+
* largest JSON listing Splitwise returns, small enough that a broken or
|
|
23
|
+
* hostile upstream cannot exhaust memory. A call can override it with
|
|
24
|
+
* `maxBytes` (the receipt download does).
|
|
25
|
+
*/
|
|
26
|
+
export const MAX_RESPONSE_BYTES = 16 * 1024 * 1024;
|
|
20
27
|
/**
|
|
21
28
|
* Whether a hostname belongs to Splitwise, and may therefore be sent the API
|
|
22
29
|
* key. Receipt bytes are served either from a Splitwise host (which requires
|
|
@@ -90,6 +97,21 @@ function assertWriteSucceeded(data) {
|
|
|
90
97
|
const detail = messages.length > 0 ? messages.join('; ') : 'success: false with no error detail';
|
|
91
98
|
throw new Error(`Splitwise rejected the request: ${detail}`);
|
|
92
99
|
}
|
|
100
|
+
/**
|
|
101
|
+
* SPLITWISE_API_KEY is required on the stdio path — no tool works without it —
|
|
102
|
+
* so it is read with requireEnvVar (which applies readEnvVar's blank /
|
|
103
|
+
* `undefined` / `null` / `${...}` placeholder handling). The throw is swallowed
|
|
104
|
+
* here only so the constructor can defer it to the first tool call, keeping the
|
|
105
|
+
* install-time tools/list probe working on a server that has no key yet.
|
|
106
|
+
*/
|
|
107
|
+
function envApiKey() {
|
|
108
|
+
try {
|
|
109
|
+
return requireEnvVar('SPLITWISE_API_KEY');
|
|
110
|
+
}
|
|
111
|
+
catch {
|
|
112
|
+
return undefined;
|
|
113
|
+
}
|
|
114
|
+
}
|
|
93
115
|
export class SplitwiseClient {
|
|
94
116
|
apiKey;
|
|
95
117
|
/** Which source supplied the API key — a LABEL, never the value. */
|
|
@@ -106,15 +128,17 @@ export class SplitwiseClient {
|
|
|
106
128
|
* Optional constructor seam: a hosted per-user deployment builds one client
|
|
107
129
|
* per request with that user's `apiKey` injected. The stdio path passes no
|
|
108
130
|
* options, so the key resolves from the environment exactly as before —
|
|
109
|
-
* 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.
|
|
110
134
|
*/
|
|
111
135
|
constructor(opts) {
|
|
112
136
|
// Injected apiKey (hosted per-user path) takes precedence; otherwise fall
|
|
113
|
-
// back to the env var.
|
|
137
|
+
// back to the env var. envApiKey trims whitespace and treats
|
|
114
138
|
// blank/`undefined`/`null`/`${...}` placeholder values as unset — defends
|
|
115
139
|
// against MCP hosts that pass .mcp.json env blocks through unexpanded.
|
|
116
140
|
const injected = opts?.apiKey;
|
|
117
|
-
const key = injected ??
|
|
141
|
+
const key = injected ?? envApiKey();
|
|
118
142
|
// Which source supplied it, for `sw_healthcheck`. Derived from the SAME
|
|
119
143
|
// `key` and `injected` bindings the resolution above uses, so the two
|
|
120
144
|
// cannot disagree. An earlier version re-tested `opts?.apiKey` for
|
|
@@ -131,7 +155,7 @@ export class SplitwiseClient {
|
|
|
131
155
|
this.apiKey = key;
|
|
132
156
|
this.configError = null;
|
|
133
157
|
}
|
|
134
|
-
this.api = this.buildApiClient(BASE_URL, true);
|
|
158
|
+
this.api = this.buildApiClient(opts?.baseUrl ?? BASE_URL, true);
|
|
135
159
|
}
|
|
136
160
|
/**
|
|
137
161
|
* Which source supplied the API key, for `sw_healthcheck`. Returns a LABEL
|
|
@@ -156,6 +180,7 @@ export class SplitwiseClient {
|
|
|
156
180
|
serviceName: service,
|
|
157
181
|
retry: { count: 1, delayMs: 2000 },
|
|
158
182
|
timeout: 30_000,
|
|
183
|
+
maxResponseBytes: MAX_RESPONSE_BYTES,
|
|
159
184
|
...(authenticated ? { getToken: () => this.requireKey() } : {}),
|
|
160
185
|
onUnauthorized: () => new Error(authenticated
|
|
161
186
|
? 'SPLITWISE_API_KEY is invalid or missing'
|
|
@@ -174,6 +199,16 @@ export class SplitwiseClient {
|
|
|
174
199
|
assertWriteSucceeded(data);
|
|
175
200
|
return data;
|
|
176
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
|
+
}
|
|
177
212
|
/**
|
|
178
213
|
* Fetch the raw bytes behind an absolute asset URL the API handed back (an
|
|
179
214
|
* expense receipt). Splitwise serves receipts two ways and this covers both:
|
|
@@ -183,8 +218,11 @@ export class SplitwiseClient {
|
|
|
183
218
|
*
|
|
184
219
|
* A cross-origin redirect (API → S3) is safe: per the Fetch standard the
|
|
185
220
|
* `Authorization` header is dropped when a redirect changes origin.
|
|
221
|
+
*
|
|
222
|
+
* `maxBytes` overrides the client-wide body cap for this download; over it,
|
|
223
|
+
* the read is refused (`ResponseTooLargeError`, `kind: 'too_large'`).
|
|
186
224
|
*/
|
|
187
|
-
async fetchAsset(url) {
|
|
225
|
+
async fetchAsset(url, opts = {}) {
|
|
188
226
|
const target = parseAssetUrl(url);
|
|
189
227
|
const authenticated = isSplitwiseHost(target.hostname);
|
|
190
228
|
const cacheKey = `${target.origin}|${authenticated ? 'auth' : 'anon'}`;
|
|
@@ -197,7 +235,7 @@ export class SplitwiseClient {
|
|
|
197
235
|
// `pathname + search` verbatim rather than the client's `query` option:
|
|
198
236
|
// a presigned signature covers the exact encoding, so re-encoding it
|
|
199
237
|
// would invalidate the URL.
|
|
200
|
-
return await api.fetchRaw('GET', `${target.pathname}${target.search}
|
|
238
|
+
return await api.fetchRaw('GET', `${target.pathname}${target.search}`, opts.maxBytes !== undefined ? { maxBytes: opts.maxBytes } : {});
|
|
201
239
|
}
|
|
202
240
|
catch (err) {
|
|
203
241
|
throw redactAssetQuery(err, target.search);
|
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: [
|
package/dist/tools/_confirm.js
CHANGED
|
@@ -26,6 +26,10 @@ export function confirmWrite(ctx, gate) {
|
|
|
26
26
|
details: preview,
|
|
27
27
|
tool: gate.tool,
|
|
28
28
|
confirmToken: gate.confirmToken,
|
|
29
|
+
// One API key per SplitwiseClient (the hosted path builds a client per
|
|
30
|
+
// user), so there is no account switch inside a server to bind.
|
|
31
|
+
account: undefined,
|
|
32
|
+
args: gate.args,
|
|
29
33
|
subject: () => ({ target: String(gate.target), payload: request, preview }),
|
|
30
34
|
}));
|
|
31
35
|
}
|
|
@@ -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
|
+
}
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import { UNTRUSTED_CONTENT_RULE, UNTRUSTED_DESCRIPTION_SUFFIX, untrustedEnvelope, untrustedResult, } from '@chrischall/mcp-utils';
|
|
2
|
+
/**
|
|
3
|
+
* Comments, notifications, expense descriptions/details and receipt text are
|
|
4
|
+
* written by OTHER Splitwise users — a roommate, a co-parent, anyone in a
|
|
5
|
+
* shared group — and this server also exposes writes (fleet-audit #737).
|
|
6
|
+
*/
|
|
7
|
+
export const SW_UNTRUSTED_NOTE = `Expense descriptions and notes, comments, notifications and receipt text below are written by other Splitwise users (friends and group members), not the user. ${UNTRUSTED_CONTENT_RULE}`;
|
|
8
|
+
export { UNTRUSTED_DESCRIPTION_SUFFIX };
|
|
9
|
+
/** A tool result fencing third-party text: markers first, then the payload. */
|
|
10
|
+
export function swUntrustedResult(payload) {
|
|
11
|
+
return untrustedResult(payload, { note: SW_UNTRUSTED_NOTE });
|
|
12
|
+
}
|
|
13
|
+
/** The same envelope as a plain object, for a result built by hand. */
|
|
14
|
+
export function swUntrustedEnvelope(payload) {
|
|
15
|
+
return untrustedEnvelope(payload, { note: SW_UNTRUSTED_NOTE });
|
|
16
|
+
}
|
package/dist/tools/expenses.js
CHANGED
|
@@ -2,6 +2,8 @@ import { z } from 'zod';
|
|
|
2
2
|
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
|
+
import { UNTRUSTED_DESCRIPTION_SUFFIX, swUntrustedResult } from './_untrusted.js';
|
|
6
|
+
import { buildReceiptForm, loadReceipt, receiptParam, receiptSchema, verifyReceiptStored, } from './_receipt.js';
|
|
5
7
|
/** Flattens a users array into Splitwise's flat-param JSON format. */
|
|
6
8
|
export function flattenUsers(users) {
|
|
7
9
|
const flat = {};
|
|
@@ -13,8 +15,9 @@ export function flattenUsers(users) {
|
|
|
13
15
|
return flat;
|
|
14
16
|
}
|
|
15
17
|
function buildExpenseBody(args) {
|
|
16
|
-
// `confirmToken` is a tool-gate input
|
|
17
|
-
|
|
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;
|
|
18
21
|
if (split_equally && users) {
|
|
19
22
|
throw new Error('Provide either split_equally or users, not both');
|
|
20
23
|
}
|
|
@@ -27,6 +30,22 @@ function buildExpenseBody(args) {
|
|
|
27
30
|
}
|
|
28
31
|
return body;
|
|
29
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
|
+
}
|
|
30
49
|
const userShareSchema = z.object({
|
|
31
50
|
user_id: z.number(),
|
|
32
51
|
paid_share: z.string().describe('Amount this user paid, e.g. "25.00"'),
|
|
@@ -34,8 +53,8 @@ const userShareSchema = z.object({
|
|
|
34
53
|
});
|
|
35
54
|
export function registerExpenseTools(server, client) {
|
|
36
55
|
server.registerTool('sw_list_expenses', {
|
|
37
|
-
description:
|
|
38
|
-
annotations: { readOnlyHint: true },
|
|
56
|
+
description: `List or search Splitwise expenses. All filters are optional. Use group_id to filter by group, dated_after/dated_before for date ranges. ${UNTRUSTED_DESCRIPTION_SUFFIX}`,
|
|
57
|
+
annotations: { readOnlyHint: true, openWorldHint: true },
|
|
39
58
|
inputSchema: z.object({
|
|
40
59
|
view: viewParam(SW_VIEWS, {
|
|
41
60
|
note: 'compact keeps the share breakdown, repayments and receipt presence and drops the avatars and the repeat/reminder/transaction block; "full" returns Splitwise\'s whole records.',
|
|
@@ -52,8 +71,16 @@ export function registerExpenseTools(server, client) {
|
|
|
52
71
|
.optional(),
|
|
53
72
|
updated_after: z.string().describe('ISO 8601 datetime').optional(),
|
|
54
73
|
updated_before: z.string().describe('ISO 8601 datetime').optional(),
|
|
55
|
-
|
|
56
|
-
|
|
74
|
+
// Bounded: Splitwise reads limit=0 as "return everything", an
|
|
75
|
+
// unbounded result the host refuses.
|
|
76
|
+
limit: z
|
|
77
|
+
.number()
|
|
78
|
+
.int()
|
|
79
|
+
.min(1)
|
|
80
|
+
.max(200)
|
|
81
|
+
.describe('Max results, 1-200 (API default: 20). Page with offset for more.')
|
|
82
|
+
.optional(),
|
|
83
|
+
offset: z.number().int().min(0).describe('Pagination offset').optional(),
|
|
57
84
|
}),
|
|
58
85
|
}, async (args) => {
|
|
59
86
|
const qs = buildQueryString({
|
|
@@ -67,11 +94,11 @@ export function registerExpenseTools(server, client) {
|
|
|
67
94
|
offset: args.offset,
|
|
68
95
|
});
|
|
69
96
|
const data = await client.request('GET', `/get_expenses${qs}`);
|
|
70
|
-
return
|
|
97
|
+
return swUntrustedResult(viewExpenses(resolveView(args.view, SW_VIEWS), data));
|
|
71
98
|
});
|
|
72
99
|
server.registerTool('sw_get_expense', {
|
|
73
|
-
description:
|
|
74
|
-
annotations: { readOnlyHint: true },
|
|
100
|
+
description: `Get full details of a single Splitwise expense by id. ${UNTRUSTED_DESCRIPTION_SUFFIX}`,
|
|
101
|
+
annotations: { readOnlyHint: true, openWorldHint: true },
|
|
75
102
|
inputSchema: z.object({
|
|
76
103
|
view: viewParam(SW_VIEWS, {
|
|
77
104
|
note: 'compact keeps the share breakdown, repayments and receipt presence and drops the avatars and the repeat/reminder/transaction block; "full" returns Splitwise\'s whole records.',
|
|
@@ -80,10 +107,11 @@ export function registerExpenseTools(server, client) {
|
|
|
80
107
|
}),
|
|
81
108
|
}, async ({ id, view }) => {
|
|
82
109
|
const data = await client.request('GET', `/get_expense/${id}`);
|
|
83
|
-
return
|
|
110
|
+
return swUntrustedResult(viewExpense(resolveView(view, SW_VIEWS), data));
|
|
84
111
|
});
|
|
85
112
|
server.registerTool('sw_create_expense', {
|
|
86
|
-
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. ${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}`,
|
|
114
|
+
annotations: { readOnlyHint: false, destructiveHint: true, openWorldHint: true },
|
|
87
115
|
inputSchema: z.object({
|
|
88
116
|
group_id: z.number().describe('Group to add expense to (use 0 for no group)'),
|
|
89
117
|
description: z.string().describe('Short description of the expense'),
|
|
@@ -103,27 +131,31 @@ export function registerExpenseTools(server, client) {
|
|
|
103
131
|
date: z.string().describe('ISO 8601 datetime').optional(),
|
|
104
132
|
category_id: z.number().describe('Category id from sw_get_categories').optional(),
|
|
105
133
|
details: z.string().describe('Notes').optional(),
|
|
134
|
+
receipt: receiptParam,
|
|
106
135
|
confirmToken: confirmTokenParam,
|
|
107
136
|
}),
|
|
108
137
|
}, async (args, ctx) => {
|
|
109
138
|
const body = buildExpenseBody(args);
|
|
139
|
+
const receipt = args.receipt ? await loadReceipt(args.receipt) : undefined;
|
|
110
140
|
const gate = await confirmWrite(ctx, {
|
|
111
141
|
tool: 'sw_create_expense',
|
|
112
142
|
action: 'expense.create',
|
|
113
|
-
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`,
|
|
114
144
|
method: 'POST',
|
|
115
145
|
path: '/create_expense',
|
|
116
|
-
body,
|
|
146
|
+
body: previewBody(body, receipt),
|
|
117
147
|
target: args.group_id,
|
|
118
148
|
confirmToken: args.confirmToken,
|
|
149
|
+
args,
|
|
119
150
|
});
|
|
120
151
|
if (gate)
|
|
121
152
|
return gate;
|
|
122
|
-
const data = await client
|
|
153
|
+
const data = await sendExpenseWrite(client, '/create_expense', body, receipt);
|
|
123
154
|
return minifiedResult(data);
|
|
124
155
|
});
|
|
125
156
|
server.registerTool('sw_update_expense', {
|
|
126
|
-
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}`,
|
|
158
|
+
annotations: { readOnlyHint: false, destructiveHint: true, openWorldHint: true },
|
|
127
159
|
inputSchema: z.object({
|
|
128
160
|
expense_id: z.number().describe('ID of the expense to update'),
|
|
129
161
|
description: z.string().optional(),
|
|
@@ -137,34 +169,66 @@ export function registerExpenseTools(server, client) {
|
|
|
137
169
|
date: z.string().optional(),
|
|
138
170
|
category_id: z.number().optional(),
|
|
139
171
|
details: z.string().optional(),
|
|
172
|
+
receipt: receiptParam,
|
|
140
173
|
confirmToken: confirmTokenParam,
|
|
141
174
|
}),
|
|
142
175
|
}, async (args, ctx) => {
|
|
143
176
|
const { expense_id } = args;
|
|
144
177
|
const body = buildExpenseBody(args);
|
|
178
|
+
const receipt = args.receipt ? await loadReceipt(args.receipt) : undefined;
|
|
145
179
|
const gate = await confirmWrite(ctx, {
|
|
146
180
|
tool: 'sw_update_expense',
|
|
147
181
|
action: 'expense.update',
|
|
148
|
-
summary: `Update Splitwise expense ${expense_id} — notifies group members`,
|
|
182
|
+
summary: `Update Splitwise expense ${expense_id}${receipt ? ` (attach receipt ${receipt.filename})` : ''} — notifies group members`,
|
|
149
183
|
method: 'POST',
|
|
150
184
|
path: `/update_expense/${expense_id}`,
|
|
151
|
-
body,
|
|
185
|
+
body: previewBody(body, receipt),
|
|
152
186
|
target: expense_id,
|
|
153
187
|
confirmToken: args.confirmToken,
|
|
188
|
+
args,
|
|
154
189
|
});
|
|
155
190
|
if (gate)
|
|
156
191
|
return gate;
|
|
157
|
-
const data = await client
|
|
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),
|
|
214
|
+
target: expense_id,
|
|
215
|
+
confirmToken: args.confirmToken,
|
|
216
|
+
args,
|
|
217
|
+
});
|
|
218
|
+
if (gate)
|
|
219
|
+
return gate;
|
|
220
|
+
const data = await sendExpenseWrite(client, path, {}, receipt, expense_id);
|
|
158
221
|
return minifiedResult(data);
|
|
159
222
|
});
|
|
160
223
|
server.registerTool('sw_delete_expense', {
|
|
161
224
|
description: `Soft-delete a Splitwise expense by id. Returns {success: true} on success. Use sw_undelete_expense to restore. ${CONFIRM_NOTE}`,
|
|
162
|
-
annotations: { destructiveHint: true },
|
|
225
|
+
annotations: { readOnlyHint: false, destructiveHint: true, openWorldHint: true },
|
|
163
226
|
inputSchema: z.object({
|
|
164
227
|
id: z.number().describe('Expense ID to delete'),
|
|
165
228
|
confirmToken: confirmTokenParam,
|
|
166
229
|
}),
|
|
167
|
-
}, async (
|
|
230
|
+
}, async (args, ctx) => {
|
|
231
|
+
const { id, confirmToken } = args;
|
|
168
232
|
const gate = await confirmWrite(ctx, {
|
|
169
233
|
tool: 'sw_delete_expense',
|
|
170
234
|
action: 'expense.delete',
|
|
@@ -173,6 +237,7 @@ export function registerExpenseTools(server, client) {
|
|
|
173
237
|
path: `/delete_expense/${id}`,
|
|
174
238
|
target: id,
|
|
175
239
|
confirmToken,
|
|
240
|
+
args,
|
|
176
241
|
});
|
|
177
242
|
if (gate)
|
|
178
243
|
return gate;
|
|
@@ -180,11 +245,28 @@ export function registerExpenseTools(server, client) {
|
|
|
180
245
|
return minifiedResult(data);
|
|
181
246
|
});
|
|
182
247
|
server.registerTool('sw_undelete_expense', {
|
|
183
|
-
description:
|
|
248
|
+
description: `Restore a soft-deleted Splitwise expense. This puts its charges back on every participant's balance and notifies them, so it is gated like the other writes. ${CONFIRM_NOTE}`,
|
|
249
|
+
// Destructive despite the delete tool being its record-level inverse:
|
|
250
|
+
// the restore notifies every participant, and no later call un-sends that.
|
|
251
|
+
annotations: { readOnlyHint: false, destructiveHint: true, openWorldHint: true },
|
|
184
252
|
inputSchema: z.object({
|
|
185
253
|
id: z.number().describe('Expense ID to restore'),
|
|
254
|
+
confirmToken: confirmTokenParam,
|
|
186
255
|
}),
|
|
187
|
-
}, async (
|
|
256
|
+
}, async (args, ctx) => {
|
|
257
|
+
const { id, confirmToken } = args;
|
|
258
|
+
const gate = await confirmWrite(ctx, {
|
|
259
|
+
tool: 'sw_undelete_expense',
|
|
260
|
+
action: 'expense.undelete',
|
|
261
|
+
summary: `Restore soft-deleted Splitwise expense ${id} — puts it back on balances and notifies its participants`,
|
|
262
|
+
method: 'POST',
|
|
263
|
+
path: `/undelete_expense/${id}`,
|
|
264
|
+
target: id,
|
|
265
|
+
confirmToken,
|
|
266
|
+
args,
|
|
267
|
+
});
|
|
268
|
+
if (gate)
|
|
269
|
+
return gate;
|
|
188
270
|
const data = await client.request('POST', `/undelete_expense/${id}`);
|
|
189
271
|
return minifiedResult(data);
|
|
190
272
|
});
|
package/dist/tools/friends.js
CHANGED
|
@@ -5,7 +5,7 @@ import { CONFIRM_NOTE, confirmTokenParam, confirmWrite } from './_confirm.js';
|
|
|
5
5
|
export function registerFriendTools(server, client) {
|
|
6
6
|
server.registerTool('sw_list_friends', {
|
|
7
7
|
description: "List all Splitwise friends. Use this to resolve a friend's name to a user_id before adding them to a group or building a custom expense split. The compact default returns id, name (first_name + last_name joined), email, registration_status and any non-empty balance per friend; pass view:'full' for Splitwise's raw records, which keep first_name and last_name separate.",
|
|
8
|
-
annotations: { readOnlyHint: true },
|
|
8
|
+
annotations: { readOnlyHint: true, openWorldHint: true },
|
|
9
9
|
inputSchema: z.object({
|
|
10
10
|
view: viewParam(SW_VIEWS, { note: PERSON_VIEW_NOTE }),
|
|
11
11
|
}),
|
|
@@ -15,14 +15,15 @@ export function registerFriendTools(server, client) {
|
|
|
15
15
|
});
|
|
16
16
|
server.registerTool('sw_create_friend', {
|
|
17
17
|
description: `Add a Splitwise friend by email (sends them an invite). ${CONFIRM_NOTE}`,
|
|
18
|
-
annotations: { destructiveHint: true },
|
|
18
|
+
annotations: { readOnlyHint: false, destructiveHint: true, openWorldHint: true },
|
|
19
19
|
inputSchema: z.object({
|
|
20
20
|
user_email: z.string().describe('Email of the user to add as a friend'),
|
|
21
21
|
user_first_name: z.string().describe('First name of the user').optional(),
|
|
22
22
|
user_last_name: z.string().describe('Last name of the user').optional(),
|
|
23
23
|
confirmToken: confirmTokenParam,
|
|
24
24
|
}),
|
|
25
|
-
}, async (
|
|
25
|
+
}, async (args, ctx) => {
|
|
26
|
+
const { user_email, user_first_name, user_last_name, confirmToken } = args;
|
|
26
27
|
const body = pruneUndefined({ user_email, user_first_name, user_last_name });
|
|
27
28
|
const gate = await confirmWrite(ctx, {
|
|
28
29
|
tool: 'sw_create_friend',
|
|
@@ -33,6 +34,7 @@ export function registerFriendTools(server, client) {
|
|
|
33
34
|
body,
|
|
34
35
|
target: user_email,
|
|
35
36
|
confirmToken,
|
|
37
|
+
args,
|
|
36
38
|
});
|
|
37
39
|
if (gate)
|
|
38
40
|
return gate;
|
|
@@ -41,12 +43,13 @@ export function registerFriendTools(server, client) {
|
|
|
41
43
|
});
|
|
42
44
|
server.registerTool('sw_delete_friend', {
|
|
43
45
|
description: `Remove a Splitwise friendship by user id. ${CONFIRM_NOTE}`,
|
|
44
|
-
annotations: { destructiveHint: true },
|
|
46
|
+
annotations: { readOnlyHint: false, destructiveHint: true, openWorldHint: true },
|
|
45
47
|
inputSchema: z.object({
|
|
46
48
|
id: z.number().describe('User ID of the friend to remove'),
|
|
47
49
|
confirmToken: confirmTokenParam,
|
|
48
50
|
}),
|
|
49
|
-
}, async (
|
|
51
|
+
}, async (args, ctx) => {
|
|
52
|
+
const { id, confirmToken } = args;
|
|
50
53
|
const gate = await confirmWrite(ctx, {
|
|
51
54
|
tool: 'sw_delete_friend',
|
|
52
55
|
action: 'friend.delete',
|
|
@@ -55,6 +58,7 @@ export function registerFriendTools(server, client) {
|
|
|
55
58
|
path: `/delete_friend/${id}`,
|
|
56
59
|
target: id,
|
|
57
60
|
confirmToken,
|
|
61
|
+
args,
|
|
58
62
|
});
|
|
59
63
|
if (gate)
|
|
60
64
|
return gate;
|