splitwise-mcp 2.1.6 → 2.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.claude-plugin/marketplace.json +2 -2
- package/.claude-plugin/plugin.json +1 -1
- package/.mcp.json +5 -2
- package/README.md +15 -3
- package/dist/bundle.js +253 -35
- package/dist/client.js +95 -8
- package/dist/index.js +3 -1
- package/dist/tools/friends.js +2 -6
- package/dist/tools/groups.js +2 -6
- package/dist/tools/receipts.js +111 -0
- package/dist/tools/user.js +2 -14
- package/package.json +1 -1
- package/server.json +9 -2
- package/skills/splitwise-mcp/SKILL.md +15 -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": "2.
|
|
10
|
+
"version": "2.2.0"
|
|
11
11
|
},
|
|
12
12
|
"plugins": [
|
|
13
13
|
{
|
|
@@ -15,7 +15,7 @@
|
|
|
15
15
|
"displayName": "Splitwise",
|
|
16
16
|
"source": "./",
|
|
17
17
|
"description": "MCP server for Splitwise — manage expenses, groups, and friends via natural language",
|
|
18
|
-
"version": "2.
|
|
18
|
+
"version": "2.2.0",
|
|
19
19
|
"author": {
|
|
20
20
|
"name": "Chris Hall"
|
|
21
21
|
},
|
package/.mcp.json
CHANGED
|
@@ -2,9 +2,12 @@
|
|
|
2
2
|
"mcpServers": {
|
|
3
3
|
"splitwise": {
|
|
4
4
|
"command": "node",
|
|
5
|
-
"args": [
|
|
5
|
+
"args": [
|
|
6
|
+
"${CLAUDE_PLUGIN_ROOT}/dist/index.js"
|
|
7
|
+
],
|
|
6
8
|
"env": {
|
|
7
|
-
"SPLITWISE_API_KEY": "${SPLITWISE_API_KEY}"
|
|
9
|
+
"SPLITWISE_API_KEY": "${SPLITWISE_API_KEY}",
|
|
10
|
+
"SPLITWISE_OUTPUT_DIR": "${SPLITWISE_OUTPUT_DIR}"
|
|
8
11
|
}
|
|
9
12
|
}
|
|
10
13
|
}
|
package/README.md
CHANGED
|
@@ -20,6 +20,7 @@ Ask Claude things like:
|
|
|
20
20
|
- *"Add Meredith to the household group"*
|
|
21
21
|
- *"Show me recent expenses"*
|
|
22
22
|
- *"Delete that duplicate expense"*
|
|
23
|
+
- *"Download the receipt from that hotel expense"*
|
|
23
24
|
|
|
24
25
|
## Requirements
|
|
25
26
|
|
|
@@ -112,10 +113,11 @@ Add to Claude Desktop config:
|
|
|
112
113
|
| Env var | Required | Notes |
|
|
113
114
|
|---------|----------|-------|
|
|
114
115
|
| `SPLITWISE_API_KEY` | Yes | API key from [splitwise.com/apps/register](https://secure.splitwise.com/apps/register) |
|
|
116
|
+
| `SPLITWISE_OUTPUT_DIR` | No | Where `sw_get_receipt` writes downloaded receipts. Defaults to the current working directory. |
|
|
115
117
|
|
|
116
118
|
## Available tools
|
|
117
119
|
|
|
118
|
-
|
|
120
|
+
26 tools across 6 domains. All tools are prefixed `sw_`.
|
|
119
121
|
|
|
120
122
|
### User
|
|
121
123
|
|
|
@@ -123,6 +125,7 @@ Add to Claude Desktop config:
|
|
|
123
125
|
|------|-------------|
|
|
124
126
|
| `sw_get_current_user` | Get the authenticated user's profile |
|
|
125
127
|
| `sw_get_user` | Get another user's profile by ID |
|
|
128
|
+
| `sw_update_user` | Update the current user's profile fields |
|
|
126
129
|
|
|
127
130
|
### Groups
|
|
128
131
|
|
|
@@ -158,6 +161,12 @@ Add to Claude Desktop config:
|
|
|
158
161
|
| `sw_create_comment` | Add a comment to an expense |
|
|
159
162
|
| `sw_delete_comment` | Delete a comment |
|
|
160
163
|
|
|
164
|
+
### Receipts
|
|
165
|
+
|
|
166
|
+
| Tool | What it does |
|
|
167
|
+
|------|-------------|
|
|
168
|
+
| `sw_get_receipt` | Download an expense's receipt image/PDF with the server's credentials and write it to a file |
|
|
169
|
+
|
|
161
170
|
### Utilities
|
|
162
171
|
|
|
163
172
|
| Tool | What it does |
|
|
@@ -170,6 +179,8 @@ Add to Claude Desktop config:
|
|
|
170
179
|
|
|
171
180
|
**"SPLITWISE_API_KEY is required"** -- set the environment variable in your MCP config or a `.env` file.
|
|
172
181
|
|
|
182
|
+
**401 when opening a receipt URL** -- the `receipt.original` / `receipt.large` URLs on an expense are not public; they need the API key. Use `sw_get_receipt` instead of fetching them directly.
|
|
183
|
+
|
|
173
184
|
**429 rate limit** -- Splitwise has undocumented rate limits. Wait a moment and retry.
|
|
174
185
|
|
|
175
186
|
**Tools not appearing in Claude** -- go to **Claude Desktop > Settings > Developer** to see connected servers. Make sure you fully quit and relaunched after editing the config.
|
|
@@ -191,8 +202,9 @@ src/
|
|
|
191
202
|
user.ts sw_get_current_user, sw_get_user
|
|
192
203
|
groups.ts group CRUD and membership
|
|
193
204
|
friends.ts friend list and management
|
|
194
|
-
expenses.ts expense CRUD
|
|
195
|
-
|
|
205
|
+
expenses.ts expense CRUD
|
|
206
|
+
receipts.ts authenticated receipt download
|
|
207
|
+
utilities.ts notifications, categories, currencies, comments
|
|
196
208
|
```
|
|
197
209
|
|
|
198
210
|
## License
|
package/dist/bundle.js
CHANGED
|
@@ -2984,7 +2984,7 @@ var require_compile = __commonJS({
|
|
|
2984
2984
|
const schOrFunc = root.refs[ref];
|
|
2985
2985
|
if (schOrFunc)
|
|
2986
2986
|
return schOrFunc;
|
|
2987
|
-
let _sch =
|
|
2987
|
+
let _sch = resolve2.call(this, root, ref);
|
|
2988
2988
|
if (_sch === void 0) {
|
|
2989
2989
|
const schema = (_a3 = root.localRefs) === null || _a3 === void 0 ? void 0 : _a3[ref];
|
|
2990
2990
|
const { schemaId } = this.opts;
|
|
@@ -3011,7 +3011,7 @@ var require_compile = __commonJS({
|
|
|
3011
3011
|
function sameSchemaEnv(s1, s2) {
|
|
3012
3012
|
return s1.schema === s2.schema && s1.root === s2.root && s1.baseId === s2.baseId;
|
|
3013
3013
|
}
|
|
3014
|
-
function
|
|
3014
|
+
function resolve2(root, ref) {
|
|
3015
3015
|
let sch;
|
|
3016
3016
|
while (typeof (sch = this.refs[ref]) == "string")
|
|
3017
3017
|
ref = sch;
|
|
@@ -3642,7 +3642,7 @@ var require_fast_uri = __commonJS({
|
|
|
3642
3642
|
}
|
|
3643
3643
|
return uri;
|
|
3644
3644
|
}
|
|
3645
|
-
function
|
|
3645
|
+
function resolve2(baseURI, relativeURI, options) {
|
|
3646
3646
|
const schemelessOptions = options ? Object.assign({ scheme: "null" }, options) : { scheme: "null" };
|
|
3647
3647
|
const { parsed: baseParsed, malformedAuthorityOrPort: baseMalformed } = parseWithStatus(baseURI, schemelessOptions);
|
|
3648
3648
|
const { parsed: relativeParsed, malformedAuthorityOrPort: relativeMalformed } = parseWithStatus(relativeURI, schemelessOptions);
|
|
@@ -3926,7 +3926,7 @@ var require_fast_uri = __commonJS({
|
|
|
3926
3926
|
var fastUri = {
|
|
3927
3927
|
SCHEMES,
|
|
3928
3928
|
normalize,
|
|
3929
|
-
resolve,
|
|
3929
|
+
resolve: resolve2,
|
|
3930
3930
|
resolveComponent,
|
|
3931
3931
|
equal,
|
|
3932
3932
|
serialize,
|
|
@@ -28873,7 +28873,7 @@ var Protocol = class {
|
|
|
28873
28873
|
return;
|
|
28874
28874
|
}
|
|
28875
28875
|
const pollInterval = task2.pollInterval ?? this._options?.defaultTaskPollInterval ?? 1e3;
|
|
28876
|
-
await new Promise((
|
|
28876
|
+
await new Promise((resolve2) => setTimeout(resolve2, pollInterval));
|
|
28877
28877
|
options?.signal?.throwIfAborted();
|
|
28878
28878
|
}
|
|
28879
28879
|
} catch (error51) {
|
|
@@ -28890,7 +28890,7 @@ var Protocol = class {
|
|
|
28890
28890
|
*/
|
|
28891
28891
|
request(request, resultSchema, options) {
|
|
28892
28892
|
const { relatedRequestId, resumptionToken, onresumptiontoken, task, relatedTask } = options ?? {};
|
|
28893
|
-
return new Promise((
|
|
28893
|
+
return new Promise((resolve2, reject) => {
|
|
28894
28894
|
const earlyReject = (error51) => {
|
|
28895
28895
|
reject(error51);
|
|
28896
28896
|
};
|
|
@@ -28968,7 +28968,7 @@ var Protocol = class {
|
|
|
28968
28968
|
if (!parseResult.success) {
|
|
28969
28969
|
reject(parseResult.error);
|
|
28970
28970
|
} else {
|
|
28971
|
-
|
|
28971
|
+
resolve2(parseResult.data);
|
|
28972
28972
|
}
|
|
28973
28973
|
} catch (error51) {
|
|
28974
28974
|
reject(error51);
|
|
@@ -29229,12 +29229,12 @@ var Protocol = class {
|
|
|
29229
29229
|
}
|
|
29230
29230
|
} catch {
|
|
29231
29231
|
}
|
|
29232
|
-
return new Promise((
|
|
29232
|
+
return new Promise((resolve2, reject) => {
|
|
29233
29233
|
if (signal.aborted) {
|
|
29234
29234
|
reject(new McpError(ErrorCode.InvalidRequest, "Request cancelled"));
|
|
29235
29235
|
return;
|
|
29236
29236
|
}
|
|
29237
|
-
const timeoutId = setTimeout(
|
|
29237
|
+
const timeoutId = setTimeout(resolve2, interval);
|
|
29238
29238
|
signal.addEventListener("abort", () => {
|
|
29239
29239
|
clearTimeout(timeoutId);
|
|
29240
29240
|
reject(new McpError(ErrorCode.InvalidRequest, "Request cancelled"));
|
|
@@ -30325,7 +30325,7 @@ var McpServer = class {
|
|
|
30325
30325
|
let task = createTaskResult.task;
|
|
30326
30326
|
const pollInterval = task.pollInterval ?? 5e3;
|
|
30327
30327
|
while (task.status !== "completed" && task.status !== "failed" && task.status !== "cancelled") {
|
|
30328
|
-
await new Promise((
|
|
30328
|
+
await new Promise((resolve2) => setTimeout(resolve2, pollInterval));
|
|
30329
30329
|
const updatedTask = await extra.taskStore.getTask(taskId);
|
|
30330
30330
|
if (!updatedTask) {
|
|
30331
30331
|
throw new McpError(ErrorCode.InternalError, `Task ${taskId} not found during polling`);
|
|
@@ -30989,12 +30989,12 @@ var StdioServerTransport = class {
|
|
|
30989
30989
|
this.onclose?.();
|
|
30990
30990
|
}
|
|
30991
30991
|
send(message) {
|
|
30992
|
-
return new Promise((
|
|
30992
|
+
return new Promise((resolve2) => {
|
|
30993
30993
|
const json2 = serializeMessage(message);
|
|
30994
30994
|
if (this._stdout.write(json2)) {
|
|
30995
|
-
|
|
30995
|
+
resolve2();
|
|
30996
30996
|
} else {
|
|
30997
|
-
this._stdout.once("drain",
|
|
30997
|
+
this._stdout.once("drain", resolve2);
|
|
30998
30998
|
}
|
|
30999
30999
|
});
|
|
31000
31000
|
}
|
|
@@ -31090,8 +31090,23 @@ function textResult(data) {
|
|
|
31090
31090
|
content: [{ type: "text", text: JSON.stringify(data, null, 2) }]
|
|
31091
31091
|
};
|
|
31092
31092
|
}
|
|
31093
|
+
function imageResult(base643, mimeType) {
|
|
31094
|
+
return {
|
|
31095
|
+
content: [{ type: "image", data: base643, mimeType }]
|
|
31096
|
+
};
|
|
31097
|
+
}
|
|
31098
|
+
function pruneUndefined(obj) {
|
|
31099
|
+
const out = {};
|
|
31100
|
+
for (const key of Object.keys(obj)) {
|
|
31101
|
+
if (obj[key] !== void 0)
|
|
31102
|
+
out[key] = obj[key];
|
|
31103
|
+
}
|
|
31104
|
+
return out;
|
|
31105
|
+
}
|
|
31093
31106
|
|
|
31094
31107
|
// node_modules/@chrischall/mcp-utils/dist/config/index.js
|
|
31108
|
+
import { homedir } from "node:os";
|
|
31109
|
+
import { isAbsolute, join, resolve } from "node:path";
|
|
31095
31110
|
var PLACEHOLDER_RE = /^\$\{[^}]*\}$/;
|
|
31096
31111
|
function readEnvVar(key, opts = {}) {
|
|
31097
31112
|
const env = opts.env ?? process.env;
|
|
@@ -31104,6 +31119,15 @@ function readEnvVar(key, opts = {}) {
|
|
|
31104
31119
|
}
|
|
31105
31120
|
return opts.default;
|
|
31106
31121
|
}
|
|
31122
|
+
function expandPath(p) {
|
|
31123
|
+
let expanded = p;
|
|
31124
|
+
if (p === "~") {
|
|
31125
|
+
expanded = homedir();
|
|
31126
|
+
} else if (p.startsWith("~/")) {
|
|
31127
|
+
expanded = join(homedir(), p.slice(2));
|
|
31128
|
+
}
|
|
31129
|
+
return isAbsolute(expanded) ? expanded : resolve(expanded);
|
|
31130
|
+
}
|
|
31107
31131
|
async function loadDotenvSafely(opts = {}) {
|
|
31108
31132
|
try {
|
|
31109
31133
|
const mod = await import(
|
|
@@ -31121,6 +31145,64 @@ async function loadDotenvSafely(opts = {}) {
|
|
|
31121
31145
|
}
|
|
31122
31146
|
}
|
|
31123
31147
|
|
|
31148
|
+
// node_modules/@chrischall/mcp-utils/dist/fs/output.js
|
|
31149
|
+
import { existsSync, mkdirSync, writeFileSync } from "node:fs";
|
|
31150
|
+
import { join as join2 } from "node:path";
|
|
31151
|
+
function resolveOutputDir(perCall, envVar, opts = {}) {
|
|
31152
|
+
const raw = perCall ?? readEnvVar(envVar, opts.env ? { env: opts.env } : {});
|
|
31153
|
+
if (!raw)
|
|
31154
|
+
return process.cwd();
|
|
31155
|
+
const dir = expandPath(raw);
|
|
31156
|
+
mkdirSync(dir, { recursive: true });
|
|
31157
|
+
return dir;
|
|
31158
|
+
}
|
|
31159
|
+
function sanitizeBaseName(base) {
|
|
31160
|
+
const safe = base.replace(/[/\\]+/g, "_").replace(/\.\.+/g, "_").replace(/^\.+/, "").trim();
|
|
31161
|
+
return safe.length > 0 ? safe : "file";
|
|
31162
|
+
}
|
|
31163
|
+
function uniquePath(dir, base, ext) {
|
|
31164
|
+
const safe = sanitizeBaseName(base);
|
|
31165
|
+
const first = join2(dir, `${safe}.${ext}`);
|
|
31166
|
+
if (!existsSync(first))
|
|
31167
|
+
return first;
|
|
31168
|
+
for (let n = 2; ; n++) {
|
|
31169
|
+
const candidate = join2(dir, `${safe}-${n}.${ext}`);
|
|
31170
|
+
if (!existsSync(candidate))
|
|
31171
|
+
return candidate;
|
|
31172
|
+
}
|
|
31173
|
+
}
|
|
31174
|
+
var MIME_EXT = {
|
|
31175
|
+
"image/png": "png",
|
|
31176
|
+
"image/jpeg": "jpg",
|
|
31177
|
+
"image/webp": "webp",
|
|
31178
|
+
"image/gif": "gif",
|
|
31179
|
+
"application/pdf": "pdf",
|
|
31180
|
+
"application/json": "json"
|
|
31181
|
+
};
|
|
31182
|
+
function writeBinaryOutput(opts) {
|
|
31183
|
+
const ext = opts.extension ?? (opts.mimeType ? MIME_EXT[opts.mimeType] : void 0) ?? "bin";
|
|
31184
|
+
mkdirSync(opts.dir, { recursive: true });
|
|
31185
|
+
const path = uniquePath(opts.dir, opts.baseName, ext);
|
|
31186
|
+
writeFileSync(path, Buffer.from(opts.base64, "base64"));
|
|
31187
|
+
return path;
|
|
31188
|
+
}
|
|
31189
|
+
function sniffMimeBytes(bytes) {
|
|
31190
|
+
const buf = Buffer.from(bytes.buffer, bytes.byteOffset, bytes.byteLength);
|
|
31191
|
+
if (buf.length >= 8 && buf.subarray(0, 8).equals(Buffer.from([137, 80, 78, 71, 13, 10, 26, 10]))) {
|
|
31192
|
+
return "image/png";
|
|
31193
|
+
}
|
|
31194
|
+
if (buf.length >= 3 && buf[0] === 255 && buf[1] === 216 && buf[2] === 255) {
|
|
31195
|
+
return "image/jpeg";
|
|
31196
|
+
}
|
|
31197
|
+
if (buf.length >= 12 && buf.subarray(0, 4).toString("latin1") === "RIFF" && buf.subarray(8, 12).toString("latin1") === "WEBP") {
|
|
31198
|
+
return "image/webp";
|
|
31199
|
+
}
|
|
31200
|
+
if (buf.length >= 4 && buf.subarray(0, 4).toString("latin1") === "GIF8") {
|
|
31201
|
+
return "image/gif";
|
|
31202
|
+
}
|
|
31203
|
+
return void 0;
|
|
31204
|
+
}
|
|
31205
|
+
|
|
31124
31206
|
// node_modules/@chrischall/mcp-utils/dist/http/net-atoms.js
|
|
31125
31207
|
function parseRetryAfterMs(header, opts = {}) {
|
|
31126
31208
|
const defaultMs = opts.defaultMs ?? 2e3;
|
|
@@ -31328,19 +31410,44 @@ var pageSchema = {
|
|
|
31328
31410
|
};
|
|
31329
31411
|
|
|
31330
31412
|
// src/client.ts
|
|
31331
|
-
import { dirname, join } from "path";
|
|
31413
|
+
import { dirname, join as join3 } from "path";
|
|
31332
31414
|
import { fileURLToPath } from "url";
|
|
31333
31415
|
try {
|
|
31334
31416
|
const dir = dirname(fileURLToPath(import.meta.url));
|
|
31335
|
-
await loadDotenvSafely({ path:
|
|
31417
|
+
await loadDotenvSafely({ path: join3(dir, "..", ".env"), override: false });
|
|
31336
31418
|
} catch {
|
|
31337
31419
|
}
|
|
31338
31420
|
var BASE_URL = "https://secure.splitwise.com/api/v3.0";
|
|
31339
31421
|
var SERVICE_NAME = "Splitwise";
|
|
31422
|
+
var SPLITWISE_DOMAIN = "splitwise.com";
|
|
31423
|
+
function isSplitwiseHost(hostname3) {
|
|
31424
|
+
const host = hostname3.toLowerCase();
|
|
31425
|
+
return host === SPLITWISE_DOMAIN || host.endsWith(`.${SPLITWISE_DOMAIN}`);
|
|
31426
|
+
}
|
|
31427
|
+
function parseAssetUrl(url2) {
|
|
31428
|
+
let parsed;
|
|
31429
|
+
try {
|
|
31430
|
+
parsed = new URL(url2);
|
|
31431
|
+
} catch {
|
|
31432
|
+
throw new Error("Splitwise returned an asset URL that could not be parsed");
|
|
31433
|
+
}
|
|
31434
|
+
if (parsed.protocol !== "https:") {
|
|
31435
|
+
throw new Error(`Refusing to fetch an asset over ${parsed.protocol}// \u2014 https only`);
|
|
31436
|
+
}
|
|
31437
|
+
return parsed;
|
|
31438
|
+
}
|
|
31439
|
+
function redactAssetQuery(err, search) {
|
|
31440
|
+
if (!(err instanceof Error) || !search) return err;
|
|
31441
|
+
const message = err.message.split(search).join("?<redacted>");
|
|
31442
|
+
if (message === err.message) return err;
|
|
31443
|
+
return err instanceof ApiError ? new ApiError(err.status, message) : new Error(message);
|
|
31444
|
+
}
|
|
31340
31445
|
var SplitwiseClient = class {
|
|
31341
31446
|
apiKey;
|
|
31342
31447
|
configError;
|
|
31343
31448
|
api;
|
|
31449
|
+
/** Per-origin clients for asset hosts, built on first use by `fetchAsset`. */
|
|
31450
|
+
assetClients = /* @__PURE__ */ new Map();
|
|
31344
31451
|
/**
|
|
31345
31452
|
* Defer the config error so the server can still start (and respond to the
|
|
31346
31453
|
* host's install-time smoke test) when SPLITWISE_API_KEY isn't set yet.
|
|
@@ -31360,14 +31467,26 @@ var SplitwiseClient = class {
|
|
|
31360
31467
|
this.apiKey = key;
|
|
31361
31468
|
this.configError = null;
|
|
31362
31469
|
}
|
|
31363
|
-
this.api =
|
|
31364
|
-
|
|
31365
|
-
|
|
31366
|
-
|
|
31470
|
+
this.api = this.buildApiClient(BASE_URL, true);
|
|
31471
|
+
}
|
|
31472
|
+
/**
|
|
31473
|
+
* One API client per origin, sharing the fleet's retry/timeout/error policy.
|
|
31474
|
+
* `authenticated` decides whether the API key is attached: `getToken` defers
|
|
31475
|
+
* config errors to request time, and on* handlers preserve Splitwise's
|
|
31476
|
+
* documented 401/429 messages.
|
|
31477
|
+
*/
|
|
31478
|
+
buildApiClient(baseUrl, authenticated) {
|
|
31479
|
+
const service = authenticated ? SERVICE_NAME : new URL(baseUrl).host;
|
|
31480
|
+
return createApiClient({
|
|
31481
|
+
baseUrl,
|
|
31482
|
+
serviceName: service,
|
|
31367
31483
|
retry: { count: 1, delayMs: 2e3 },
|
|
31368
31484
|
timeout: 3e4,
|
|
31369
|
-
|
|
31370
|
-
|
|
31485
|
+
...authenticated ? { getToken: () => this.requireKey() } : {},
|
|
31486
|
+
onUnauthorized: () => new Error(
|
|
31487
|
+
authenticated ? "SPLITWISE_API_KEY is invalid or missing" : "The asset URL was rejected (401) \u2014 presigned URLs are short-lived, so re-run to get a fresh one"
|
|
31488
|
+
),
|
|
31489
|
+
onRateLimited: () => new Error(`Rate limited by ${authenticated ? "Splitwise API" : service}`)
|
|
31371
31490
|
});
|
|
31372
31491
|
}
|
|
31373
31492
|
requireKey() {
|
|
@@ -31377,6 +31496,31 @@ var SplitwiseClient = class {
|
|
|
31377
31496
|
async request(method, path, body) {
|
|
31378
31497
|
return this.api.fetchJson(method, path, body !== void 0 ? { body } : {});
|
|
31379
31498
|
}
|
|
31499
|
+
/**
|
|
31500
|
+
* Fetch the raw bytes behind an absolute asset URL the API handed back (an
|
|
31501
|
+
* expense receipt). Splitwise serves receipts two ways and this covers both:
|
|
31502
|
+
* from its own API (`https://www.splitwise.com/api/v4.0/expenses/{id}/receipt`),
|
|
31503
|
+
* which 401s without the key, and from a presigned S3 URL, which carries its
|
|
31504
|
+
* own signature and must NOT be sent the key. `isSplitwiseHost` picks.
|
|
31505
|
+
*
|
|
31506
|
+
* A cross-origin redirect (API → S3) is safe: per the Fetch standard the
|
|
31507
|
+
* `Authorization` header is dropped when a redirect changes origin.
|
|
31508
|
+
*/
|
|
31509
|
+
async fetchAsset(url2) {
|
|
31510
|
+
const target = parseAssetUrl(url2);
|
|
31511
|
+
const authenticated = isSplitwiseHost(target.hostname);
|
|
31512
|
+
const cacheKey = `${target.origin}|${authenticated ? "auth" : "anon"}`;
|
|
31513
|
+
let api = this.assetClients.get(cacheKey);
|
|
31514
|
+
if (!api) {
|
|
31515
|
+
api = this.buildApiClient(target.origin, authenticated);
|
|
31516
|
+
this.assetClients.set(cacheKey, api);
|
|
31517
|
+
}
|
|
31518
|
+
try {
|
|
31519
|
+
return await api.fetchRaw("GET", `${target.pathname}${target.search}`);
|
|
31520
|
+
} catch (err) {
|
|
31521
|
+
throw redactAssetQuery(err, target.search);
|
|
31522
|
+
}
|
|
31523
|
+
}
|
|
31380
31524
|
};
|
|
31381
31525
|
var client = new SplitwiseClient();
|
|
31382
31526
|
|
|
@@ -31425,13 +31569,7 @@ function registerUserTools(server, client2) {
|
|
|
31425
31569
|
confirm: schemaConfirm
|
|
31426
31570
|
}
|
|
31427
31571
|
}, async ({ id, first_name, last_name, email: email3, password, locale, default_currency, confirm }) => {
|
|
31428
|
-
const body = {};
|
|
31429
|
-
if (first_name !== void 0) body.first_name = first_name;
|
|
31430
|
-
if (last_name !== void 0) body.last_name = last_name;
|
|
31431
|
-
if (email3 !== void 0) body.email = email3;
|
|
31432
|
-
if (password !== void 0) body.password = password;
|
|
31433
|
-
if (locale !== void 0) body.locale = locale;
|
|
31434
|
-
if (default_currency !== void 0) body.default_currency = default_currency;
|
|
31572
|
+
const body = pruneUndefined({ first_name, last_name, email: email3, password, locale, default_currency });
|
|
31435
31573
|
const previewBody = { ...body, ...password !== void 0 ? { password: "[hidden]" } : {} };
|
|
31436
31574
|
const gate = previewUnlessConfirmed(confirm, `Update current Splitwise user ${id} profile`, "POST", `/update_user/${id}`, previewBody);
|
|
31437
31575
|
if (gate) return gate;
|
|
@@ -31469,9 +31607,7 @@ function registerGroupTools(server, client2) {
|
|
|
31469
31607
|
confirm: schemaConfirm
|
|
31470
31608
|
}
|
|
31471
31609
|
}, async ({ name, group_type, simplify_by_default, confirm }) => {
|
|
31472
|
-
const body = { name };
|
|
31473
|
-
if (group_type !== void 0) body.group_type = group_type;
|
|
31474
|
-
if (simplify_by_default !== void 0) body.simplify_by_default = simplify_by_default;
|
|
31610
|
+
const body = pruneUndefined({ name, group_type, simplify_by_default });
|
|
31475
31611
|
const gate = previewUnlessConfirmed(confirm, `Create Splitwise group "${name}"`, "POST", "/create_group", body);
|
|
31476
31612
|
if (gate) return gate;
|
|
31477
31613
|
const data = await client2.request("POST", "/create_group", body);
|
|
@@ -31559,9 +31695,7 @@ function registerFriendTools(server, client2) {
|
|
|
31559
31695
|
confirm: schemaConfirm
|
|
31560
31696
|
}
|
|
31561
31697
|
}, async ({ user_email, user_first_name, user_last_name, confirm }) => {
|
|
31562
|
-
const body = { user_email };
|
|
31563
|
-
if (user_first_name !== void 0) body.user_first_name = user_first_name;
|
|
31564
|
-
if (user_last_name !== void 0) body.user_last_name = user_last_name;
|
|
31698
|
+
const body = pruneUndefined({ user_email, user_first_name, user_last_name });
|
|
31565
31699
|
const gate = previewUnlessConfirmed(confirm, `Add ${user_email} as a Splitwise friend`, "POST", "/create_friend", body);
|
|
31566
31700
|
if (gate) return gate;
|
|
31567
31701
|
const data = await client2.request("POST", "/create_friend", body);
|
|
@@ -31715,6 +31849,89 @@ function registerExpenseTools(server, client2) {
|
|
|
31715
31849
|
});
|
|
31716
31850
|
}
|
|
31717
31851
|
|
|
31852
|
+
// src/tools/receipts.ts
|
|
31853
|
+
var MAX_INLINE_BYTES = 4 * 1024 * 1024;
|
|
31854
|
+
var GENERIC_TYPES = /* @__PURE__ */ new Set(["application/octet-stream", "binary/octet-stream"]);
|
|
31855
|
+
function isPdf(bytes) {
|
|
31856
|
+
return bytes.length >= 5 && Buffer.from(bytes.subarray(0, 5)).toString("latin1") === "%PDF-";
|
|
31857
|
+
}
|
|
31858
|
+
function resolveMimeType(contentType, bytes) {
|
|
31859
|
+
const declared = (contentType ?? "").split(";")[0].trim().toLowerCase();
|
|
31860
|
+
if (declared && !GENERIC_TYPES.has(declared)) return declared;
|
|
31861
|
+
return sniffMimeBytes(bytes) ?? (isPdf(bytes) ? "application/pdf" : void 0);
|
|
31862
|
+
}
|
|
31863
|
+
function extensionFromUrl(url2) {
|
|
31864
|
+
const match = /\.([a-z0-9]{1,5})$/i.exec(new URL(url2).pathname);
|
|
31865
|
+
return match ? match[1].toLowerCase() : void 0;
|
|
31866
|
+
}
|
|
31867
|
+
function extensionFor(mimeType, url2) {
|
|
31868
|
+
const subtype = mimeType?.split("/")[1];
|
|
31869
|
+
if (subtype === "jpeg") return "jpg";
|
|
31870
|
+
if (subtype && /^[a-z0-9]{1,5}$/.test(subtype)) return subtype;
|
|
31871
|
+
return extensionFromUrl(url2) ?? "bin";
|
|
31872
|
+
}
|
|
31873
|
+
function registerReceiptTools(server, client2) {
|
|
31874
|
+
server.registerTool("sw_get_receipt", {
|
|
31875
|
+
description: "Download the receipt image or PDF attached to a Splitwise expense. The receipt URLs returned by sw_get_expense need the server's credentials \u2014 fetching them directly returns 401 \u2014 so use this tool instead. It writes the authenticated bytes to a file and returns the path; set inline:true to also get an image receipt back in the result. Files go to output_dir, else $SPLITWISE_OUTPUT_DIR, else the current directory, and an existing file is never overwritten.",
|
|
31876
|
+
// Not read-only: this writes a file into a caller-supplied directory, and
|
|
31877
|
+
// `readOnlyHint` is what a host reads when deciding to skip its approval
|
|
31878
|
+
// prompt. Not destructive either — `uniquePath` always picks a filename
|
|
31879
|
+
// that doesn't exist, so an existing file is never touched.
|
|
31880
|
+
annotations: { readOnlyHint: false, destructiveHint: false },
|
|
31881
|
+
inputSchema: {
|
|
31882
|
+
id: external_exports.number().describe("Expense ID (the same id sw_get_expense takes)"),
|
|
31883
|
+
size: external_exports.enum(["original", "large"]).describe("Which stored rendition to fetch. Defaults to 'original' (full quality, and the only one a PDF receipt has). Falls back to the other rendition when the requested one is absent.").optional(),
|
|
31884
|
+
output_dir: external_exports.string().describe("Directory to write the receipt into. Defaults to $SPLITWISE_OUTPUT_DIR, else the current working directory.").optional(),
|
|
31885
|
+
inline: external_exports.boolean().describe(`Also return the receipt as a base64 image in the result. Image receipts only, and only under ${MAX_INLINE_BYTES} bytes.`).optional()
|
|
31886
|
+
}
|
|
31887
|
+
}, async ({ id, size, output_dir, inline }) => {
|
|
31888
|
+
const wanted = size ?? "original";
|
|
31889
|
+
const fallback = wanted === "original" ? "large" : "original";
|
|
31890
|
+
const expense = await client2.request(
|
|
31891
|
+
"GET",
|
|
31892
|
+
`/get_expense/${id}`
|
|
31893
|
+
);
|
|
31894
|
+
const receipt = expense?.expense?.receipt;
|
|
31895
|
+
const url2 = receipt?.[wanted] || receipt?.[fallback];
|
|
31896
|
+
if (!url2) {
|
|
31897
|
+
throw new Error(`Splitwise expense ${id} has no receipt attached.`);
|
|
31898
|
+
}
|
|
31899
|
+
const served = receipt?.[wanted] ? wanted : fallback;
|
|
31900
|
+
const asset = await client2.fetchAsset(url2);
|
|
31901
|
+
if (asset.bytes.length === 0) {
|
|
31902
|
+
throw new Error(`Splitwise returned an empty receipt body for expense ${id}.`);
|
|
31903
|
+
}
|
|
31904
|
+
const mimeType = resolveMimeType(asset.contentType, asset.bytes);
|
|
31905
|
+
const base643 = Buffer.from(asset.bytes).toString("base64");
|
|
31906
|
+
const path = writeBinaryOutput({
|
|
31907
|
+
dir: resolveOutputDir(output_dir, "SPLITWISE_OUTPUT_DIR"),
|
|
31908
|
+
baseName: `splitwise-receipt-${id}`,
|
|
31909
|
+
base64: base643,
|
|
31910
|
+
extension: extensionFor(mimeType, url2)
|
|
31911
|
+
});
|
|
31912
|
+
const imageMime = mimeType?.startsWith("image/") ? mimeType : void 0;
|
|
31913
|
+
const inlined = inline === true && imageMime !== void 0 && asset.bytes.length <= MAX_INLINE_BYTES;
|
|
31914
|
+
const result = textResult({
|
|
31915
|
+
expense_id: id,
|
|
31916
|
+
size: served,
|
|
31917
|
+
...served !== wanted ? { requested_size: wanted, note: `Expense ${id} has no ${wanted} rendition; fetched ${served}.` } : {},
|
|
31918
|
+
path,
|
|
31919
|
+
bytes: asset.bytes.length,
|
|
31920
|
+
content_type: mimeType ?? "unknown",
|
|
31921
|
+
// The receipt URL itself is a signed capability, so report only its host.
|
|
31922
|
+
source_host: new URL(url2).host,
|
|
31923
|
+
inline: inlined,
|
|
31924
|
+
...inline === true && !inlined ? {
|
|
31925
|
+
inline_skipped: imageMime ? `Receipt is ${asset.bytes.length} bytes, over the ${MAX_INLINE_BYTES}-byte inline limit \u2014 open the file at the path above.` : "Only image receipts can be returned inline \u2014 open the file at the path above."
|
|
31926
|
+
} : {}
|
|
31927
|
+
});
|
|
31928
|
+
if (inlined && imageMime) {
|
|
31929
|
+
result.content.push(...imageResult(base643, imageMime).content);
|
|
31930
|
+
}
|
|
31931
|
+
return result;
|
|
31932
|
+
});
|
|
31933
|
+
}
|
|
31934
|
+
|
|
31718
31935
|
// src/tools/utilities.ts
|
|
31719
31936
|
function registerUtilityTools(server, client2) {
|
|
31720
31937
|
server.registerTool("sw_get_notifications", {
|
|
@@ -31780,7 +31997,7 @@ function registerUtilityTools(server, client2) {
|
|
|
31780
31997
|
// src/index.ts
|
|
31781
31998
|
await runMcp({
|
|
31782
31999
|
name: "splitwise-mcp",
|
|
31783
|
-
version: "2.
|
|
32000
|
+
version: "2.2.0",
|
|
31784
32001
|
// x-release-please-version
|
|
31785
32002
|
deps: client,
|
|
31786
32003
|
banner: "[splitwise-mcp] This project was developed and is maintained by AI (Claude Sonnet 4.6). Use at your own discretion.",
|
|
@@ -31789,6 +32006,7 @@ await runMcp({
|
|
|
31789
32006
|
registerGroupTools,
|
|
31790
32007
|
registerFriendTools,
|
|
31791
32008
|
registerExpenseTools,
|
|
32009
|
+
registerReceiptTools,
|
|
31792
32010
|
registerUtilityTools
|
|
31793
32011
|
]
|
|
31794
32012
|
});
|
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, readEnvVar, createApiClient } from '@chrischall/mcp-utils';
|
|
3
|
+
import { loadDotenvSafely, readEnvVar, createApiClient, ApiError, } from '@chrischall/mcp-utils';
|
|
4
4
|
// Load .env for local dev; silently skip if dotenv is unavailable (e.g. mcpb
|
|
5
5
|
// bundle). `loadDotenvSafely` swallows a missing dotenv module and never lets
|
|
6
6
|
// .env override a host-provided value.
|
|
@@ -16,10 +16,55 @@ catch {
|
|
|
16
16
|
}
|
|
17
17
|
const BASE_URL = 'https://secure.splitwise.com/api/v3.0';
|
|
18
18
|
const SERVICE_NAME = 'Splitwise';
|
|
19
|
+
const SPLITWISE_DOMAIN = 'splitwise.com';
|
|
20
|
+
/**
|
|
21
|
+
* Whether a hostname belongs to Splitwise, and may therefore be sent the API
|
|
22
|
+
* key. Receipt bytes are served either from a Splitwise host (which requires
|
|
23
|
+
* the key) or from a presigned third-party URL (S3), which must never see it.
|
|
24
|
+
*/
|
|
25
|
+
export function isSplitwiseHost(hostname) {
|
|
26
|
+
const host = hostname.toLowerCase();
|
|
27
|
+
return host === SPLITWISE_DOMAIN || host.endsWith(`.${SPLITWISE_DOMAIN}`);
|
|
28
|
+
}
|
|
29
|
+
/** Parse an asset URL handed back by the API, rejecting anything not https. */
|
|
30
|
+
function parseAssetUrl(url) {
|
|
31
|
+
let parsed;
|
|
32
|
+
try {
|
|
33
|
+
parsed = new URL(url);
|
|
34
|
+
}
|
|
35
|
+
catch {
|
|
36
|
+
// Deliberately not echoing the URL: it can carry a signed token.
|
|
37
|
+
throw new Error('Splitwise returned an asset URL that could not be parsed');
|
|
38
|
+
}
|
|
39
|
+
if (parsed.protocol !== 'https:') {
|
|
40
|
+
throw new Error(`Refusing to fetch an asset over ${parsed.protocol}// — https only`);
|
|
41
|
+
}
|
|
42
|
+
return parsed;
|
|
43
|
+
}
|
|
44
|
+
/**
|
|
45
|
+
* Strip the asset URL's signed query string out of an error message. A failed
|
|
46
|
+
* `fetchRaw` names the path it requested, and a receipt URL's query carries a
|
|
47
|
+
* signed, capability-bearing token that must not reach a tool result.
|
|
48
|
+
*
|
|
49
|
+
* Matches the exact query bytes we sent rather than a pattern for the message
|
|
50
|
+
* shape: that keeps working whatever an upstream formatter does with the URL
|
|
51
|
+
* (relative path today, absolute URL tomorrow), and it can't mangle an error
|
|
52
|
+
* body that happens to contain a `?`.
|
|
53
|
+
*/
|
|
54
|
+
function redactAssetQuery(err, search) {
|
|
55
|
+
if (!(err instanceof Error) || !search)
|
|
56
|
+
return err;
|
|
57
|
+
const message = err.message.split(search).join('?<redacted>');
|
|
58
|
+
if (message === err.message)
|
|
59
|
+
return err;
|
|
60
|
+
return err instanceof ApiError ? new ApiError(err.status, message) : new Error(message);
|
|
61
|
+
}
|
|
19
62
|
export class SplitwiseClient {
|
|
20
63
|
apiKey;
|
|
21
64
|
configError;
|
|
22
65
|
api;
|
|
66
|
+
/** Per-origin clients for asset hosts, built on first use by `fetchAsset`. */
|
|
67
|
+
assetClients = new Map();
|
|
23
68
|
/**
|
|
24
69
|
* Defer the config error so the server can still start (and respond to the
|
|
25
70
|
* host's install-time smoke test) when SPLITWISE_API_KEY isn't set yet.
|
|
@@ -44,15 +89,28 @@ export class SplitwiseClient {
|
|
|
44
89
|
this.apiKey = key;
|
|
45
90
|
this.configError = null;
|
|
46
91
|
}
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
92
|
+
this.api = this.buildApiClient(BASE_URL, true);
|
|
93
|
+
}
|
|
94
|
+
/**
|
|
95
|
+
* One API client per origin, sharing the fleet's retry/timeout/error policy.
|
|
96
|
+
* `authenticated` decides whether the API key is attached: `getToken` defers
|
|
97
|
+
* config errors to request time, and on* handlers preserve Splitwise's
|
|
98
|
+
* documented 401/429 messages.
|
|
99
|
+
*/
|
|
100
|
+
buildApiClient(baseUrl, authenticated) {
|
|
101
|
+
// An anonymous asset origin isn't Splitwise, so name it accurately in
|
|
102
|
+
// errors rather than blaming the Splitwise API for a third party's 429.
|
|
103
|
+
const service = authenticated ? SERVICE_NAME : new URL(baseUrl).host;
|
|
104
|
+
return createApiClient({
|
|
105
|
+
baseUrl,
|
|
106
|
+
serviceName: service,
|
|
52
107
|
retry: { count: 1, delayMs: 2000 },
|
|
53
108
|
timeout: 30_000,
|
|
54
|
-
|
|
55
|
-
|
|
109
|
+
...(authenticated ? { getToken: () => this.requireKey() } : {}),
|
|
110
|
+
onUnauthorized: () => new Error(authenticated
|
|
111
|
+
? 'SPLITWISE_API_KEY is invalid or missing'
|
|
112
|
+
: 'The asset URL was rejected (401) — presigned URLs are short-lived, so re-run to get a fresh one'),
|
|
113
|
+
onRateLimited: () => new Error(`Rate limited by ${authenticated ? 'Splitwise API' : service}`),
|
|
56
114
|
});
|
|
57
115
|
}
|
|
58
116
|
requireKey() {
|
|
@@ -63,6 +121,35 @@ export class SplitwiseClient {
|
|
|
63
121
|
async request(method, path, body) {
|
|
64
122
|
return this.api.fetchJson(method, path, body !== undefined ? { body } : {});
|
|
65
123
|
}
|
|
124
|
+
/**
|
|
125
|
+
* Fetch the raw bytes behind an absolute asset URL the API handed back (an
|
|
126
|
+
* expense receipt). Splitwise serves receipts two ways and this covers both:
|
|
127
|
+
* from its own API (`https://www.splitwise.com/api/v4.0/expenses/{id}/receipt`),
|
|
128
|
+
* which 401s without the key, and from a presigned S3 URL, which carries its
|
|
129
|
+
* own signature and must NOT be sent the key. `isSplitwiseHost` picks.
|
|
130
|
+
*
|
|
131
|
+
* A cross-origin redirect (API → S3) is safe: per the Fetch standard the
|
|
132
|
+
* `Authorization` header is dropped when a redirect changes origin.
|
|
133
|
+
*/
|
|
134
|
+
async fetchAsset(url) {
|
|
135
|
+
const target = parseAssetUrl(url);
|
|
136
|
+
const authenticated = isSplitwiseHost(target.hostname);
|
|
137
|
+
const cacheKey = `${target.origin}|${authenticated ? 'auth' : 'anon'}`;
|
|
138
|
+
let api = this.assetClients.get(cacheKey);
|
|
139
|
+
if (!api) {
|
|
140
|
+
api = this.buildApiClient(target.origin, authenticated);
|
|
141
|
+
this.assetClients.set(cacheKey, api);
|
|
142
|
+
}
|
|
143
|
+
try {
|
|
144
|
+
// `pathname + search` verbatim rather than the client's `query` option:
|
|
145
|
+
// a presigned signature covers the exact encoding, so re-encoding it
|
|
146
|
+
// would invalidate the URL.
|
|
147
|
+
return await api.fetchRaw('GET', `${target.pathname}${target.search}`);
|
|
148
|
+
}
|
|
149
|
+
catch (err) {
|
|
150
|
+
throw redactAssetQuery(err, target.search);
|
|
151
|
+
}
|
|
152
|
+
}
|
|
66
153
|
}
|
|
67
154
|
/**
|
|
68
155
|
* Module-level singleton shared by every tool module. Constructing it here (not
|
package/dist/index.js
CHANGED
|
@@ -5,6 +5,7 @@ import { registerUserTools } from './tools/user.js';
|
|
|
5
5
|
import { registerGroupTools } from './tools/groups.js';
|
|
6
6
|
import { registerFriendTools } from './tools/friends.js';
|
|
7
7
|
import { registerExpenseTools } from './tools/expenses.js';
|
|
8
|
+
import { registerReceiptTools } from './tools/receipts.js';
|
|
8
9
|
import { registerUtilityTools } from './tools/utilities.js';
|
|
9
10
|
// runMcp builds the McpServer, applies the registrars (threading `client`
|
|
10
11
|
// through as deps), prints the banner to stderr, wires graceful shutdown, and
|
|
@@ -17,7 +18,7 @@ import { registerUtilityTools } from './tools/utilities.js';
|
|
|
17
18
|
// client into the same registrars instead of this singleton.
|
|
18
19
|
await runMcp({
|
|
19
20
|
name: 'splitwise-mcp',
|
|
20
|
-
version: '2.
|
|
21
|
+
version: '2.2.0', // x-release-please-version
|
|
21
22
|
deps: client,
|
|
22
23
|
banner: '[splitwise-mcp] This project was developed and is maintained by AI (Claude Sonnet 4.6). Use at your own discretion.',
|
|
23
24
|
tools: [
|
|
@@ -25,6 +26,7 @@ await runMcp({
|
|
|
25
26
|
registerGroupTools,
|
|
26
27
|
registerFriendTools,
|
|
27
28
|
registerExpenseTools,
|
|
29
|
+
registerReceiptTools,
|
|
28
30
|
registerUtilityTools,
|
|
29
31
|
],
|
|
30
32
|
});
|
package/dist/tools/friends.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { z } from 'zod';
|
|
2
|
-
import { textResult } from '@chrischall/mcp-utils';
|
|
2
|
+
import { textResult, pruneUndefined } from '@chrischall/mcp-utils';
|
|
3
3
|
import { previewUnlessConfirmed, schemaConfirm } from './_confirm.js';
|
|
4
4
|
export function registerFriendTools(server, client) {
|
|
5
5
|
server.registerTool('sw_list_friends', {
|
|
@@ -19,11 +19,7 @@ export function registerFriendTools(server, client) {
|
|
|
19
19
|
confirm: schemaConfirm,
|
|
20
20
|
},
|
|
21
21
|
}, async ({ user_email, user_first_name, user_last_name, confirm }) => {
|
|
22
|
-
const body = { user_email };
|
|
23
|
-
if (user_first_name !== undefined)
|
|
24
|
-
body.user_first_name = user_first_name;
|
|
25
|
-
if (user_last_name !== undefined)
|
|
26
|
-
body.user_last_name = user_last_name;
|
|
22
|
+
const body = pruneUndefined({ user_email, user_first_name, user_last_name });
|
|
27
23
|
const gate = previewUnlessConfirmed(confirm, `Add ${user_email} as a Splitwise friend`, 'POST', '/create_friend', body);
|
|
28
24
|
if (gate)
|
|
29
25
|
return gate;
|
package/dist/tools/groups.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { z } from 'zod';
|
|
2
|
-
import { textResult } from '@chrischall/mcp-utils';
|
|
2
|
+
import { textResult, pruneUndefined } from '@chrischall/mcp-utils';
|
|
3
3
|
import { previewUnlessConfirmed, schemaConfirm } from './_confirm.js';
|
|
4
4
|
export function registerGroupTools(server, client) {
|
|
5
5
|
server.registerTool('sw_list_groups', {
|
|
@@ -29,11 +29,7 @@ export function registerGroupTools(server, client) {
|
|
|
29
29
|
confirm: schemaConfirm,
|
|
30
30
|
},
|
|
31
31
|
}, async ({ name, group_type, simplify_by_default, confirm }) => {
|
|
32
|
-
const body = { name };
|
|
33
|
-
if (group_type !== undefined)
|
|
34
|
-
body.group_type = group_type;
|
|
35
|
-
if (simplify_by_default !== undefined)
|
|
36
|
-
body.simplify_by_default = simplify_by_default;
|
|
32
|
+
const body = pruneUndefined({ name, group_type, simplify_by_default });
|
|
37
33
|
const gate = previewUnlessConfirmed(confirm, `Create Splitwise group "${name}"`, 'POST', '/create_group', body);
|
|
38
34
|
if (gate)
|
|
39
35
|
return gate;
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
import { z } from 'zod';
|
|
2
|
+
import { textResult, imageResult, resolveOutputDir, writeBinaryOutput, sniffMimeBytes, } from '@chrischall/mcp-utils';
|
|
3
|
+
/** Cap on bytes returned as base64 in the tool result; the file is written regardless. */
|
|
4
|
+
const MAX_INLINE_BYTES = 4 * 1024 * 1024;
|
|
5
|
+
/** Content types that carry no information about the actual format. */
|
|
6
|
+
const GENERIC_TYPES = new Set(['application/octet-stream', 'binary/octet-stream']);
|
|
7
|
+
/** `%PDF-` magic bytes — `sniffMimeBytes` only knows the image formats. */
|
|
8
|
+
function isPdf(bytes) {
|
|
9
|
+
return bytes.length >= 5 && Buffer.from(bytes.subarray(0, 5)).toString('latin1') === '%PDF-';
|
|
10
|
+
}
|
|
11
|
+
/**
|
|
12
|
+
* Best-effort MIME type for the downloaded bytes: the response's Content-Type
|
|
13
|
+
* when it says something useful, else the magic bytes, else undefined.
|
|
14
|
+
*/
|
|
15
|
+
function resolveMimeType(contentType, bytes) {
|
|
16
|
+
const declared = (contentType ?? '').split(';')[0].trim().toLowerCase();
|
|
17
|
+
if (declared && !GENERIC_TYPES.has(declared))
|
|
18
|
+
return declared;
|
|
19
|
+
return sniffMimeBytes(bytes) ?? (isPdf(bytes) ? 'application/pdf' : undefined);
|
|
20
|
+
}
|
|
21
|
+
/** Extension from a URL path (`…/receipt.jpg?sig=…` → `jpg`), when it has one. */
|
|
22
|
+
function extensionFromUrl(url) {
|
|
23
|
+
const match = /\.([a-z0-9]{1,5})$/i.exec(new URL(url).pathname);
|
|
24
|
+
return match ? match[1].toLowerCase() : undefined;
|
|
25
|
+
}
|
|
26
|
+
/**
|
|
27
|
+
* File extension for the receipt: the MIME subtype when it looks like a plain
|
|
28
|
+
* one (`application/pdf` → `pdf`, `image/heic` → `heic`), else the URL's own
|
|
29
|
+
* extension, else `bin`.
|
|
30
|
+
*/
|
|
31
|
+
function extensionFor(mimeType, url) {
|
|
32
|
+
const subtype = mimeType?.split('/')[1];
|
|
33
|
+
if (subtype === 'jpeg')
|
|
34
|
+
return 'jpg';
|
|
35
|
+
if (subtype && /^[a-z0-9]{1,5}$/.test(subtype))
|
|
36
|
+
return subtype;
|
|
37
|
+
return extensionFromUrl(url) ?? 'bin';
|
|
38
|
+
}
|
|
39
|
+
export function registerReceiptTools(server, client) {
|
|
40
|
+
server.registerTool('sw_get_receipt', {
|
|
41
|
+
description: "Download the receipt image or PDF attached to a Splitwise expense. The receipt URLs returned by sw_get_expense need the server's credentials — fetching them directly returns 401 — so use this tool instead. It writes the authenticated bytes to a file and returns the path; set inline:true to also get an image receipt back in the result. Files go to output_dir, else $SPLITWISE_OUTPUT_DIR, else the current directory, and an existing file is never overwritten.",
|
|
42
|
+
// Not read-only: this writes a file into a caller-supplied directory, and
|
|
43
|
+
// `readOnlyHint` is what a host reads when deciding to skip its approval
|
|
44
|
+
// prompt. Not destructive either — `uniquePath` always picks a filename
|
|
45
|
+
// that doesn't exist, so an existing file is never touched.
|
|
46
|
+
annotations: { readOnlyHint: false, destructiveHint: false },
|
|
47
|
+
inputSchema: {
|
|
48
|
+
id: z.number().describe('Expense ID (the same id sw_get_expense takes)'),
|
|
49
|
+
size: z
|
|
50
|
+
.enum(['original', 'large'])
|
|
51
|
+
.describe("Which stored rendition to fetch. Defaults to 'original' (full quality, and the only one a PDF receipt has). Falls back to the other rendition when the requested one is absent.")
|
|
52
|
+
.optional(),
|
|
53
|
+
output_dir: z
|
|
54
|
+
.string()
|
|
55
|
+
.describe('Directory to write the receipt into. Defaults to $SPLITWISE_OUTPUT_DIR, else the current working directory.')
|
|
56
|
+
.optional(),
|
|
57
|
+
inline: z
|
|
58
|
+
.boolean()
|
|
59
|
+
.describe(`Also return the receipt as a base64 image in the result. Image receipts only, and only under ${MAX_INLINE_BYTES} bytes.`)
|
|
60
|
+
.optional(),
|
|
61
|
+
},
|
|
62
|
+
}, async ({ id, size, output_dir, inline }) => {
|
|
63
|
+
const wanted = size ?? 'original';
|
|
64
|
+
const fallback = wanted === 'original' ? 'large' : 'original';
|
|
65
|
+
const expense = await client.request('GET', `/get_expense/${id}`);
|
|
66
|
+
const receipt = expense?.expense?.receipt;
|
|
67
|
+
const url = receipt?.[wanted] || receipt?.[fallback];
|
|
68
|
+
if (!url) {
|
|
69
|
+
throw new Error(`Splitwise expense ${id} has no receipt attached.`);
|
|
70
|
+
}
|
|
71
|
+
const served = receipt?.[wanted] ? wanted : fallback;
|
|
72
|
+
const asset = await client.fetchAsset(url);
|
|
73
|
+
if (asset.bytes.length === 0) {
|
|
74
|
+
throw new Error(`Splitwise returned an empty receipt body for expense ${id}.`);
|
|
75
|
+
}
|
|
76
|
+
const mimeType = resolveMimeType(asset.contentType, asset.bytes);
|
|
77
|
+
const base64 = Buffer.from(asset.bytes).toString('base64');
|
|
78
|
+
const path = writeBinaryOutput({
|
|
79
|
+
dir: resolveOutputDir(output_dir, 'SPLITWISE_OUTPUT_DIR'),
|
|
80
|
+
baseName: `splitwise-receipt-${id}`,
|
|
81
|
+
base64,
|
|
82
|
+
extension: extensionFor(mimeType, url),
|
|
83
|
+
});
|
|
84
|
+
const imageMime = mimeType?.startsWith('image/') ? mimeType : undefined;
|
|
85
|
+
const inlined = inline === true && imageMime !== undefined && asset.bytes.length <= MAX_INLINE_BYTES;
|
|
86
|
+
const result = textResult({
|
|
87
|
+
expense_id: id,
|
|
88
|
+
size: served,
|
|
89
|
+
...(served !== wanted
|
|
90
|
+
? { requested_size: wanted, note: `Expense ${id} has no ${wanted} rendition; fetched ${served}.` }
|
|
91
|
+
: {}),
|
|
92
|
+
path,
|
|
93
|
+
bytes: asset.bytes.length,
|
|
94
|
+
content_type: mimeType ?? 'unknown',
|
|
95
|
+
// The receipt URL itself is a signed capability, so report only its host.
|
|
96
|
+
source_host: new URL(url).host,
|
|
97
|
+
inline: inlined,
|
|
98
|
+
...(inline === true && !inlined
|
|
99
|
+
? {
|
|
100
|
+
inline_skipped: imageMime
|
|
101
|
+
? `Receipt is ${asset.bytes.length} bytes, over the ${MAX_INLINE_BYTES}-byte inline limit — open the file at the path above.`
|
|
102
|
+
: 'Only image receipts can be returned inline — open the file at the path above.',
|
|
103
|
+
}
|
|
104
|
+
: {}),
|
|
105
|
+
});
|
|
106
|
+
if (inlined && imageMime) {
|
|
107
|
+
result.content.push(...imageResult(base64, imageMime).content);
|
|
108
|
+
}
|
|
109
|
+
return result;
|
|
110
|
+
});
|
|
111
|
+
}
|
package/dist/tools/user.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { z } from 'zod';
|
|
2
|
-
import { textResult } from '@chrischall/mcp-utils';
|
|
2
|
+
import { textResult, pruneUndefined } from '@chrischall/mcp-utils';
|
|
3
3
|
import { previewUnlessConfirmed, schemaConfirm } from './_confirm.js';
|
|
4
4
|
export function registerUserTools(server, client) {
|
|
5
5
|
server.registerTool('sw_get_current_user', {
|
|
@@ -32,19 +32,7 @@ export function registerUserTools(server, client) {
|
|
|
32
32
|
confirm: schemaConfirm,
|
|
33
33
|
},
|
|
34
34
|
}, async ({ id, first_name, last_name, email, password, locale, default_currency, confirm }) => {
|
|
35
|
-
const body = {};
|
|
36
|
-
if (first_name !== undefined)
|
|
37
|
-
body.first_name = first_name;
|
|
38
|
-
if (last_name !== undefined)
|
|
39
|
-
body.last_name = last_name;
|
|
40
|
-
if (email !== undefined)
|
|
41
|
-
body.email = email;
|
|
42
|
-
if (password !== undefined)
|
|
43
|
-
body.password = password;
|
|
44
|
-
if (locale !== undefined)
|
|
45
|
-
body.locale = locale;
|
|
46
|
-
if (default_currency !== undefined)
|
|
47
|
-
body.default_currency = default_currency;
|
|
35
|
+
const body = pruneUndefined({ first_name, last_name, email, password, locale, default_currency });
|
|
48
36
|
// Never echo the password in the dry-run preview.
|
|
49
37
|
const previewBody = { ...body, ...(password !== undefined ? { password: '[hidden]' } : {}) };
|
|
50
38
|
const gate = previewUnlessConfirmed(confirm, `Update current Splitwise user ${id} profile`, 'POST', `/update_user/${id}`, previewBody);
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "splitwise-mcp",
|
|
3
|
-
"version": "2.
|
|
3
|
+
"version": "2.2.0",
|
|
4
4
|
"mcpName": "io.github.chrischall/splitwise-mcp",
|
|
5
5
|
"description": "Splitwise MCP server for Claude — developed and maintained by AI (Claude Code)",
|
|
6
6
|
"author": "Claude Code (AI) <https://www.anthropic.com/claude>",
|
package/server.json
CHANGED
|
@@ -6,12 +6,12 @@
|
|
|
6
6
|
"url": "https://github.com/chrischall/splitwise-mcp",
|
|
7
7
|
"source": "github"
|
|
8
8
|
},
|
|
9
|
-
"version": "2.
|
|
9
|
+
"version": "2.2.0",
|
|
10
10
|
"packages": [
|
|
11
11
|
{
|
|
12
12
|
"registryType": "npm",
|
|
13
13
|
"identifier": "splitwise-mcp",
|
|
14
|
-
"version": "2.
|
|
14
|
+
"version": "2.2.0",
|
|
15
15
|
"transport": {
|
|
16
16
|
"type": "stdio"
|
|
17
17
|
},
|
|
@@ -22,6 +22,13 @@
|
|
|
22
22
|
"isRequired": true,
|
|
23
23
|
"format": "string",
|
|
24
24
|
"isSecret": true
|
|
25
|
+
},
|
|
26
|
+
{
|
|
27
|
+
"name": "SPLITWISE_OUTPUT_DIR",
|
|
28
|
+
"description": "Directory sw_get_receipt writes downloaded receipts into (default: the working directory)",
|
|
29
|
+
"isRequired": false,
|
|
30
|
+
"format": "string",
|
|
31
|
+
"isSecret": false
|
|
25
32
|
}
|
|
26
33
|
]
|
|
27
34
|
}
|
|
@@ -95,6 +95,12 @@ API key auth — no login flow or token rotation. The key is attached to every r
|
|
|
95
95
|
| `sw_create_expense(group_id, description, cost, split_equally? \| users?)` | Create an expense — equal split or custom per-person split |
|
|
96
96
|
| `sw_update_expense(expense_id, ...)` | Edit an existing expense (custom split requires full `users` array) |
|
|
97
97
|
| `sw_delete_expense(id)` | Soft-delete an expense |
|
|
98
|
+
| `sw_undelete_expense(id)` | Restore a soft-deleted expense |
|
|
99
|
+
|
|
100
|
+
### Receipts
|
|
101
|
+
| Tool | Description |
|
|
102
|
+
|------|-------------|
|
|
103
|
+
| `sw_get_receipt(id, size?, output_dir?, inline?)` | Download the receipt attached to an expense and write it to a file. Returns the path, byte count, and content type; `inline: true` also returns an image receipt in the result |
|
|
98
104
|
|
|
99
105
|
### Utilities
|
|
100
106
|
| Tool | Description |
|
|
@@ -128,6 +134,12 @@ sw_create_expense(group_id, "Hotel", "200.00", users: [
|
|
|
128
134
|
])
|
|
129
135
|
```
|
|
130
136
|
|
|
137
|
+
**Get the receipt for an expense:**
|
|
138
|
+
```
|
|
139
|
+
sw_list_expenses(...) → find expense ID
|
|
140
|
+
sw_get_receipt(id) → writes e.g. ./splitwise-receipt-4644814211.pdf
|
|
141
|
+
```
|
|
142
|
+
|
|
131
143
|
**Search and edit an expense:**
|
|
132
144
|
```
|
|
133
145
|
sw_list_expenses(group_id, dated_after: "2026-01-01") → find expense ID
|
|
@@ -139,5 +151,7 @@ sw_update_expense(expense_id, description: "Corrected description", cost: "95.00
|
|
|
139
151
|
- `cost` is always a decimal string (e.g. `"25.00"`)
|
|
140
152
|
- `split_equally: true` and `users` array are mutually exclusive
|
|
141
153
|
- For custom split updates, the **full `users` array is required** — the API replaces the entire split
|
|
142
|
-
- `sw_delete_expense` is a soft delete
|
|
154
|
+
- `sw_delete_expense` is a soft delete — restore with `sw_undelete_expense`
|
|
155
|
+
- The `receipt.original` / `receipt.large` URLs on an expense are **not public** — fetching them without the API key returns 401. Always use `sw_get_receipt`, which fetches them with the server's own credentials
|
|
156
|
+
- `sw_get_receipt` writes into `output_dir`, else `$SPLITWISE_OUTPUT_DIR`, else the working directory, and never overwrites an existing file
|
|
143
157
|
- API default for `sw_list_expenses` is 20 results when `limit` is omitted
|