splitwise-mcp 2.1.5 → 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 +320 -60
- package/dist/client.js +103 -16
- package/dist/index.js +5 -3
- 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 +4 -13
- package/server.json +9 -2
- package/skills/splitwise-mcp/SKILL.md +15 -1
- package/dist/splitwise-auth.js +0 -23
|
@@ -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,9 +3642,14 @@ 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
|
-
const
|
|
3647
|
+
const { parsed: baseParsed, malformedAuthorityOrPort: baseMalformed } = parseWithStatus(baseURI, schemelessOptions);
|
|
3648
|
+
const { parsed: relativeParsed, malformedAuthorityOrPort: relativeMalformed } = parseWithStatus(relativeURI, schemelessOptions);
|
|
3649
|
+
if (baseMalformed || relativeMalformed) {
|
|
3650
|
+
throw new Error(baseParsed.error || relativeParsed.error || "URI is malformed.");
|
|
3651
|
+
}
|
|
3652
|
+
const resolved = resolveComponent(baseParsed, relativeParsed, schemelessOptions, true);
|
|
3648
3653
|
schemelessOptions.skipEscape = true;
|
|
3649
3654
|
return serialize(resolved, schemelessOptions);
|
|
3650
3655
|
}
|
|
@@ -3770,6 +3775,7 @@ var require_fast_uri = __commonJS({
|
|
|
3770
3775
|
}
|
|
3771
3776
|
var URI_PARSE = /^(?:([^#/:?]+):)?(?:\/\/((?:([^#/?@]*)@)?(\[[^#/?\]]+\]|[^#/:?]*)(?::(\d*))?))?([^#?]*)(?:\?([^#]*))?(?:#((?:.|[\n\r])*))?/u;
|
|
3772
3777
|
var AUTHORITY_PREFIX = /^(?:[^#/:?]+:)?\/\/([^/?#]*)/;
|
|
3778
|
+
var AUTHORITY_INTRODUCER_REGION = /^(?:[^#/:?]+:)?([/\\\t\n\r]*)/;
|
|
3773
3779
|
function getParseError(parsed, matches) {
|
|
3774
3780
|
if (matches[2] !== void 0 && parsed.path && parsed.path[0] !== "/") {
|
|
3775
3781
|
return 'URI path must start with "/" when authority is present.';
|
|
@@ -3804,6 +3810,20 @@ var require_fast_uri = __commonJS({
|
|
|
3804
3810
|
parsed.error = "URI authority must not contain a literal backslash.";
|
|
3805
3811
|
malformedAuthorityOrPort = true;
|
|
3806
3812
|
}
|
|
3813
|
+
const introducerMatch = uri.match(AUTHORITY_INTRODUCER_REGION);
|
|
3814
|
+
if (introducerMatch !== null) {
|
|
3815
|
+
const region = introducerMatch[1];
|
|
3816
|
+
const normalizedRegion = region.replace(/[\t\n\r]/g, "");
|
|
3817
|
+
if (normalizedRegion.length >= 2) {
|
|
3818
|
+
if (normalizedRegion.slice(0, 2) !== "//") {
|
|
3819
|
+
parsed.error = parsed.error || "URI authority must not contain a literal backslash.";
|
|
3820
|
+
malformedAuthorityOrPort = true;
|
|
3821
|
+
} else if (region.length !== normalizedRegion.length) {
|
|
3822
|
+
parsed.error = parsed.error || "URI authority introducer must not contain whitespace.";
|
|
3823
|
+
malformedAuthorityOrPort = true;
|
|
3824
|
+
}
|
|
3825
|
+
}
|
|
3826
|
+
}
|
|
3807
3827
|
const matches = uri.match(URI_PARSE);
|
|
3808
3828
|
if (matches) {
|
|
3809
3829
|
parsed.scheme = matches[1];
|
|
@@ -3906,7 +3926,7 @@ var require_fast_uri = __commonJS({
|
|
|
3906
3926
|
var fastUri = {
|
|
3907
3927
|
SCHEMES,
|
|
3908
3928
|
normalize,
|
|
3909
|
-
resolve,
|
|
3929
|
+
resolve: resolve2,
|
|
3910
3930
|
resolveComponent,
|
|
3911
3931
|
equal,
|
|
3912
3932
|
serialize,
|
|
@@ -23048,17 +23068,33 @@ function normalizeObjectSchema(schema) {
|
|
|
23048
23068
|
}
|
|
23049
23069
|
return void 0;
|
|
23050
23070
|
}
|
|
23071
|
+
function getDotPath(path) {
|
|
23072
|
+
if (path.length === 0) {
|
|
23073
|
+
return "object root";
|
|
23074
|
+
}
|
|
23075
|
+
return path.reduce((acc, seg, index) => {
|
|
23076
|
+
if (index === 0) {
|
|
23077
|
+
return String(seg);
|
|
23078
|
+
}
|
|
23079
|
+
if (typeof seg === "number") {
|
|
23080
|
+
return `${acc}[${seg}]`;
|
|
23081
|
+
}
|
|
23082
|
+
return `${acc}.${seg}`;
|
|
23083
|
+
}, "");
|
|
23084
|
+
}
|
|
23051
23085
|
function getParseErrorMessage(error51) {
|
|
23052
23086
|
if (error51 && typeof error51 === "object") {
|
|
23087
|
+
if ("issues" in error51 && Array.isArray(error51.issues) && error51.issues.length > 0) {
|
|
23088
|
+
return error51.issues.map((i) => {
|
|
23089
|
+
if (!i.path?.length) {
|
|
23090
|
+
return i.message;
|
|
23091
|
+
}
|
|
23092
|
+
return `${i.message} at ${getDotPath(i.path)}`;
|
|
23093
|
+
}).join("\n");
|
|
23094
|
+
}
|
|
23053
23095
|
if ("message" in error51 && typeof error51.message === "string") {
|
|
23054
23096
|
return error51.message;
|
|
23055
23097
|
}
|
|
23056
|
-
if ("issues" in error51 && Array.isArray(error51.issues) && error51.issues.length > 0) {
|
|
23057
|
-
const firstIssue = error51.issues[0];
|
|
23058
|
-
if (firstIssue && typeof firstIssue === "object" && "message" in firstIssue) {
|
|
23059
|
-
return String(firstIssue.message);
|
|
23060
|
-
}
|
|
23061
|
-
}
|
|
23062
23098
|
try {
|
|
23063
23099
|
return JSON.stringify(error51);
|
|
23064
23100
|
} catch {
|
|
@@ -28837,7 +28873,7 @@ var Protocol = class {
|
|
|
28837
28873
|
return;
|
|
28838
28874
|
}
|
|
28839
28875
|
const pollInterval = task2.pollInterval ?? this._options?.defaultTaskPollInterval ?? 1e3;
|
|
28840
|
-
await new Promise((
|
|
28876
|
+
await new Promise((resolve2) => setTimeout(resolve2, pollInterval));
|
|
28841
28877
|
options?.signal?.throwIfAborted();
|
|
28842
28878
|
}
|
|
28843
28879
|
} catch (error51) {
|
|
@@ -28854,7 +28890,7 @@ var Protocol = class {
|
|
|
28854
28890
|
*/
|
|
28855
28891
|
request(request, resultSchema, options) {
|
|
28856
28892
|
const { relatedRequestId, resumptionToken, onresumptiontoken, task, relatedTask } = options ?? {};
|
|
28857
|
-
return new Promise((
|
|
28893
|
+
return new Promise((resolve2, reject) => {
|
|
28858
28894
|
const earlyReject = (error51) => {
|
|
28859
28895
|
reject(error51);
|
|
28860
28896
|
};
|
|
@@ -28932,7 +28968,7 @@ var Protocol = class {
|
|
|
28932
28968
|
if (!parseResult.success) {
|
|
28933
28969
|
reject(parseResult.error);
|
|
28934
28970
|
} else {
|
|
28935
|
-
|
|
28971
|
+
resolve2(parseResult.data);
|
|
28936
28972
|
}
|
|
28937
28973
|
} catch (error51) {
|
|
28938
28974
|
reject(error51);
|
|
@@ -29193,12 +29229,12 @@ var Protocol = class {
|
|
|
29193
29229
|
}
|
|
29194
29230
|
} catch {
|
|
29195
29231
|
}
|
|
29196
|
-
return new Promise((
|
|
29232
|
+
return new Promise((resolve2, reject) => {
|
|
29197
29233
|
if (signal.aborted) {
|
|
29198
29234
|
reject(new McpError(ErrorCode.InvalidRequest, "Request cancelled"));
|
|
29199
29235
|
return;
|
|
29200
29236
|
}
|
|
29201
|
-
const timeoutId = setTimeout(
|
|
29237
|
+
const timeoutId = setTimeout(resolve2, interval);
|
|
29202
29238
|
signal.addEventListener("abort", () => {
|
|
29203
29239
|
clearTimeout(timeoutId);
|
|
29204
29240
|
reject(new McpError(ErrorCode.InvalidRequest, "Request cancelled"));
|
|
@@ -29673,16 +29709,7 @@ var Server = class extends Protocol {
|
|
|
29673
29709
|
if (!methodSchema) {
|
|
29674
29710
|
throw new Error("Schema is missing a method literal");
|
|
29675
29711
|
}
|
|
29676
|
-
|
|
29677
|
-
if (isZ4Schema(methodSchema)) {
|
|
29678
|
-
const v4Schema = methodSchema;
|
|
29679
|
-
const v4Def = v4Schema._zod?.def;
|
|
29680
|
-
methodValue = v4Def?.value ?? v4Schema.value;
|
|
29681
|
-
} else {
|
|
29682
|
-
const v3Schema = methodSchema;
|
|
29683
|
-
const legacyDef = v3Schema._def;
|
|
29684
|
-
methodValue = legacyDef?.value ?? v3Schema.value;
|
|
29685
|
-
}
|
|
29712
|
+
const methodValue = getLiteralValue(methodSchema);
|
|
29686
29713
|
if (typeof methodValue !== "string") {
|
|
29687
29714
|
throw new Error("Schema method literal must be a string");
|
|
29688
29715
|
}
|
|
@@ -30298,7 +30325,7 @@ var McpServer = class {
|
|
|
30298
30325
|
let task = createTaskResult.task;
|
|
30299
30326
|
const pollInterval = task.pollInterval ?? 5e3;
|
|
30300
30327
|
while (task.status !== "completed" && task.status !== "failed" && task.status !== "cancelled") {
|
|
30301
|
-
await new Promise((
|
|
30328
|
+
await new Promise((resolve2) => setTimeout(resolve2, pollInterval));
|
|
30302
30329
|
const updatedTask = await extra.taskStore.getTask(taskId);
|
|
30303
30330
|
if (!updatedTask) {
|
|
30304
30331
|
throw new McpError(ErrorCode.InternalError, `Task ${taskId} not found during polling`);
|
|
@@ -30870,8 +30897,17 @@ var EMPTY_COMPLETION_RESULT = {
|
|
|
30870
30897
|
import process3 from "node:process";
|
|
30871
30898
|
|
|
30872
30899
|
// node_modules/@modelcontextprotocol/sdk/dist/esm/shared/stdio.js
|
|
30900
|
+
var STDIO_DEFAULT_MAX_BUFFER_SIZE = 10 * 1024 * 1024;
|
|
30873
30901
|
var ReadBuffer = class {
|
|
30902
|
+
constructor(options) {
|
|
30903
|
+
this._maxBufferSize = options?.maxBufferSize ?? STDIO_DEFAULT_MAX_BUFFER_SIZE;
|
|
30904
|
+
}
|
|
30874
30905
|
append(chunk) {
|
|
30906
|
+
const newSize = (this._buffer?.length ?? 0) + chunk.length;
|
|
30907
|
+
if (newSize > this._maxBufferSize) {
|
|
30908
|
+
this.clear();
|
|
30909
|
+
throw new Error(`ReadBuffer exceeded maximum size of ${this._maxBufferSize} bytes`);
|
|
30910
|
+
}
|
|
30875
30911
|
this._buffer = this._buffer ? Buffer.concat([this._buffer, chunk]) : chunk;
|
|
30876
30912
|
}
|
|
30877
30913
|
readMessage() {
|
|
@@ -30899,18 +30935,24 @@ function serializeMessage(message) {
|
|
|
30899
30935
|
|
|
30900
30936
|
// node_modules/@modelcontextprotocol/sdk/dist/esm/server/stdio.js
|
|
30901
30937
|
var StdioServerTransport = class {
|
|
30902
|
-
constructor(_stdin = process3.stdin, _stdout = process3.stdout) {
|
|
30938
|
+
constructor(_stdin = process3.stdin, _stdout = process3.stdout, options) {
|
|
30903
30939
|
this._stdin = _stdin;
|
|
30904
30940
|
this._stdout = _stdout;
|
|
30905
|
-
this._readBuffer = new ReadBuffer();
|
|
30906
30941
|
this._started = false;
|
|
30907
30942
|
this._ondata = (chunk) => {
|
|
30908
|
-
|
|
30909
|
-
|
|
30943
|
+
try {
|
|
30944
|
+
this._readBuffer.append(chunk);
|
|
30945
|
+
this.processReadBuffer();
|
|
30946
|
+
} catch (error51) {
|
|
30947
|
+
this.onerror?.(error51);
|
|
30948
|
+
this.close().catch(() => {
|
|
30949
|
+
});
|
|
30950
|
+
}
|
|
30910
30951
|
};
|
|
30911
30952
|
this._onerror = (error51) => {
|
|
30912
30953
|
this.onerror?.(error51);
|
|
30913
30954
|
};
|
|
30955
|
+
this._readBuffer = new ReadBuffer({ maxBufferSize: options?.maxBufferSize });
|
|
30914
30956
|
}
|
|
30915
30957
|
/**
|
|
30916
30958
|
* Starts listening for messages on stdin.
|
|
@@ -30947,12 +30989,12 @@ var StdioServerTransport = class {
|
|
|
30947
30989
|
this.onclose?.();
|
|
30948
30990
|
}
|
|
30949
30991
|
send(message) {
|
|
30950
|
-
return new Promise((
|
|
30992
|
+
return new Promise((resolve2) => {
|
|
30951
30993
|
const json2 = serializeMessage(message);
|
|
30952
30994
|
if (this._stdout.write(json2)) {
|
|
30953
|
-
|
|
30995
|
+
resolve2();
|
|
30954
30996
|
} else {
|
|
30955
|
-
this._stdout.once("drain",
|
|
30997
|
+
this._stdout.once("drain", resolve2);
|
|
30956
30998
|
}
|
|
30957
30999
|
});
|
|
30958
31000
|
}
|
|
@@ -31048,8 +31090,23 @@ function textResult(data) {
|
|
|
31048
31090
|
content: [{ type: "text", text: JSON.stringify(data, null, 2) }]
|
|
31049
31091
|
};
|
|
31050
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
|
+
}
|
|
31051
31106
|
|
|
31052
31107
|
// node_modules/@chrischall/mcp-utils/dist/config/index.js
|
|
31108
|
+
import { homedir } from "node:os";
|
|
31109
|
+
import { isAbsolute, join, resolve } from "node:path";
|
|
31053
31110
|
var PLACEHOLDER_RE = /^\$\{[^}]*\}$/;
|
|
31054
31111
|
function readEnvVar(key, opts = {}) {
|
|
31055
31112
|
const env = opts.env ?? process.env;
|
|
@@ -31062,6 +31119,15 @@ function readEnvVar(key, opts = {}) {
|
|
|
31062
31119
|
}
|
|
31063
31120
|
return opts.default;
|
|
31064
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
|
+
}
|
|
31065
31131
|
async function loadDotenvSafely(opts = {}) {
|
|
31066
31132
|
try {
|
|
31067
31133
|
const mod = await import(
|
|
@@ -31079,6 +31145,64 @@ async function loadDotenvSafely(opts = {}) {
|
|
|
31079
31145
|
}
|
|
31080
31146
|
}
|
|
31081
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
|
+
|
|
31082
31206
|
// node_modules/@chrischall/mcp-utils/dist/http/net-atoms.js
|
|
31083
31207
|
function parseRetryAfterMs(header, opts = {}) {
|
|
31084
31208
|
const defaultMs = opts.defaultMs ?? 2e3;
|
|
@@ -31286,28 +31410,53 @@ var pageSchema = {
|
|
|
31286
31410
|
};
|
|
31287
31411
|
|
|
31288
31412
|
// src/client.ts
|
|
31289
|
-
import { dirname, join } from "path";
|
|
31413
|
+
import { dirname, join as join3 } from "path";
|
|
31290
31414
|
import { fileURLToPath } from "url";
|
|
31291
31415
|
try {
|
|
31292
31416
|
const dir = dirname(fileURLToPath(import.meta.url));
|
|
31293
|
-
await loadDotenvSafely({ path:
|
|
31417
|
+
await loadDotenvSafely({ path: join3(dir, "..", ".env"), override: false });
|
|
31294
31418
|
} catch {
|
|
31295
31419
|
}
|
|
31296
31420
|
var BASE_URL = "https://secure.splitwise.com/api/v3.0";
|
|
31297
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
|
+
}
|
|
31298
31445
|
var SplitwiseClient = class {
|
|
31299
31446
|
apiKey;
|
|
31300
31447
|
configError;
|
|
31301
31448
|
api;
|
|
31449
|
+
/** Per-origin clients for asset hosts, built on first use by `fetchAsset`. */
|
|
31450
|
+
assetClients = /* @__PURE__ */ new Map();
|
|
31302
31451
|
/**
|
|
31303
31452
|
* Defer the config error so the server can still start (and respond to the
|
|
31304
31453
|
* host's install-time smoke test) when SPLITWISE_API_KEY isn't set yet.
|
|
31305
31454
|
* Tool calls re-raise the error at request time.
|
|
31306
31455
|
*
|
|
31307
|
-
* Optional constructor seam: a hosted per-user deployment
|
|
31308
|
-
*
|
|
31309
|
-
*
|
|
31310
|
-
*
|
|
31456
|
+
* Optional constructor seam: a hosted per-user deployment builds one client
|
|
31457
|
+
* per request with that user's `apiKey` injected. The stdio path passes no
|
|
31458
|
+
* options, so the key resolves from the environment exactly as before —
|
|
31459
|
+
* byte-for-byte identical behaviour.
|
|
31311
31460
|
*/
|
|
31312
31461
|
constructor(opts) {
|
|
31313
31462
|
const key = opts?.apiKey ?? readEnvVar("SPLITWISE_API_KEY");
|
|
@@ -31318,14 +31467,26 @@ var SplitwiseClient = class {
|
|
|
31318
31467
|
this.apiKey = key;
|
|
31319
31468
|
this.configError = null;
|
|
31320
31469
|
}
|
|
31321
|
-
this.api =
|
|
31322
|
-
|
|
31323
|
-
|
|
31324
|
-
|
|
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,
|
|
31325
31483
|
retry: { count: 1, delayMs: 2e3 },
|
|
31326
31484
|
timeout: 3e4,
|
|
31327
|
-
|
|
31328
|
-
|
|
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}`)
|
|
31329
31490
|
});
|
|
31330
31491
|
}
|
|
31331
31492
|
requireKey() {
|
|
@@ -31335,6 +31496,31 @@ var SplitwiseClient = class {
|
|
|
31335
31496
|
async request(method, path, body) {
|
|
31336
31497
|
return this.api.fetchJson(method, path, body !== void 0 ? { body } : {});
|
|
31337
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
|
+
}
|
|
31338
31524
|
};
|
|
31339
31525
|
var client = new SplitwiseClient();
|
|
31340
31526
|
|
|
@@ -31383,13 +31569,7 @@ function registerUserTools(server, client2) {
|
|
|
31383
31569
|
confirm: schemaConfirm
|
|
31384
31570
|
}
|
|
31385
31571
|
}, async ({ id, first_name, last_name, email: email3, password, locale, default_currency, confirm }) => {
|
|
31386
|
-
const body = {};
|
|
31387
|
-
if (first_name !== void 0) body.first_name = first_name;
|
|
31388
|
-
if (last_name !== void 0) body.last_name = last_name;
|
|
31389
|
-
if (email3 !== void 0) body.email = email3;
|
|
31390
|
-
if (password !== void 0) body.password = password;
|
|
31391
|
-
if (locale !== void 0) body.locale = locale;
|
|
31392
|
-
if (default_currency !== void 0) body.default_currency = default_currency;
|
|
31572
|
+
const body = pruneUndefined({ first_name, last_name, email: email3, password, locale, default_currency });
|
|
31393
31573
|
const previewBody = { ...body, ...password !== void 0 ? { password: "[hidden]" } : {} };
|
|
31394
31574
|
const gate = previewUnlessConfirmed(confirm, `Update current Splitwise user ${id} profile`, "POST", `/update_user/${id}`, previewBody);
|
|
31395
31575
|
if (gate) return gate;
|
|
@@ -31427,9 +31607,7 @@ function registerGroupTools(server, client2) {
|
|
|
31427
31607
|
confirm: schemaConfirm
|
|
31428
31608
|
}
|
|
31429
31609
|
}, async ({ name, group_type, simplify_by_default, confirm }) => {
|
|
31430
|
-
const body = { name };
|
|
31431
|
-
if (group_type !== void 0) body.group_type = group_type;
|
|
31432
|
-
if (simplify_by_default !== void 0) body.simplify_by_default = simplify_by_default;
|
|
31610
|
+
const body = pruneUndefined({ name, group_type, simplify_by_default });
|
|
31433
31611
|
const gate = previewUnlessConfirmed(confirm, `Create Splitwise group "${name}"`, "POST", "/create_group", body);
|
|
31434
31612
|
if (gate) return gate;
|
|
31435
31613
|
const data = await client2.request("POST", "/create_group", body);
|
|
@@ -31517,9 +31695,7 @@ function registerFriendTools(server, client2) {
|
|
|
31517
31695
|
confirm: schemaConfirm
|
|
31518
31696
|
}
|
|
31519
31697
|
}, async ({ user_email, user_first_name, user_last_name, confirm }) => {
|
|
31520
|
-
const body = { user_email };
|
|
31521
|
-
if (user_first_name !== void 0) body.user_first_name = user_first_name;
|
|
31522
|
-
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 });
|
|
31523
31699
|
const gate = previewUnlessConfirmed(confirm, `Add ${user_email} as a Splitwise friend`, "POST", "/create_friend", body);
|
|
31524
31700
|
if (gate) return gate;
|
|
31525
31701
|
const data = await client2.request("POST", "/create_friend", body);
|
|
@@ -31673,6 +31849,89 @@ function registerExpenseTools(server, client2) {
|
|
|
31673
31849
|
});
|
|
31674
31850
|
}
|
|
31675
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
|
+
|
|
31676
31935
|
// src/tools/utilities.ts
|
|
31677
31936
|
function registerUtilityTools(server, client2) {
|
|
31678
31937
|
server.registerTool("sw_get_notifications", {
|
|
@@ -31738,7 +31997,7 @@ function registerUtilityTools(server, client2) {
|
|
|
31738
31997
|
// src/index.ts
|
|
31739
31998
|
await runMcp({
|
|
31740
31999
|
name: "splitwise-mcp",
|
|
31741
|
-
version: "2.
|
|
32000
|
+
version: "2.2.0",
|
|
31742
32001
|
// x-release-please-version
|
|
31743
32002
|
deps: client,
|
|
31744
32003
|
banner: "[splitwise-mcp] This project was developed and is maintained by AI (Claude Sonnet 4.6). Use at your own discretion.",
|
|
@@ -31747,6 +32006,7 @@ await runMcp({
|
|
|
31747
32006
|
registerGroupTools,
|
|
31748
32007
|
registerFriendTools,
|
|
31749
32008
|
registerExpenseTools,
|
|
32009
|
+
registerReceiptTools,
|
|
31750
32010
|
registerUtilityTools
|
|
31751
32011
|
]
|
|
31752
32012
|
});
|
package/dist/client.js
CHANGED
|
@@ -1,34 +1,79 @@
|
|
|
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.
|
|
7
|
-
// The try/catch guards
|
|
8
|
-
//
|
|
9
|
-
//
|
|
7
|
+
// The try/catch guards a runtime where `import.meta.url` is undefined and
|
|
8
|
+
// `fileURLToPath(undefined)` would throw at module init — a sandboxed one has
|
|
9
|
+
// no filesystem or .env to load anyway.
|
|
10
10
|
try {
|
|
11
11
|
const dir = dirname(fileURLToPath(import.meta.url));
|
|
12
12
|
await loadDotenvSafely({ path: join(dir, '..', '.env'), override: false });
|
|
13
13
|
}
|
|
14
14
|
catch {
|
|
15
|
-
/* non-Node runtime
|
|
15
|
+
/* non-Node runtime: no .env to load */
|
|
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.
|
|
26
71
|
* Tool calls re-raise the error at request time.
|
|
27
72
|
*
|
|
28
|
-
* Optional constructor seam: a hosted per-user deployment
|
|
29
|
-
*
|
|
30
|
-
*
|
|
31
|
-
*
|
|
73
|
+
* Optional constructor seam: a hosted per-user deployment builds one client
|
|
74
|
+
* per request with that user's `apiKey` injected. The stdio path passes no
|
|
75
|
+
* options, so the key resolves from the environment exactly as before —
|
|
76
|
+
* byte-for-byte identical behaviour.
|
|
32
77
|
*/
|
|
33
78
|
constructor(opts) {
|
|
34
79
|
// Injected apiKey (hosted per-user path) takes precedence; otherwise fall
|
|
@@ -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
|
|
@@ -13,11 +14,11 @@ import { registerUtilityTools } from './tools/utilities.js';
|
|
|
13
14
|
// first request. That preserves the deferred-config-error pattern: the server
|
|
14
15
|
// boots and answers the host's install-time tools/list smoke test even when
|
|
15
16
|
// SPLITWISE_API_KEY is absent — the configuration error only surfaces on the
|
|
16
|
-
// first tool call. A hosted per-user
|
|
17
|
-
//
|
|
17
|
+
// first tool call. A hosted per-user deployment injects its own per-request
|
|
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>",
|
|
@@ -40,28 +40,19 @@
|
|
|
40
40
|
"dev": "node dist/index.js",
|
|
41
41
|
"test": "vitest run",
|
|
42
42
|
"test:watch": "vitest",
|
|
43
|
-
"test:coverage": "vitest run --coverage"
|
|
44
|
-
"worker:dev": "wrangler dev",
|
|
45
|
-
"worker:deploy": "wrangler deploy",
|
|
46
|
-
"worker:test": "vitest run --config vitest.workers.config.ts"
|
|
43
|
+
"test:coverage": "vitest run --coverage"
|
|
47
44
|
},
|
|
48
45
|
"dependencies": {
|
|
49
|
-
"@chrischall/mcp-utils": "^0.
|
|
46
|
+
"@chrischall/mcp-utils": "^0.14.0",
|
|
50
47
|
"@modelcontextprotocol/sdk": "^1.29.0",
|
|
51
48
|
"dotenv": "^17.4.0",
|
|
52
49
|
"zod": "^4.4.2"
|
|
53
50
|
},
|
|
54
51
|
"devDependencies": {
|
|
55
|
-
"@chrischall/mcp-connector": "^1.1.1",
|
|
56
|
-
"@cloudflare/vitest-pool-workers": "^0.18.4",
|
|
57
|
-
"@cloudflare/workers-oauth-provider": "^0.8.1",
|
|
58
|
-
"@cloudflare/workers-types": "^5.20260708.1",
|
|
59
52
|
"@types/node": "^26.0.0",
|
|
60
53
|
"@vitest/coverage-v8": "^4.1.2",
|
|
61
|
-
"agents": "^0.19.0",
|
|
62
54
|
"esbuild": "^0.28.0",
|
|
63
55
|
"typescript": "^7.0.2",
|
|
64
|
-
"vitest": "^4.1.2"
|
|
65
|
-
"wrangler": "^4.110.0"
|
|
56
|
+
"vitest": "^4.1.2"
|
|
66
57
|
}
|
|
67
58
|
}
|
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
|
package/dist/splitwise-auth.js
DELETED
|
@@ -1,23 +0,0 @@
|
|
|
1
|
-
import { SplitwiseClient } from './client.js';
|
|
2
|
-
/**
|
|
3
|
-
* `ConnectorAuth` for the Splitwise remote connector: the login page collects
|
|
4
|
-
* the user's own Splitwise API key (from https://secure.splitwise.com/apps/register),
|
|
5
|
-
* verifies it by constructing a `SplitwiseClient` with the key and calling the
|
|
6
|
-
* current-user endpoint (a bad key throws, which the connector surfaces back on
|
|
7
|
-
* the login page), and stores `{ apiKey }` as the OAuth props that `worker.ts`'s
|
|
8
|
-
* `buildClient` turns into a per-user client.
|
|
9
|
-
*/
|
|
10
|
-
export const splitwiseAuth = {
|
|
11
|
-
service: 'Splitwise',
|
|
12
|
-
accent: '#1CC29F',
|
|
13
|
-
privacyNote: 'Your Splitwise API key is stored encrypted and used only to call Splitwise on your behalf.',
|
|
14
|
-
fields: [{ name: 'apiKey', label: 'Splitwise API key', type: 'password' }],
|
|
15
|
-
async login(fields) {
|
|
16
|
-
// Verify the key up front — an invalid key makes /get_current_user throw
|
|
17
|
-
// here, which the connector surfaces back on the login page. We discard the
|
|
18
|
-
// response: the per-user client is built fresh from the stored key.
|
|
19
|
-
const client = new SplitwiseClient({ apiKey: fields.apiKey });
|
|
20
|
-
await client.request('GET', '/get_current_user');
|
|
21
|
-
return { apiKey: fields.apiKey };
|
|
22
|
-
},
|
|
23
|
-
};
|