splitwise-mcp 2.2.0 → 2.2.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.claude-plugin/marketplace.json +2 -2
- package/.claude-plugin/plugin.json +1 -1
- package/README.md +5 -3
- package/dist/bundle.js +41638 -1616
- package/dist/index.js +1 -1
- package/dist/tools/receipts.js +161 -25
- package/mint.yaml +18 -0
- package/package.json +8 -5
- package/server.json +2 -2
- package/skills/splitwise-mcp/SKILL.md +6 -2
package/dist/index.js
CHANGED
|
@@ -18,7 +18,7 @@ import { registerUtilityTools } from './tools/utilities.js';
|
|
|
18
18
|
// client into the same registrars instead of this singleton.
|
|
19
19
|
await runMcp({
|
|
20
20
|
name: 'splitwise-mcp',
|
|
21
|
-
version: '2.2.
|
|
21
|
+
version: '2.2.2', // x-release-please-version
|
|
22
22
|
deps: client,
|
|
23
23
|
banner: '[splitwise-mcp] This project was developed and is maintained by AI (Claude Sonnet 4.6). Use at your own discretion.',
|
|
24
24
|
tools: [
|
package/dist/tools/receipts.js
CHANGED
|
@@ -1,7 +1,10 @@
|
|
|
1
1
|
import { z } from 'zod';
|
|
2
2
|
import { textResult, imageResult, resolveOutputDir, writeBinaryOutput, sniffMimeBytes, } from '@chrischall/mcp-utils';
|
|
3
|
-
/** Cap on bytes returned as base64 in the tool result
|
|
3
|
+
/** Cap on bytes returned as base64 in the tool result. */
|
|
4
4
|
const MAX_INLINE_BYTES = 4 * 1024 * 1024;
|
|
5
|
+
/** Cap on extracted characters, so a long PDF can't flood the result. */
|
|
6
|
+
const MAX_TEXT_CHARS = 100_000;
|
|
7
|
+
const PDF_MIME = 'application/pdf';
|
|
5
8
|
/** Content types that carry no information about the actual format. */
|
|
6
9
|
const GENERIC_TYPES = new Set(['application/octet-stream', 'binary/octet-stream']);
|
|
7
10
|
/** `%PDF-` magic bytes — `sniffMimeBytes` only knows the image formats. */
|
|
@@ -9,14 +12,25 @@ function isPdf(bytes) {
|
|
|
9
12
|
return bytes.length >= 5 && Buffer.from(bytes.subarray(0, 5)).toString('latin1') === '%PDF-';
|
|
10
13
|
}
|
|
11
14
|
/**
|
|
12
|
-
* Best-effort MIME type for the downloaded bytes
|
|
13
|
-
*
|
|
15
|
+
* Best-effort MIME type for the downloaded bytes.
|
|
16
|
+
*
|
|
17
|
+
* The magic bytes win over the declared Content-Type: the header is a hint an
|
|
18
|
+
* object store often gets wrong (`application/force-download`, `application/
|
|
19
|
+
* x-pdf`, `application/octet-stream`), while the signature describes what the
|
|
20
|
+
* bytes actually are. Sniffing only recognises the handful of formats a
|
|
21
|
+
* receipt arrives in, so anything it can't place falls back to what the server
|
|
22
|
+
* claimed — and a claim of nothing-in-particular falls back to undefined.
|
|
23
|
+
*
|
|
24
|
+
* Everything downstream reads this one value, so getting it right here is what
|
|
25
|
+
* keeps the file extension, the inline block's type, and the text-extraction
|
|
26
|
+
* gate agreeing with each other.
|
|
14
27
|
*/
|
|
15
28
|
function resolveMimeType(contentType, bytes) {
|
|
29
|
+
const sniffed = sniffMimeBytes(bytes) ?? (isPdf(bytes) ? PDF_MIME : undefined);
|
|
30
|
+
if (sniffed)
|
|
31
|
+
return sniffed;
|
|
16
32
|
const declared = (contentType ?? '').split(';')[0].trim().toLowerCase();
|
|
17
|
-
|
|
18
|
-
return declared;
|
|
19
|
-
return sniffMimeBytes(bytes) ?? (isPdf(bytes) ? 'application/pdf' : undefined);
|
|
33
|
+
return declared && !GENERIC_TYPES.has(declared) ? declared : undefined;
|
|
20
34
|
}
|
|
21
35
|
/** Extension from a URL path (`…/receipt.jpg?sig=…` → `jpg`), when it has one. */
|
|
22
36
|
function extensionFromUrl(url) {
|
|
@@ -36,9 +50,32 @@ function extensionFor(mimeType, url) {
|
|
|
36
50
|
return subtype;
|
|
37
51
|
return extensionFromUrl(url) ?? 'bin';
|
|
38
52
|
}
|
|
53
|
+
/**
|
|
54
|
+
* Extract a PDF's text layer. `unpdf` is imported lazily so the cost lands only
|
|
55
|
+
* on calls that ask for text — it is a sizable pdf.js build, and every other
|
|
56
|
+
* receipt path (download, inline, write) must not pay for it at startup.
|
|
57
|
+
*
|
|
58
|
+
* Returns `''` for a PDF with no text layer, which is the normal case for a
|
|
59
|
+
* photographed or scanned receipt.
|
|
60
|
+
*/
|
|
61
|
+
async function extractPdfText(bytes) {
|
|
62
|
+
const { extractText, getDocumentProxy } = await import('unpdf');
|
|
63
|
+
// Copy: pdf.js transfers/detaches the buffer it is handed, and the caller
|
|
64
|
+
// still needs these bytes for the base64 and the file write.
|
|
65
|
+
const doc = await getDocumentProxy(Uint8Array.from(bytes));
|
|
66
|
+
const { text } = await extractText(doc, { mergePages: true });
|
|
67
|
+
return (Array.isArray(text) ? text.join('\n') : text).trim();
|
|
68
|
+
}
|
|
69
|
+
/** An MCP embedded-resource block — how non-image bytes ride back in a result. */
|
|
70
|
+
function resourceBlock(uri, mimeType, base64) {
|
|
71
|
+
return { type: 'resource', resource: { uri, mimeType, blob: base64 } };
|
|
72
|
+
}
|
|
73
|
+
function describeError(err) {
|
|
74
|
+
return err instanceof Error ? err.message : String(err);
|
|
75
|
+
}
|
|
39
76
|
export function registerReceiptTools(server, client) {
|
|
40
77
|
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.
|
|
78
|
+
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. Set inline:true to get the bytes back in the result (images AND PDFs), or extract_text:true to get a PDF's text without the binary at all — both work when the caller can't see this server's filesystem. It also writes the file and returns the path, unless write:false or the filesystem is read-only.",
|
|
42
79
|
// Not read-only: this writes a file into a caller-supplied directory, and
|
|
43
80
|
// `readOnlyHint` is what a host reads when deciding to skip its approval
|
|
44
81
|
// prompt. Not destructive either — `uniquePath` always picks a filename
|
|
@@ -50,16 +87,24 @@ export function registerReceiptTools(server, client) {
|
|
|
50
87
|
.enum(['original', 'large'])
|
|
51
88
|
.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
89
|
.optional(),
|
|
90
|
+
inline: z
|
|
91
|
+
.boolean()
|
|
92
|
+
.describe(`Return the receipt bytes in the result — an image block for images, an embedded resource for PDFs and everything else. Only for receipts under ${MAX_INLINE_BYTES} bytes.`)
|
|
93
|
+
.optional(),
|
|
94
|
+
extract_text: z
|
|
95
|
+
.boolean()
|
|
96
|
+
.describe("Also return the PDF's text layer as `text`. Ideal for looking up line items or totals without transferring the file. PDFs only; a scanned receipt has no text layer to extract.")
|
|
97
|
+
.optional(),
|
|
53
98
|
output_dir: z
|
|
54
99
|
.string()
|
|
55
100
|
.describe('Directory to write the receipt into. Defaults to $SPLITWISE_OUTPUT_DIR, else the current working directory.')
|
|
56
101
|
.optional(),
|
|
57
|
-
|
|
102
|
+
write: z
|
|
58
103
|
.boolean()
|
|
59
|
-
.describe(
|
|
104
|
+
.describe('Write the receipt to disk. Defaults to true. Pass false when the caller cannot reach this server\'s filesystem, or when the server runs read-only.')
|
|
60
105
|
.optional(),
|
|
61
106
|
},
|
|
62
|
-
}, async ({ id, size, output_dir,
|
|
107
|
+
}, async ({ id, size, inline, extract_text, output_dir, write }) => {
|
|
63
108
|
const wanted = size ?? 'original';
|
|
64
109
|
const fallback = wanted === 'original' ? 'large' : 'original';
|
|
65
110
|
const expense = await client.request('GET', `/get_expense/${id}`);
|
|
@@ -75,36 +120,127 @@ export function registerReceiptTools(server, client) {
|
|
|
75
120
|
}
|
|
76
121
|
const mimeType = resolveMimeType(asset.contentType, asset.bytes);
|
|
77
122
|
const base64 = Buffer.from(asset.bytes).toString('base64');
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
123
|
+
// The write is best-effort: a hosted server often runs on a read-only
|
|
124
|
+
// filesystem, and failing the whole call there would strand a caller who
|
|
125
|
+
// only wanted the content. Report it and carry on — unless nothing else
|
|
126
|
+
// was requested, in which case there is no result to return (below).
|
|
127
|
+
let path;
|
|
128
|
+
let writeError;
|
|
129
|
+
if (write !== false) {
|
|
130
|
+
try {
|
|
131
|
+
path = writeBinaryOutput({
|
|
132
|
+
dir: resolveOutputDir(output_dir, 'SPLITWISE_OUTPUT_DIR'),
|
|
133
|
+
baseName: `splitwise-receipt-${id}`,
|
|
134
|
+
base64,
|
|
135
|
+
extension: extensionFor(mimeType, url),
|
|
136
|
+
});
|
|
137
|
+
}
|
|
138
|
+
catch (err) {
|
|
139
|
+
writeError = describeError(err);
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
const oversized = asset.bytes.length > MAX_INLINE_BYTES;
|
|
143
|
+
const inlined = inline === true && !oversized;
|
|
144
|
+
// Diagnosis and suggestion are kept apart: the diagnosis is always true,
|
|
145
|
+
// while the suggestion is only worth making if that route is still open.
|
|
146
|
+
// Folding them into one string is how a caller ends up being told to use a
|
|
147
|
+
// flag they already passed.
|
|
148
|
+
let text;
|
|
149
|
+
let textDiagnosis;
|
|
150
|
+
let textSuggestion;
|
|
151
|
+
if (extract_text === true) {
|
|
152
|
+
if (mimeType !== PDF_MIME) {
|
|
153
|
+
textDiagnosis = `Text extraction covers PDFs only; this receipt is ${mimeType ?? 'of an unknown type'}.`;
|
|
154
|
+
textSuggestion = 'Use inline:true to get the bytes.';
|
|
155
|
+
}
|
|
156
|
+
else {
|
|
157
|
+
try {
|
|
158
|
+
const extracted = await extractPdfText(asset.bytes);
|
|
159
|
+
if (extracted.length === 0) {
|
|
160
|
+
textDiagnosis = 'This PDF has no text layer — it is probably a scan or photo.';
|
|
161
|
+
textSuggestion = 'Use inline:true to read the receipt itself.';
|
|
162
|
+
}
|
|
163
|
+
else if (extracted.length > MAX_TEXT_CHARS) {
|
|
164
|
+
text = extracted.slice(0, MAX_TEXT_CHARS);
|
|
165
|
+
textDiagnosis = `Text truncated to the first ${MAX_TEXT_CHARS} characters.`;
|
|
166
|
+
}
|
|
167
|
+
else {
|
|
168
|
+
text = extracted;
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
catch (err) {
|
|
172
|
+
textDiagnosis = `Could not extract text: ${describeError(err)}.`;
|
|
173
|
+
textSuggestion = 'Use inline:true to get the bytes.';
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
// Gate the suggestion on the FLAG, not the outcome — the same test the
|
|
178
|
+
// throw path's `routes` uses below. `inlined` conflates "never asked for
|
|
179
|
+
// inline" with "asked, and it was refused for size"; suggesting it in the
|
|
180
|
+
// second case contradicts the `inline_skipped` note in the same result.
|
|
181
|
+
const textNote = [textDiagnosis, inline === true ? undefined : textSuggestion].filter(Boolean).join(' ') ||
|
|
182
|
+
undefined;
|
|
183
|
+
// Nothing reached the caller: no file, no bytes, no text. Fail loudly and
|
|
184
|
+
// name the way out rather than reporting a success with an empty result.
|
|
185
|
+
if (path === undefined && !inlined && text === undefined) {
|
|
186
|
+
const reason = writeError
|
|
187
|
+
? `could not be written (${writeError})`
|
|
188
|
+
: 'was not written (write:false)';
|
|
189
|
+
// Name every blocker — more than one can apply at once — then offer only
|
|
190
|
+
// the routes still open. A flag the caller already passed is not a
|
|
191
|
+
// suggestion; when `inline` was requested it can only have been rejected
|
|
192
|
+
// for size, so it never belongs in the list.
|
|
193
|
+
const blockers = [
|
|
194
|
+
textDiagnosis,
|
|
195
|
+
inline === true && oversized
|
|
196
|
+
? `The receipt is ${asset.bytes.length} bytes, over the ${MAX_INLINE_BYTES}-byte inline limit.`
|
|
197
|
+
: undefined,
|
|
198
|
+
].filter((b) => b !== undefined);
|
|
199
|
+
const routes = [];
|
|
200
|
+
if (inline !== true)
|
|
201
|
+
routes.push('inline:true for the bytes');
|
|
202
|
+
if (extract_text !== true && mimeType === PDF_MIME)
|
|
203
|
+
routes.push("extract_text:true for the PDF's text");
|
|
204
|
+
const advice = routes.length > 0
|
|
205
|
+
? `Re-run with ${routes.join(' or ')}.`
|
|
206
|
+
: 'Point output_dir somewhere this server can write.';
|
|
207
|
+
throw new Error([
|
|
208
|
+
`The receipt for expense ${id} ${reason}, and nothing was returned in its place.`,
|
|
209
|
+
...blockers,
|
|
210
|
+
advice,
|
|
211
|
+
].join(' '));
|
|
212
|
+
}
|
|
86
213
|
const result = textResult({
|
|
87
214
|
expense_id: id,
|
|
88
215
|
size: served,
|
|
89
216
|
...(served !== wanted
|
|
90
217
|
? { requested_size: wanted, note: `Expense ${id} has no ${wanted} rendition; fetched ${served}.` }
|
|
91
218
|
: {}),
|
|
92
|
-
path,
|
|
219
|
+
...(path !== undefined ? { path } : {}),
|
|
220
|
+
...(writeError !== undefined ? { write_error: writeError } : {}),
|
|
93
221
|
bytes: asset.bytes.length,
|
|
94
222
|
content_type: mimeType ?? 'unknown',
|
|
95
223
|
// The receipt URL itself is a signed capability, so report only its host.
|
|
96
224
|
source_host: new URL(url).host,
|
|
97
225
|
inline: inlined,
|
|
98
|
-
...(inline === true &&
|
|
226
|
+
...(inline === true && oversized
|
|
99
227
|
? {
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
228
|
+
// The write outcome is already known, so don't advise `write:true`
|
|
229
|
+
// to someone who either has the file or just watched the write fail.
|
|
230
|
+
inline_skipped: `Receipt is ${asset.bytes.length} bytes, over the ${MAX_INLINE_BYTES}-byte inline limit — ` +
|
|
231
|
+
(path !== undefined
|
|
232
|
+
? 'read it from the path above.'
|
|
233
|
+
: 'get it on disk instead, with write:true and an output_dir this server can write to.'),
|
|
103
234
|
}
|
|
104
235
|
: {}),
|
|
236
|
+
...(text !== undefined ? { text } : {}),
|
|
237
|
+
...(textNote !== undefined ? { text_note: textNote } : {}),
|
|
105
238
|
});
|
|
106
|
-
if (inlined
|
|
107
|
-
|
|
239
|
+
if (inlined) {
|
|
240
|
+
const type = mimeType ?? 'application/octet-stream';
|
|
241
|
+
result.content.push(type.startsWith('image/')
|
|
242
|
+
? imageResult(base64, type).content[0]
|
|
243
|
+
: resourceBlock(`splitwise://expenses/${id}/receipt`, type, base64));
|
|
108
244
|
}
|
|
109
245
|
return result;
|
|
110
246
|
});
|
package/mint.yaml
ADDED
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
version: 1
|
|
2
|
+
name: Splitwise
|
|
3
|
+
slug: splitwise
|
|
4
|
+
summary: >-
|
|
5
|
+
Splitwise expenses, groups and friends. Bearer/API-key auth against the
|
|
6
|
+
documented REST API — no browser bridge, so it hosts on any tier.
|
|
7
|
+
env:
|
|
8
|
+
- name: SPLITWISE_API_KEY
|
|
9
|
+
secret: true
|
|
10
|
+
required: true
|
|
11
|
+
help: >-
|
|
12
|
+
Your Splitwise API key. Register an app at
|
|
13
|
+
https://secure.splitwise.com/apps/register and copy its API key.
|
|
14
|
+
egress:
|
|
15
|
+
allow:
|
|
16
|
+
# The Splitwise REST API (https://secure.splitwise.com/api/v3.0) — the only
|
|
17
|
+
# host this server contacts.
|
|
18
|
+
- secure.splitwise.com
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "splitwise-mcp",
|
|
3
|
-
"version": "2.2.
|
|
3
|
+
"version": "2.2.2",
|
|
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>",
|
|
@@ -32,20 +32,23 @@
|
|
|
32
32
|
".claude-plugin",
|
|
33
33
|
"skills/",
|
|
34
34
|
".mcp.json",
|
|
35
|
-
"server.json"
|
|
35
|
+
"server.json",
|
|
36
|
+
"mint.yaml"
|
|
36
37
|
],
|
|
37
38
|
"scripts": {
|
|
38
39
|
"build": "tsc && npm run bundle",
|
|
39
40
|
"bundle": "esbuild src/index.ts --bundle --platform=node --format=esm --external:dotenv --outfile=dist/bundle.js",
|
|
40
41
|
"dev": "node dist/index.js",
|
|
41
|
-
"test": "vitest run",
|
|
42
|
+
"test": "npm run typecheck && vitest run",
|
|
42
43
|
"test:watch": "vitest",
|
|
43
|
-
"test:coverage": "vitest run --coverage"
|
|
44
|
+
"test:coverage": "npm run typecheck && vitest run --coverage",
|
|
45
|
+
"typecheck": "tsc -p tsconfig.json --noEmit"
|
|
44
46
|
},
|
|
45
47
|
"dependencies": {
|
|
46
|
-
"@chrischall/mcp-utils": "^0.
|
|
48
|
+
"@chrischall/mcp-utils": "^0.15.0",
|
|
47
49
|
"@modelcontextprotocol/sdk": "^1.29.0",
|
|
48
50
|
"dotenv": "^17.4.0",
|
|
51
|
+
"unpdf": "^1.8.1",
|
|
49
52
|
"zod": "^4.4.2"
|
|
50
53
|
},
|
|
51
54
|
"devDependencies": {
|
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.2.
|
|
9
|
+
"version": "2.2.2",
|
|
10
10
|
"packages": [
|
|
11
11
|
{
|
|
12
12
|
"registryType": "npm",
|
|
13
13
|
"identifier": "splitwise-mcp",
|
|
14
|
-
"version": "2.2.
|
|
14
|
+
"version": "2.2.2",
|
|
15
15
|
"transport": {
|
|
16
16
|
"type": "stdio"
|
|
17
17
|
},
|
|
@@ -100,7 +100,7 @@ API key auth — no login flow or token rotation. The key is attached to every r
|
|
|
100
100
|
### Receipts
|
|
101
101
|
| Tool | Description |
|
|
102
102
|
|------|-------------|
|
|
103
|
-
| `sw_get_receipt(id, size?, output_dir?,
|
|
103
|
+
| `sw_get_receipt(id, size?, inline?, extract_text?, output_dir?, write?)` | Download the receipt attached to an expense. `inline: true` returns the bytes in the result (images **and** PDFs); `extract_text: true` returns a PDF's text layer; by default it also writes the file and returns the path |
|
|
104
104
|
|
|
105
105
|
### Utilities
|
|
106
106
|
| Tool | Description |
|
|
@@ -137,7 +137,9 @@ sw_create_expense(group_id, "Hotel", "200.00", users: [
|
|
|
137
137
|
**Get the receipt for an expense:**
|
|
138
138
|
```
|
|
139
139
|
sw_list_expenses(...) → find expense ID
|
|
140
|
-
sw_get_receipt(id) →
|
|
140
|
+
sw_get_receipt(id, extract_text: true) → line items and totals as text
|
|
141
|
+
sw_get_receipt(id, inline: true) → the actual bytes, when you need to see it
|
|
142
|
+
sw_get_receipt(id) → writes e.g. ./splitwise-receipt-4644814211.pdf
|
|
141
143
|
```
|
|
142
144
|
|
|
143
145
|
**Search and edit an expense:**
|
|
@@ -154,4 +156,6 @@ sw_update_expense(expense_id, description: "Corrected description", cost: "95.00
|
|
|
154
156
|
- `sw_delete_expense` is a soft delete — restore with `sw_undelete_expense`
|
|
155
157
|
- 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
158
|
- `sw_get_receipt` writes into `output_dir`, else `$SPLITWISE_OUTPUT_DIR`, else the working directory, and never overwrites an existing file
|
|
159
|
+
- That path is on the **server's** filesystem. If you can't read it — a hosted or containerised server — use `inline: true` (bytes) or `extract_text: true` (PDF text) instead of the path
|
|
160
|
+
- `extract_text` only works on PDFs, and only when the PDF has a text layer; a scanned or photographed receipt returns `text_note` instead, and needs `inline: true` to read
|
|
157
161
|
- API default for `sw_list_expenses` is 20 results when `limit` is omitted
|