splitwise-mcp 3.5.0 → 3.5.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.
@@ -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": "3.5.0"
10
+ "version": "3.5.1"
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": "3.5.0",
18
+ "version": "3.5.1",
19
19
  "author": {
20
20
  "name": "Chris Hall"
21
21
  },
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "splitwise-mcp",
3
3
  "displayName": "Splitwise",
4
- "version": "3.5.0",
4
+ "version": "3.5.1",
5
5
  "description": "MCP server for Splitwise — natural-language expense and group management via the Splitwise API",
6
6
  "author": {
7
7
  "name": "Chris Hall",
package/dist/bundle.js CHANGED
@@ -2604,21 +2604,21 @@ var require_schemas = __commonJS({
2604
2604
  function stripTabAndNewline2(value) {
2605
2605
  return value.replace(asciiTabOrNewline2, "");
2606
2606
  }
2607
- function urlHostnameOk2(url5, hostname3) {
2607
+ function urlHostnameOk2(url4, hostname3) {
2608
2608
  hostname3.lastIndex = 0;
2609
- return hostname3.test(url5.hostname);
2609
+ return hostname3.test(url4.hostname);
2610
2610
  }
2611
- function urlProtocolOk2(url5, protocol) {
2611
+ function urlProtocolOk2(url4, protocol) {
2612
2612
  protocol.lastIndex = 0;
2613
- return protocol.test(url5.protocol.endsWith(":") ? url5.protocol.slice(0, -1) : url5.protocol);
2613
+ return protocol.test(url4.protocol.endsWith(":") ? url4.protocol.slice(0, -1) : url4.protocol);
2614
2614
  }
2615
2615
  exports.$ZodURL = core.$constructor("$ZodURL", (inst, def) => {
2616
2616
  exports.$ZodStringFormat.init(inst, def);
2617
2617
  inst._zod.check = (payload) => {
2618
2618
  try {
2619
2619
  const trimmed = payload.value.trim();
2620
- const url5 = validateURL2(trimmed, def);
2621
- if (url5 === exports.URL_BAD_FORMAT) {
2620
+ const url4 = validateURL2(trimmed, def);
2621
+ if (url4 === exports.URL_BAD_FORMAT) {
2622
2622
  payload.issues.push({
2623
2623
  code: "invalid_format",
2624
2624
  format: "url",
@@ -2629,7 +2629,7 @@ var require_schemas = __commonJS({
2629
2629
  });
2630
2630
  return;
2631
2631
  }
2632
- if (url5 === exports.URL_UNPARSEABLE) {
2632
+ if (url4 === exports.URL_UNPARSEABLE) {
2633
2633
  payload.issues.push({
2634
2634
  code: "invalid_format",
2635
2635
  format: "url",
@@ -2639,11 +2639,11 @@ var require_schemas = __commonJS({
2639
2639
  });
2640
2640
  return;
2641
2641
  }
2642
- if (url5 === true) {
2642
+ if (url4 === true) {
2643
2643
  payload.value = stripTabAndNewline2(trimmed);
2644
2644
  return;
2645
2645
  }
2646
- if (def.hostname && !urlHostnameOk2(url5, def.hostname)) {
2646
+ if (def.hostname && !urlHostnameOk2(url4, def.hostname)) {
2647
2647
  payload.issues.push({
2648
2648
  code: "invalid_format",
2649
2649
  format: "url",
@@ -2654,7 +2654,7 @@ var require_schemas = __commonJS({
2654
2654
  continue: !def.abort
2655
2655
  });
2656
2656
  }
2657
- if (def.protocol && !urlProtocolOk2(url5, def.protocol)) {
2657
+ if (def.protocol && !urlProtocolOk2(url4, def.protocol)) {
2658
2658
  payload.issues.push({
2659
2659
  code: "invalid_format",
2660
2660
  format: "url",
@@ -2665,7 +2665,7 @@ var require_schemas = __commonJS({
2665
2665
  continue: !def.abort
2666
2666
  });
2667
2667
  }
2668
- payload.value = def.normalize ? url5.href : stripTabAndNewline2(trimmed);
2668
+ payload.value = def.normalize ? url4.href : stripTabAndNewline2(trimmed);
2669
2669
  return;
2670
2670
  } catch (_2) {
2671
2671
  payload.issues.push({
@@ -20109,7 +20109,7 @@ var require_schemas2 = __commonJS({
20109
20109
  exports.uuidv4 = uuidv42;
20110
20110
  exports.uuidv6 = uuidv62;
20111
20111
  exports.uuidv7 = uuidv72;
20112
- exports.url = url5;
20112
+ exports.url = url4;
20113
20113
  exports.httpUrl = httpUrl2;
20114
20114
  exports.emoji = emoji3;
20115
20115
  exports.nanoid = nanoid3;
@@ -20590,7 +20590,7 @@ var require_schemas2 = __commonJS({
20590
20590
  core.$ZodURL.init(inst, def);
20591
20591
  exports.ZodStringFormat.init(inst, def);
20592
20592
  });
20593
- function url5(params) {
20593
+ function url4(params) {
20594
20594
  return core._url(exports.ZodURL, params);
20595
20595
  }
20596
20596
  function httpUrl2(params) {
@@ -64155,8 +64155,8 @@ var SafeUrlSchema = z.url().superRefine((val, ctx) => {
64155
64155
  });
64156
64156
  return z.NEVER;
64157
64157
  }
64158
- }).refine((url5) => {
64159
- const u2 = new URL(url5);
64158
+ }).refine((url4) => {
64159
+ const u2 = new URL(url4);
64160
64160
  return u2.protocol !== "javascript:" && u2.protocol !== "data:" && u2.protocol !== "vbscript:";
64161
64161
  }, { message: "URL cannot use javascript:, data:, or vbscript: scheme" });
64162
64162
  var OAuthProtectedResourceMetadataSchema = z.looseObject({
@@ -81129,21 +81129,21 @@ var asciiTabOrNewline = /[\t\n\r]/g;
81129
81129
  function stripTabAndNewline(value) {
81130
81130
  return value.replace(asciiTabOrNewline, "");
81131
81131
  }
81132
- function urlHostnameOk(url5, hostname3) {
81132
+ function urlHostnameOk(url4, hostname3) {
81133
81133
  hostname3.lastIndex = 0;
81134
- return hostname3.test(url5.hostname);
81134
+ return hostname3.test(url4.hostname);
81135
81135
  }
81136
- function urlProtocolOk(url5, protocol) {
81136
+ function urlProtocolOk(url4, protocol) {
81137
81137
  protocol.lastIndex = 0;
81138
- return protocol.test(url5.protocol.endsWith(":") ? url5.protocol.slice(0, -1) : url5.protocol);
81138
+ return protocol.test(url4.protocol.endsWith(":") ? url4.protocol.slice(0, -1) : url4.protocol);
81139
81139
  }
81140
81140
  var $ZodURL = /* @__PURE__ */ $constructor("$ZodURL", (inst, def) => {
81141
81141
  $ZodStringFormat.init(inst, def);
81142
81142
  inst._zod.check = (payload) => {
81143
81143
  try {
81144
81144
  const trimmed = payload.value.trim();
81145
- const url5 = validateURL(trimmed, def);
81146
- if (url5 === URL_BAD_FORMAT) {
81145
+ const url4 = validateURL(trimmed, def);
81146
+ if (url4 === URL_BAD_FORMAT) {
81147
81147
  payload.issues.push({
81148
81148
  code: "invalid_format",
81149
81149
  format: "url",
@@ -81154,7 +81154,7 @@ var $ZodURL = /* @__PURE__ */ $constructor("$ZodURL", (inst, def) => {
81154
81154
  });
81155
81155
  return;
81156
81156
  }
81157
- if (url5 === URL_UNPARSEABLE) {
81157
+ if (url4 === URL_UNPARSEABLE) {
81158
81158
  payload.issues.push({
81159
81159
  code: "invalid_format",
81160
81160
  format: "url",
@@ -81164,11 +81164,11 @@ var $ZodURL = /* @__PURE__ */ $constructor("$ZodURL", (inst, def) => {
81164
81164
  });
81165
81165
  return;
81166
81166
  }
81167
- if (url5 === true) {
81167
+ if (url4 === true) {
81168
81168
  payload.value = stripTabAndNewline(trimmed);
81169
81169
  return;
81170
81170
  }
81171
- if (def.hostname && !urlHostnameOk(url5, def.hostname)) {
81171
+ if (def.hostname && !urlHostnameOk(url4, def.hostname)) {
81172
81172
  payload.issues.push({
81173
81173
  code: "invalid_format",
81174
81174
  format: "url",
@@ -81179,7 +81179,7 @@ var $ZodURL = /* @__PURE__ */ $constructor("$ZodURL", (inst, def) => {
81179
81179
  continue: !def.abort
81180
81180
  });
81181
81181
  }
81182
- if (def.protocol && !urlProtocolOk(url5, def.protocol)) {
81182
+ if (def.protocol && !urlProtocolOk(url4, def.protocol)) {
81183
81183
  payload.issues.push({
81184
81184
  code: "invalid_format",
81185
81185
  format: "url",
@@ -81190,7 +81190,7 @@ var $ZodURL = /* @__PURE__ */ $constructor("$ZodURL", (inst, def) => {
81190
81190
  continue: !def.abort
81191
81191
  });
81192
81192
  }
81193
- payload.value = def.normalize ? url5.href : stripTabAndNewline(trimmed);
81193
+ payload.value = def.normalize ? url4.href : stripTabAndNewline(trimmed);
81194
81194
  return;
81195
81195
  } catch (_2) {
81196
81196
  payload.issues.push({
@@ -100161,19 +100161,19 @@ function createApiClient(opts) {
100161
100161
  `Refusing request to ${service}: path ${JSON.stringify(path.split(/[?#]/, 1)[0])} contains a ${hazard}, which URL normalisation would rewrite. Build paths with apiPath\`...\` so each value is one encoded segment.`
100162
100162
  );
100163
100163
  }
100164
- const url5 = `${base}${joinQuery(path, query)}`;
100164
+ const url4 = `${base}${joinQuery(path, query)}`;
100165
100165
  if (baseOrigin === void 0)
100166
- return url5;
100166
+ return url4;
100167
100167
  let parsed;
100168
100168
  try {
100169
- parsed = new URL(url5);
100169
+ parsed = new URL(url4);
100170
100170
  } catch {
100171
100171
  throw new Error(`Refusing request to ${service}: path ${JSON.stringify(path)} does not form a valid URL.`);
100172
100172
  }
100173
100173
  if (parsed.origin !== baseOrigin || parsed.username !== baseParts?.username || parsed.password !== baseParts?.password) {
100174
100174
  throw new Error(`Refusing request to ${service}: path ${JSON.stringify(path)} resolves outside the base origin ${baseOrigin}. Paths must be relative to baseUrl (start them with "/").`);
100175
100175
  }
100176
- return url5;
100176
+ return url4;
100177
100177
  }
100178
100178
  function resolveLocation(location, from) {
100179
100179
  try {
@@ -100197,7 +100197,7 @@ function createApiClient(opts) {
100197
100197
  async function send(method, path, opt, dispatch) {
100198
100198
  const { body: reqBody, contentType } = encodeBody(opt);
100199
100199
  const query = opt.query ? buildQueryString(opt.query) : "";
100200
- const url5 = resolveUrl(path, query);
100200
+ const url4 = resolveUrl(path, query);
100201
100201
  const bodyInit = reqBody !== void 0 ? { body: reqBody } : {};
100202
100202
  const fetchHop = (hopUrl, hopMethod, withBody, token, signal, credentialed = true) => {
100203
100203
  if (!signal?.aborted)
@@ -100222,8 +100222,8 @@ function createApiClient(opts) {
100222
100222
  };
100223
100223
  const fetchWith = async (token, signal) => {
100224
100224
  if (!manualRedirects)
100225
- return fetchHop(url5, method, true, token, signal);
100226
- let hopUrl = url5;
100225
+ return fetchHop(url4, method, true, token, signal);
100226
+ let hopUrl = url4;
100227
100227
  let hopMethod = method;
100228
100228
  let withBody = true;
100229
100229
  let credentialed = true;
@@ -100502,10 +100502,10 @@ function isSplitwiseHost(hostname3) {
100502
100502
  const host = hostname3.toLowerCase();
100503
100503
  return host === SPLITWISE_DOMAIN || host.endsWith(`.${SPLITWISE_DOMAIN}`);
100504
100504
  }
100505
- function parseAssetUrl(url5) {
100505
+ function parseAssetUrl(url4) {
100506
100506
  let parsed;
100507
100507
  try {
100508
- parsed = new URL(url5);
100508
+ parsed = new URL(url4);
100509
100509
  } catch {
100510
100510
  throw new Error("Splitwise returned an asset URL that could not be parsed");
100511
100511
  }
@@ -100640,8 +100640,8 @@ var SplitwiseClient = class {
100640
100640
  * `maxBytes` overrides the client-wide body cap for this download; over it,
100641
100641
  * the read is refused (`ResponseTooLargeError`, `kind: 'too_large'`).
100642
100642
  */
100643
- async fetchAsset(url5, opts = {}) {
100644
- const target = parseAssetUrl(url5);
100643
+ async fetchAsset(url4, opts = {}) {
100644
+ const target = parseAssetUrl(url4);
100645
100645
  const authenticated = isSplitwiseHost(target.hostname);
100646
100646
  const cacheKey = `${target.origin}|${authenticated ? "auth" : "anon"}`;
100647
100647
  let api = this.assetClients.get(cacheKey);
@@ -101376,40 +101376,134 @@ function swUntrustedEnvelope(payload) {
101376
101376
  // src/tools/_receipt.ts
101377
101377
  import { createHash as createHash4 } from "crypto";
101378
101378
 
101379
- // src/tools/_blob.ts
101380
- import { createHmac as createHmac3 } from "crypto";
101381
- function blobConfig() {
101382
- const baseUrl = readEnvVar("MCP_BLOB_BASE_URL");
101383
- const signingKey = readEnvVar("MCP_BLOB_SIGNING_KEY");
101384
- if (!baseUrl || !signingKey) return void 0;
101379
+ // node_modules/@chrischall/mcp-utils/dist/export/blob.js
101380
+ import { createHmac as createHmac3 } from "node:crypto";
101381
+ var BLOB_DEFAULT_TTL_MS = 60 * 60 * 1e3;
101382
+ var BLOB_MAX_TTL_MS = 24 * 60 * 60 * 1e3;
101383
+ var MAX_KEY_LENGTH = 1024;
101384
+ var SAFE_SEGMENT = /^[A-Za-z0-9._~-]+$/;
101385
+ function configError(reason) {
101386
+ return new McpToolError(`MCP_BLOB_BASE_URL is not usable: ${reason}.`, {
101387
+ hint: "It should look like https://<host>/b/<registrationId> \u2014 check the deployment's environment.",
101388
+ kind: "unknown"
101389
+ });
101390
+ }
101391
+ function parseBase(baseUrl) {
101385
101392
  let parsed;
101386
101393
  try {
101387
101394
  parsed = new URL(baseUrl);
101388
101395
  } catch {
101389
- throw new Error(`MCP_BLOB_BASE_URL is not a valid URL \u2014 it should look like https://<host>/b/<registrationId>`);
101390
- }
101396
+ throw configError("not a valid URL");
101397
+ }
101398
+ if (parsed.protocol !== "https:" && parsed.protocol !== "http:")
101399
+ throw configError("scheme must be http(s)");
101400
+ if (parsed.username || parsed.password)
101401
+ throw configError("must not contain credentials");
101402
+ if (parsed.search || parsed.hash)
101403
+ throw configError("must not contain a query or fragment");
101391
101404
  const path = parsed.pathname.replace(/^\/+|\/+$/g, "");
101392
101405
  const prefix = path.startsWith("b/") ? path.slice(2) : path;
101393
- return { base: baseUrl.replace(/\/+$/, ""), prefix, signingKey };
101394
- }
101395
- var sign2 = (cfg, payload) => createHmac3("sha256", cfg.signingKey).update(payload).digest("base64url");
101396
- function url4(cfg, key, exp, sig) {
101397
- return `${cfg.base}/${key}?exp=${exp}&sig=${encodeURIComponent(sig)}`;
101398
- }
101399
- function signedPutUrl(cfg, key, contentType, exp) {
101400
- return url4(cfg, key, exp, sign2(cfg, `put\0${cfg.prefix}/${key}\0${contentType}\0${exp}`));
101401
- }
101402
- function signedGetUrl(cfg, key, exp) {
101403
- return url4(cfg, key, exp, sign2(cfg, `${cfg.prefix}/${key}
101404
- ${exp}`));
101406
+ if (!prefix || prefix === "b")
101407
+ throw configError("has no registration path");
101408
+ return { base: `${parsed.origin}/${path}`, prefix };
101409
+ }
101410
+ function badInput(message) {
101411
+ return new McpToolError(message, { kind: "unknown" });
101412
+ }
101413
+ function encodeKey(key) {
101414
+ if (typeof key !== "string" || key.length === 0)
101415
+ throw badInput("Blob key must be a non-empty string.");
101416
+ if (key.length > MAX_KEY_LENGTH)
101417
+ throw badInput(`Blob key is longer than ${MAX_KEY_LENGTH} characters.`);
101418
+ const segments = key.split("/");
101419
+ for (const seg of segments) {
101420
+ if (seg === "" || seg === "." || seg === ".." || !SAFE_SEGMENT.test(seg)) {
101421
+ throw badInput('Blob key must be relative, slash-separated segments of [A-Za-z0-9._~-] with no empty, "." or ".." segment.');
101422
+ }
101423
+ }
101424
+ return segments.map(encodeURIComponent).join("/");
101425
+ }
101426
+ function checkContentType(contentType) {
101427
+ if (typeof contentType !== "string" || contentType.length === 0 || /[\x00-\x1f\x7f]/.test(contentType)) {
101428
+ throw badInput("Blob content type must be a non-empty string without control characters.");
101429
+ }
101430
+ }
101431
+ function expiry(opts) {
101432
+ const ttl = opts?.ttlMs ?? BLOB_DEFAULT_TTL_MS;
101433
+ if (!Number.isFinite(ttl) || ttl <= 0 || ttl > BLOB_MAX_TTL_MS) {
101434
+ throw badInput(`Blob link TTL must be between 1 ms and ${BLOB_MAX_TTL_MS} ms (24h); the gateway refuses longer.`);
101435
+ }
101436
+ return Math.floor((opts?.now ?? Date.now()) + ttl);
101437
+ }
101438
+ function toArrayBuffer(view) {
101439
+ return view.slice().buffer;
101440
+ }
101441
+ function createBlobStore(opts) {
101442
+ const { base, prefix } = parseBase(opts.baseUrl);
101443
+ const signingKey = opts.signingKey;
101444
+ const sign2 = (payload) => createHmac3("sha256", signingKey).update(payload).digest("base64url");
101445
+ const link = (encodedKey, exp, sig) => `${base}/${encodedKey}?exp=${exp}&sig=${encodeURIComponent(sig)}`;
101446
+ const signGetUrl = (key, o2) => {
101447
+ const encoded = encodeKey(key);
101448
+ const exp = expiry(o2);
101449
+ return { url: link(encoded, exp, sign2(`${prefix}/${key}
101450
+ ${exp}`)), expiresAtMs: exp };
101451
+ };
101452
+ const signPutUrl = (key, contentType, o2) => {
101453
+ const encoded = encodeKey(key);
101454
+ checkContentType(contentType);
101455
+ const exp = expiry(o2);
101456
+ return { url: link(encoded, exp, sign2(`put\0${prefix}/${key}\0${contentType}\0${exp}`)), expiresAtMs: exp };
101457
+ };
101458
+ const put = async (key, body, contentType, o2 = {}) => {
101459
+ const { url: url4 } = signPutUrl(key, contentType, o2);
101460
+ const bytes = typeof body === "string" ? new TextEncoder().encode(body) : body;
101461
+ const doFetch = o2.fetchImpl ?? fetch;
101462
+ let res;
101463
+ try {
101464
+ res = await doFetch(url4, {
101465
+ method: "PUT",
101466
+ headers: { "content-type": contentType },
101467
+ body: toArrayBuffer(bytes),
101468
+ ...o2.signal ? { signal: o2.signal } : {}
101469
+ });
101470
+ } catch (cause) {
101471
+ if (o2.signal?.aborted)
101472
+ throw cause;
101473
+ throw new McpToolError(`Blob store upload of ${key} did not complete (network error).`, {
101474
+ kind: "transport"
101475
+ });
101476
+ }
101477
+ if (!res.ok) {
101478
+ throw new McpToolError(`Blob store upload of ${key} failed (HTTP ${res.status}).`, {
101479
+ kind: "http",
101480
+ status: res.status
101481
+ });
101482
+ }
101483
+ return { key, bytes: bytes.byteLength };
101484
+ };
101485
+ return { baseUrl: base, prefix, signGetUrl, signPutUrl, put };
101405
101486
  }
101406
- var UPLOAD_ID_PATTERN = /^up\/[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}\/[A-Za-z0-9_-][A-Za-z0-9._-]{0,99}$/;
101407
- function safeKeyName(filename) {
101408
- const base = filename.split(/[\\/]/).pop() ?? "";
101409
- const cleaned = base.replace(/[^A-Za-z0-9._-]+/g, "_").replace(/^[._]+/, "").slice(0, 100);
101410
- return cleaned || "receipt";
101487
+ function blobStoreFromEnv(env = process.env) {
101488
+ const baseUrl = readEnvVar("MCP_BLOB_BASE_URL", { env });
101489
+ const signingKey = readEnvVar("MCP_BLOB_SIGNING_KEY", { env });
101490
+ if (!baseUrl || !signingKey)
101491
+ return void 0;
101492
+ return createBlobStore({ baseUrl, signingKey });
101411
101493
  }
101412
101494
 
101495
+ // node_modules/@chrischall/mcp-utils/dist/export/zip.js
101496
+ var CRC_TABLE = (() => {
101497
+ const table = new Uint32Array(256);
101498
+ for (let n2 = 0; n2 < 256; n2++) {
101499
+ let c2 = n2;
101500
+ for (let k2 = 0; k2 < 8; k2++)
101501
+ c2 = c2 & 1 ? 3988292384 ^ c2 >>> 1 : c2 >>> 1;
101502
+ table[n2] = c2 >>> 0;
101503
+ }
101504
+ return table;
101505
+ })();
101506
+
101413
101507
  // src/tools/_receipt.ts
101414
101508
  var MAX_RECEIPT_BYTES = 10 * 1024 * 1024;
101415
101509
  var LIMIT_LABEL = "10 MiB";
@@ -101485,17 +101579,23 @@ async function readSandboxed(requested) {
101485
101579
  await handle.close();
101486
101580
  }
101487
101581
  }
101582
+ var UPLOAD_ID_PATTERN = /^up\/[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}\/[A-Za-z0-9_-][A-Za-z0-9._-]{0,99}$/;
101583
+ function safeKeyName(filename) {
101584
+ const base = filename.split(/[\\/]/).pop() ?? "";
101585
+ const cleaned = base.replace(/[^A-Za-z0-9._-]+/g, "_").replace(/^[._]+/, "").slice(0, 100);
101586
+ return cleaned || "receipt";
101587
+ }
101488
101588
  var UPLOAD_READ_TTL_MS = 5 * 60 * 1e3;
101489
101589
  async function readUpload(uploadId, client2) {
101490
101590
  if (!UPLOAD_ID_PATTERN.test(uploadId)) {
101491
101591
  throw new Error("receipt.upload_id is not one sw_get_upload_url issued (expected up/<uuid>/<filename>)");
101492
101592
  }
101493
- const cfg = blobConfig();
101494
- if (!cfg || !client2) {
101593
+ const store = blobStoreFromEnv();
101594
+ if (!store || !client2) {
101495
101595
  throw new Error("receipt.upload_id only works on the hosted server; pass receipt.path or receipt.base64 here.");
101496
101596
  }
101497
101597
  try {
101498
- const asset = await client2.fetchAsset(signedGetUrl(cfg, uploadId, Date.now() + UPLOAD_READ_TTL_MS), {
101598
+ const asset = await client2.fetchAsset(store.signGetUrl(uploadId, { ttlMs: UPLOAD_READ_TTL_MS }).url, {
101499
101599
  maxBytes: MAX_RECEIPT_BYTES
101500
101600
  });
101501
101601
  return Uint8Array.from(asset.bytes);
@@ -102319,12 +102419,12 @@ function registerReceiptTools(server, client2) {
102319
102419
  const fallback = wanted === "original" ? "large" : "original";
102320
102420
  const expense = await client2.request("GET", `/get_expense/${id}`);
102321
102421
  const receipt = expense?.expense?.receipt;
102322
- const url5 = receipt?.[wanted] || receipt?.[fallback];
102323
- if (!url5) {
102422
+ const url4 = receipt?.[wanted] || receipt?.[fallback];
102423
+ if (!url4) {
102324
102424
  throw new Error(`Splitwise expense ${id} has no receipt attached.`);
102325
102425
  }
102326
102426
  const served = receipt?.[wanted] ? wanted : fallback;
102327
- const asset = await client2.fetchAsset(url5, { maxBytes: MAX_RECEIPT_BYTES2 });
102427
+ const asset = await client2.fetchAsset(url4, { maxBytes: MAX_RECEIPT_BYTES2 });
102328
102428
  if (asset.bytes.length === 0) {
102329
102429
  throw new Error(`Splitwise returned an empty receipt body for expense ${id}.`);
102330
102430
  }
@@ -102345,7 +102445,7 @@ function registerReceiptTools(server, client2) {
102345
102445
  baseName: `splitwise-receipt-${id}`,
102346
102446
  base64: base643,
102347
102447
  // The MIME type, else the receipt URL's own extension, else `bin`.
102348
- extension: extensionForMime(mimeType, { url: url5 }),
102448
+ extension: extensionForMime(mimeType, { url: url4 }),
102349
102449
  mode: RECEIPT_FILE_MODE
102350
102450
  });
102351
102451
  } catch (err) {
@@ -102411,7 +102511,7 @@ function registerReceiptTools(server, client2) {
102411
102511
  bytes: asset.bytes.length,
102412
102512
  content_type: mimeType ?? "unknown",
102413
102513
  // The receipt URL itself is a signed capability, so report only its host.
102414
- source_host: new URL(url5).host,
102514
+ source_host: new URL(url4).host,
102415
102515
  inline: inlined,
102416
102516
  ...inline === true && oversized ? {
102417
102517
  // The write outcome is already known, so don't advise `write:true`
@@ -102448,16 +102548,15 @@ function registerUploadTools(server, _client) {
102448
102548
  })
102449
102549
  },
102450
102550
  async ({ filename, content_type }) => {
102451
- const cfg = blobConfig();
102452
- if (!cfg) {
102551
+ const store = blobStoreFromEnv();
102552
+ if (!store) {
102453
102553
  throw new Error(
102454
102554
  "sw_get_upload_url only works on the hosted server (no blob store is configured here). Pass the file as receipt.path (a file inside SPLITWISE_INPUT_DIR) or receipt.base64 instead."
102455
102555
  );
102456
102556
  }
102457
102557
  const contentType = canonicalReceiptMime(content_type);
102458
102558
  const key = `up/${randomUUID()}/${safeKeyName(filename)}`;
102459
- const exp = Date.now() + UPLOAD_URL_TTL_MS;
102460
- const uploadUrl = signedPutUrl(cfg, key, contentType, exp);
102559
+ const { url: uploadUrl, expiresAtMs: exp } = store.signPutUrl(key, contentType, { ttlMs: UPLOAD_URL_TTL_MS });
102461
102560
  return minifiedResult({
102462
102561
  upload_id: key,
102463
102562
  upload_url: uploadUrl,
@@ -102586,7 +102685,7 @@ function registerUtilityTools(server, client2) {
102586
102685
  // src/index.ts
102587
102686
  await runMcp({
102588
102687
  name: "splitwise-mcp",
102589
- version: "3.5.0",
102688
+ version: "3.5.1",
102590
102689
  // x-release-please-version
102591
102690
  deps: client,
102592
102691
  banner: "[splitwise-mcp] This project was developed and is maintained by AI (Claude Sonnet 4.6). Use at your own discretion.",
package/dist/index.js CHANGED
@@ -21,7 +21,7 @@ import { registerUtilityTools } from './tools/utilities.js';
21
21
  // client into the same registrars instead of this singleton.
22
22
  await runMcp({
23
23
  name: 'splitwise-mcp',
24
- version: '3.5.0', // x-release-please-version
24
+ version: '3.5.1', // x-release-please-version
25
25
  deps: client,
26
26
  banner: '[splitwise-mcp] This project was developed and is maintained by AI (Claude Sonnet 4.6). Use at your own discretion.',
27
27
  tools: [
@@ -1,6 +1,6 @@
1
1
  import { createHash } from 'crypto';
2
2
  import { ApiError, readEnvVar } from '@chrischall/mcp-utils';
3
- import { blobConfig, signedGetUrl, UPLOAD_ID_PATTERN } from './_blob.js';
3
+ import { blobStoreFromEnv } from '@chrischall/mcp-utils/export';
4
4
  import { z } from 'zod';
5
5
  /**
6
6
  * Splitwise does not document an upload cap, so this is a conservative 10 MiB:
@@ -112,6 +112,18 @@ async function readSandboxed(requested) {
112
112
  await handle.close();
113
113
  }
114
114
  }
115
+ /**
116
+ * The only keys `receipt.upload_id` may name: `up/<uuid>/<safe name>`, exactly
117
+ * what `sw_get_upload_url` mints. Anything else in this registration's space
118
+ * is not the caller's to read through a receipt.
119
+ */
120
+ export const UPLOAD_ID_PATTERN = /^up\/[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}\/[A-Za-z0-9_-][A-Za-z0-9._-]{0,99}$/;
121
+ /** A filename made safe for a blob key segment: no separators, no dot-dot, no surprises. */
122
+ export function safeKeyName(filename) {
123
+ const base = filename.split(/[\\/]/).pop() ?? '';
124
+ const cleaned = base.replace(/[^A-Za-z0-9._-]+/g, '_').replace(/^[._]+/, '').slice(0, 100);
125
+ return cleaned || 'receipt';
126
+ }
115
127
  /** How long the server's own read link for an upload lives — it is used at once. */
116
128
  const UPLOAD_READ_TTL_MS = 5 * 60 * 1000;
117
129
  /**
@@ -122,12 +134,12 @@ async function readUpload(uploadId, client) {
122
134
  if (!UPLOAD_ID_PATTERN.test(uploadId)) {
123
135
  throw new Error('receipt.upload_id is not one sw_get_upload_url issued (expected up/<uuid>/<filename>)');
124
136
  }
125
- const cfg = blobConfig();
126
- if (!cfg || !client) {
137
+ const store = blobStoreFromEnv();
138
+ if (!store || !client) {
127
139
  throw new Error('receipt.upload_id only works on the hosted server; pass receipt.path or receipt.base64 here.');
128
140
  }
129
141
  try {
130
- const asset = await client.fetchAsset(signedGetUrl(cfg, uploadId, Date.now() + UPLOAD_READ_TTL_MS), {
142
+ const asset = await client.fetchAsset(store.signGetUrl(uploadId, { ttlMs: UPLOAD_READ_TTL_MS }).url, {
131
143
  maxBytes: MAX_RECEIPT_BYTES,
132
144
  });
133
145
  return Uint8Array.from(asset.bytes);
@@ -1,8 +1,8 @@
1
1
  import { randomUUID } from 'crypto';
2
2
  import { z } from 'zod';
3
3
  import { minifiedResult } from '@chrischall/mcp-utils';
4
- import { blobConfig, safeKeyName, signedPutUrl } from './_blob.js';
5
- import { canonicalReceiptMime, MAX_RECEIPT_BYTES } from './_receipt.js';
4
+ import { blobStoreFromEnv } from '@chrischall/mcp-utils/export';
5
+ import { canonicalReceiptMime, MAX_RECEIPT_BYTES, safeKeyName } from './_receipt.js';
6
6
  /** Long enough to run a curl in the same turn, short enough that a leaked link is soon dead. */
7
7
  export const UPLOAD_URL_TTL_MS = 10 * 60 * 1000;
8
8
  /**
@@ -24,14 +24,13 @@ export function registerUploadTools(server, _client) {
24
24
  .describe('The file\'s type: image/png, image/jpeg, image/gif or application/pdf. The upload must send exactly this Content-Type.'),
25
25
  }),
26
26
  }, async ({ filename, content_type }) => {
27
- const cfg = blobConfig();
28
- if (!cfg) {
27
+ const store = blobStoreFromEnv();
28
+ if (!store) {
29
29
  throw new Error('sw_get_upload_url only works on the hosted server (no blob store is configured here). Pass the file as receipt.path (a file inside SPLITWISE_INPUT_DIR) or receipt.base64 instead.');
30
30
  }
31
31
  const contentType = canonicalReceiptMime(content_type);
32
32
  const key = `up/${randomUUID()}/${safeKeyName(filename)}`;
33
- const exp = Date.now() + UPLOAD_URL_TTL_MS;
34
- const uploadUrl = signedPutUrl(cfg, key, contentType, exp);
33
+ const { url: uploadUrl, expiresAtMs: exp } = store.signPutUrl(key, contentType, { ttlMs: UPLOAD_URL_TTL_MS });
35
34
  return minifiedResult({
36
35
  upload_id: key,
37
36
  upload_url: uploadUrl,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "splitwise-mcp",
3
- "version": "3.5.0",
3
+ "version": "3.5.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>",
@@ -45,7 +45,7 @@
45
45
  "typecheck": "tsc -p tsconfig.json --noEmit"
46
46
  },
47
47
  "dependencies": {
48
- "@chrischall/mcp-utils": "^3.2.0",
48
+ "@chrischall/mcp-utils": "^3.3.0",
49
49
  "@modelcontextprotocol/server": "^2.3.0",
50
50
  "dotenv": "^18.0.2",
51
51
  "unpdf": "^1.8.1",
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": "3.5.0",
9
+ "version": "3.5.1",
10
10
  "packages": [
11
11
  {
12
12
  "registryType": "npm",
13
13
  "identifier": "splitwise-mcp",
14
- "version": "3.5.0",
14
+ "version": "3.5.1",
15
15
  "transport": {
16
16
  "type": "stdio"
17
17
  },
@@ -1,40 +0,0 @@
1
- import { createHmac } from 'crypto';
2
- import { readEnvVar } from '@chrischall/mcp-utils';
3
- export function blobConfig() {
4
- const baseUrl = readEnvVar('MCP_BLOB_BASE_URL');
5
- const signingKey = readEnvVar('MCP_BLOB_SIGNING_KEY');
6
- if (!baseUrl || !signingKey)
7
- return undefined;
8
- let parsed;
9
- try {
10
- parsed = new URL(baseUrl);
11
- }
12
- catch {
13
- throw new Error(`MCP_BLOB_BASE_URL is not a valid URL — it should look like https://<host>/b/<registrationId>`);
14
- }
15
- const path = parsed.pathname.replace(/^\/+|\/+$/g, '');
16
- const prefix = path.startsWith('b/') ? path.slice(2) : path;
17
- return { base: baseUrl.replace(/\/+$/, ''), prefix, signingKey };
18
- }
19
- const sign = (cfg, payload) => createHmac('sha256', cfg.signingKey).update(payload).digest('base64url');
20
- function url(cfg, key, exp, sig) {
21
- return `${cfg.base}/${key}?exp=${exp}&sig=${encodeURIComponent(sig)}`;
22
- }
23
- export function signedPutUrl(cfg, key, contentType, exp) {
24
- return url(cfg, key, exp, sign(cfg, `put\0${cfg.prefix}/${key}\0${contentType}\0${exp}`));
25
- }
26
- export function signedGetUrl(cfg, key, exp) {
27
- return url(cfg, key, exp, sign(cfg, `${cfg.prefix}/${key}\n${exp}`));
28
- }
29
- /**
30
- * The only keys `receipt.upload_id` may name: `up/<uuid>/<safe name>`, exactly
31
- * what `sw_get_upload_url` mints. Anything else in this registration's space
32
- * is not the caller's to read through a receipt.
33
- */
34
- export const UPLOAD_ID_PATTERN = /^up\/[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}\/[A-Za-z0-9_-][A-Za-z0-9._-]{0,99}$/;
35
- /** A filename made safe for a key segment: no separators, no dot-dot, no surprises. */
36
- export function safeKeyName(filename) {
37
- const base = filename.split(/[\\/]/).pop() ?? '';
38
- const cleaned = base.replace(/[^A-Za-z0-9._-]+/g, '_').replace(/^[._]+/, '').slice(0, 100);
39
- return cleaned || 'receipt';
40
- }