splitwise-mcp 2.2.0 → 2.2.1

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/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.0', // x-release-please-version
21
+ version: '2.2.1', // 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: [
@@ -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; the file is written regardless. */
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: the response's Content-Type
13
- * when it says something useful, else the magic bytes, else undefined.
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
- if (declared && !GENERIC_TYPES.has(declared))
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. 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.",
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
- inline: z
102
+ write: z
58
103
  .boolean()
59
- .describe(`Also return the receipt as a base64 image in the result. Image receipts only, and only under ${MAX_INLINE_BYTES} bytes.`)
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, inline }) => {
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
- 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;
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 && !inlined
226
+ ...(inline === true && oversized
99
227
  ? {
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.',
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 && imageMime) {
107
- result.content.push(...imageResult(base64, imageMime).content);
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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "splitwise-mcp",
3
- "version": "2.2.0",
3
+ "version": "2.2.1",
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>",
@@ -46,6 +46,7 @@
46
46
  "@chrischall/mcp-utils": "^0.14.0",
47
47
  "@modelcontextprotocol/sdk": "^1.29.0",
48
48
  "dotenv": "^17.4.0",
49
+ "unpdf": "^1.8.1",
49
50
  "zod": "^4.4.2"
50
51
  },
51
52
  "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.0",
9
+ "version": "2.2.1",
10
10
  "packages": [
11
11
  {
12
12
  "registryType": "npm",
13
13
  "identifier": "splitwise-mcp",
14
- "version": "2.2.0",
14
+ "version": "2.2.1",
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?, 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 |
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) → writes e.g. ./splitwise-receipt-4644814211.pdf
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