splitwise-mcp 2.0.9 → 2.0.11

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": "2.0.9"
10
+ "version": "2.0.11"
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.0.9",
18
+ "version": "2.0.11",
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": "2.0.9",
4
+ "version": "2.0.11",
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
@@ -6,7 +6,11 @@ var __getOwnPropNames = Object.getOwnPropertyNames;
6
6
  var __getProtoOf = Object.getPrototypeOf;
7
7
  var __hasOwnProp = Object.prototype.hasOwnProperty;
8
8
  var __commonJS = (cb, mod) => function __require() {
9
- return mod || (0, cb[__getOwnPropNames(cb)[0]])((mod = { exports: {} }).exports, mod), mod.exports;
9
+ try {
10
+ return mod || (0, cb[__getOwnPropNames(cb)[0]])((mod = { exports: {} }).exports, mod), mod.exports;
11
+ } catch (e) {
12
+ throw mod = 0, e;
13
+ }
10
14
  };
11
15
  var __export = (target, all) => {
12
16
  for (var name in all)
@@ -31017,8 +31021,12 @@ var API_KEY_RE = new RegExp([
31017
31021
  // webhook signing secret (Stripe-style)
31018
31022
  ].map((p) => `\\b${p}`).join("|"), "g");
31019
31023
  var QUERY_SECRET_RE = /([?&](?:access_token|refresh_token|client_secret|api_?key|signature|token|key|sig)=)[^&#\s"'<>`]+/gi;
31024
+ var AWS_SIGV4_RE = /([?&]X-Amz-(?:Signature|Security-Token|Credential)=)[^&#\s"'<>`]+/gi;
31025
+ var JSON_SECRET_KEYS = "access_token|refresh_token|client_secret|api_?key|password|passwd|secret|token";
31026
+ var JSON_SECRET_DQ_RE = new RegExp(`("(?:${JSON_SECRET_KEYS})"\\s*:\\s*")[^"]*(")`, "gi");
31027
+ var JSON_SECRET_SQ_RE = new RegExp(`('(?:${JSON_SECRET_KEYS})'\\s*:\\s*')[^']*(')`, "gi");
31020
31028
  function redactSecrets(text) {
31021
- return text.replace(BEARER_RE, "$1[REDACTED]").replace(BASIC_AUTH_RE, "$1[REDACTED]").replace(SET_COOKIE_RE, "$1$2=[REDACTED]").replace(COOKIE_HEADER_RE, (_m, prefix, pairs) => `${prefix}${pairs.replace(/=[^;,\s]*/g, "=[REDACTED]")}`).replace(API_KEY_RE, "[REDACTED]").replace(QUERY_SECRET_RE, "$1[REDACTED]").replace(JWT_RE, "[REDACTED]");
31029
+ return text.replace(BEARER_RE, "$1[REDACTED]").replace(BASIC_AUTH_RE, "$1[REDACTED]").replace(SET_COOKIE_RE, "$1$2=[REDACTED]").replace(COOKIE_HEADER_RE, (_m, prefix, pairs) => `${prefix}${pairs.replace(/=[^;,\s]*/g, "=[REDACTED]")}`).replace(API_KEY_RE, "[REDACTED]").replace(QUERY_SECRET_RE, "$1[REDACTED]").replace(AWS_SIGV4_RE, "$1[REDACTED]").replace(JSON_SECRET_DQ_RE, "$1[REDACTED]$2").replace(JSON_SECRET_SQ_RE, "$1[REDACTED]$2").replace(JWT_RE, "[REDACTED]");
31022
31030
  }
31023
31031
  function truncateErrorMessage(text, max = DEFAULT_ERROR_MESSAGE_MAX) {
31024
31032
  const str = text === null || text === void 0 ? "" : String(text);
@@ -31065,6 +31073,18 @@ async function loadDotenvSafely(opts = {}) {
31065
31073
  }
31066
31074
  }
31067
31075
 
31076
+ // node_modules/@chrischall/mcp-utils/dist/http/net-atoms.js
31077
+ function parseRetryAfterMs(header, opts = {}) {
31078
+ const defaultMs = opts.defaultMs ?? 2e3;
31079
+ const capMs = opts.capMs ?? 3e4;
31080
+ if (header == null)
31081
+ return defaultMs;
31082
+ const trimmed = header.trim();
31083
+ if (!/^\d+$/.test(trimmed))
31084
+ return defaultMs;
31085
+ return Math.min(Number(trimmed) * 1e3, capMs);
31086
+ }
31087
+
31068
31088
  // node_modules/@chrischall/mcp-utils/dist/http/index.js
31069
31089
  var DEFAULT_RETRY = { count: 1, delayMs: 2e3 };
31070
31090
  var defaultSleep = (ms) => new Promise((r) => setTimeout(r, ms));
@@ -31156,7 +31176,11 @@ function createApiClient(opts) {
31156
31176
  const res = await once();
31157
31177
  if (retryStatuses.includes(res.status) && attempt < retry.count) {
31158
31178
  attempt += 1;
31159
- await sleep(retry.delayMs);
31179
+ const delay = retry.honorRetryAfter ? parseRetryAfterMs(res.headers.get("retry-after"), {
31180
+ defaultMs: retry.delayMs,
31181
+ capMs: retry.maxRetryAfterMs ?? 3e4
31182
+ }) : retry.delayMs;
31183
+ await sleep(delay);
31160
31184
  continue;
31161
31185
  }
31162
31186
  return res;
@@ -31191,7 +31215,26 @@ function createApiClient(opts) {
31191
31215
  }
31192
31216
  return text;
31193
31217
  }
31194
- return { fetchJson, fetchHtml };
31218
+ async function fetchRaw(method, path, opt = {}) {
31219
+ const headers = { Accept: "*/*", ...opt.headers };
31220
+ const res = await send(method, path, { ...opt, headers });
31221
+ if (res.status === 401)
31222
+ throw unauthorized();
31223
+ if (res.status === 429)
31224
+ throw rateLimited();
31225
+ if (!res.ok) {
31226
+ const text = await res.text().catch(() => "");
31227
+ throw new ApiError(res.status, formatApiError(res.status, method, path, text, { service }));
31228
+ }
31229
+ const bytes = new Uint8Array(await res.arrayBuffer());
31230
+ return {
31231
+ status: res.status,
31232
+ contentType: res.headers.get("content-type"),
31233
+ headers: res.headers,
31234
+ bytes
31235
+ };
31236
+ }
31237
+ return { fetchJson, fetchHtml, fetchRaw };
31195
31238
  }
31196
31239
  function buildQueryString(params) {
31197
31240
  const parts = [];
@@ -31223,6 +31266,8 @@ var NonNegInt = external_exports.number().int().nonnegative();
31223
31266
  var NonEmptyString = external_exports.string().min(1);
31224
31267
  var IsoDate = external_exports.iso.date();
31225
31268
  var IsoTime = external_exports.string().regex(/^([01]?\d|2[0-3]):[0-5]\d$/, "must be HH:MM (24h), e.g. 19:30");
31269
+ var NumericIdString = external_exports.string().regex(/^\d+$/, "must be a numeric id (digits only)").describe("A numeric id string (digits only), safe to interpolate into a URL path.");
31270
+ var SafePathSegment = external_exports.string().min(1).regex(/^[^/?#\s]+$/, 'must not contain "/", "?", "#", or whitespace').refine((v) => !v.includes(".."), 'must not contain ".."').describe('A single URL path segment, safe to interpolate: no "/", "..", "?", "#", or whitespace.');
31226
31271
  var schemaOrigin = external_exports.string().optional().describe("Portal origin (e.g. https://<vendor>.example.co) selecting which active session to use. Optional when only one session is active.");
31227
31272
  var schemaConfirm = external_exports.boolean().optional().describe("Must be true to proceed. Without this, the tool returns a preview.");
31228
31273
  var paginationSchema = {
@@ -31279,6 +31324,19 @@ var SplitwiseClient = class {
31279
31324
  };
31280
31325
  var client = new SplitwiseClient();
31281
31326
 
31327
+ // src/tools/_confirm.ts
31328
+ function previewUnlessConfirmed(confirm, action, method, path, body) {
31329
+ if (confirm === true) return null;
31330
+ return textResult({
31331
+ dryRun: true,
31332
+ action,
31333
+ method,
31334
+ path,
31335
+ ...body !== void 0 ? { willSend: body } : {},
31336
+ note: "Re-run with confirm: true to execute."
31337
+ });
31338
+ }
31339
+
31282
31340
  // src/tools/user.ts
31283
31341
  function registerUserTools(server) {
31284
31342
  server.registerTool("sw_get_current_user", {
@@ -31307,9 +31365,10 @@ function registerUserTools(server) {
31307
31365
  email: external_exports.string().optional(),
31308
31366
  password: external_exports.string().optional(),
31309
31367
  locale: external_exports.string().optional(),
31310
- default_currency: external_exports.string().optional()
31368
+ default_currency: external_exports.string().optional(),
31369
+ confirm: schemaConfirm
31311
31370
  }
31312
- }, async ({ id, first_name, last_name, email: email3, password, locale, default_currency }) => {
31371
+ }, async ({ id, first_name, last_name, email: email3, password, locale, default_currency, confirm }) => {
31313
31372
  const body = {};
31314
31373
  if (first_name !== void 0) body.first_name = first_name;
31315
31374
  if (last_name !== void 0) body.last_name = last_name;
@@ -31317,6 +31376,9 @@ function registerUserTools(server) {
31317
31376
  if (password !== void 0) body.password = password;
31318
31377
  if (locale !== void 0) body.locale = locale;
31319
31378
  if (default_currency !== void 0) body.default_currency = default_currency;
31379
+ const previewBody = { ...body, ...password !== void 0 ? { password: "[hidden]" } : {} };
31380
+ const gate = previewUnlessConfirmed(confirm, `Update current Splitwise user ${id} profile`, "POST", `/update_user/${id}`, previewBody);
31381
+ if (gate) return gate;
31320
31382
  const data = await client.request("POST", `/update_user/${id}`, body);
31321
31383
  return textResult(data);
31322
31384
  });
@@ -31343,15 +31405,19 @@ function registerGroupTools(server) {
31343
31405
  });
31344
31406
  server.registerTool("sw_create_group", {
31345
31407
  description: "Create a new Splitwise group.",
31408
+ annotations: { destructiveHint: true },
31346
31409
  inputSchema: {
31347
31410
  name: external_exports.string().describe("Group name"),
31348
31411
  group_type: external_exports.enum(["apartment", "house", "trip", "other"]).describe("Type of group").optional(),
31349
- simplify_by_default: external_exports.boolean().describe("Whether to simplify debts by default").optional()
31412
+ simplify_by_default: external_exports.boolean().describe("Whether to simplify debts by default").optional(),
31413
+ confirm: schemaConfirm
31350
31414
  }
31351
- }, async ({ name, group_type, simplify_by_default }) => {
31415
+ }, async ({ name, group_type, simplify_by_default, confirm }) => {
31352
31416
  const body = { name };
31353
31417
  if (group_type !== void 0) body.group_type = group_type;
31354
31418
  if (simplify_by_default !== void 0) body.simplify_by_default = simplify_by_default;
31419
+ const gate = previewUnlessConfirmed(confirm, `Create Splitwise group "${name}"`, "POST", "/create_group", body);
31420
+ if (gate) return gate;
31355
31421
  const data = await client.request("POST", "/create_group", body);
31356
31422
  return textResult(data);
31357
31423
  });
@@ -31362,9 +31428,10 @@ function registerGroupTools(server) {
31362
31428
  user_id: external_exports.number().describe("User ID (preferred)").optional(),
31363
31429
  first_name: external_exports.string().optional(),
31364
31430
  last_name: external_exports.string().optional(),
31365
- email: external_exports.string().optional()
31431
+ email: external_exports.string().optional(),
31432
+ confirm: schemaConfirm
31366
31433
  }
31367
- }, async ({ group_id, user_id, first_name, last_name, email: email3 }) => {
31434
+ }, async ({ group_id, user_id, first_name, last_name, email: email3, confirm }) => {
31368
31435
  let body;
31369
31436
  if (user_id !== void 0) {
31370
31437
  body = { group_id, user_id };
@@ -31374,26 +31441,35 @@ function registerGroupTools(server) {
31374
31441
  }
31375
31442
  body = { group_id, first_name, last_name, email: email3 };
31376
31443
  }
31444
+ const gate = previewUnlessConfirmed(confirm, `Add a user to Splitwise group ${group_id} (may send an invite email)`, "POST", "/add_user_to_group", body);
31445
+ if (gate) return gate;
31377
31446
  const data = await client.request("POST", "/add_user_to_group", body);
31378
31447
  return textResult(data);
31379
31448
  });
31380
31449
  server.registerTool("sw_remove_user_from_group", {
31381
- description: "Remove a user from a Splitwise group.",
31450
+ description: "Remove a user from a Splitwise group. Without confirm:true this returns a dry-run preview and makes NO network call; with confirm:true it removes the user.",
31451
+ annotations: { destructiveHint: true },
31382
31452
  inputSchema: {
31383
31453
  group_id: external_exports.number().describe("Group ID"),
31384
- user_id: external_exports.number().describe("User ID to remove")
31454
+ user_id: external_exports.number().describe("User ID to remove"),
31455
+ confirm: schemaConfirm
31385
31456
  }
31386
- }, async ({ group_id, user_id }) => {
31457
+ }, async ({ group_id, user_id, confirm }) => {
31458
+ const gate = previewUnlessConfirmed(confirm, `Remove user ${user_id} from Splitwise group ${group_id}`, "POST", "/remove_user_from_group", { group_id, user_id });
31459
+ if (gate) return gate;
31387
31460
  const data = await client.request("POST", "/remove_user_from_group", { group_id, user_id });
31388
31461
  return textResult(data);
31389
31462
  });
31390
31463
  server.registerTool("sw_delete_group", {
31391
- description: "Soft-delete a Splitwise group.",
31464
+ description: "Soft-delete a Splitwise group. Without confirm:true this returns a dry-run preview and makes NO network call; with confirm:true it deletes.",
31392
31465
  annotations: { destructiveHint: true },
31393
31466
  inputSchema: {
31394
- id: external_exports.number().describe("Group ID to delete")
31467
+ id: external_exports.number().describe("Group ID to delete"),
31468
+ confirm: schemaConfirm
31395
31469
  }
31396
- }, async ({ id }) => {
31470
+ }, async ({ id, confirm }) => {
31471
+ const gate = previewUnlessConfirmed(confirm, `Soft-delete Splitwise group ${id}`, "POST", `/delete_group/${id}`);
31472
+ if (gate) return gate;
31397
31473
  const data = await client.request("POST", `/delete_group/${id}`);
31398
31474
  return textResult(data);
31399
31475
  });
@@ -31418,26 +31494,33 @@ function registerFriendTools(server) {
31418
31494
  return textResult(data);
31419
31495
  });
31420
31496
  server.registerTool("sw_create_friend", {
31421
- description: "Add a Splitwise friend by email.",
31497
+ description: "Add a Splitwise friend by email (sends them an invite). Without confirm:true this returns a dry-run preview and makes NO network call; with confirm:true it adds the friend.",
31498
+ annotations: { destructiveHint: true },
31422
31499
  inputSchema: {
31423
31500
  user_email: external_exports.string().describe("Email of the user to add as a friend"),
31424
31501
  user_first_name: external_exports.string().describe("First name of the user").optional(),
31425
- user_last_name: external_exports.string().describe("Last name of the user").optional()
31502
+ user_last_name: external_exports.string().describe("Last name of the user").optional(),
31503
+ confirm: schemaConfirm
31426
31504
  }
31427
- }, async ({ user_email, user_first_name, user_last_name }) => {
31505
+ }, async ({ user_email, user_first_name, user_last_name, confirm }) => {
31428
31506
  const body = { user_email };
31429
31507
  if (user_first_name !== void 0) body.user_first_name = user_first_name;
31430
31508
  if (user_last_name !== void 0) body.user_last_name = user_last_name;
31509
+ const gate = previewUnlessConfirmed(confirm, `Add ${user_email} as a Splitwise friend`, "POST", "/create_friend", body);
31510
+ if (gate) return gate;
31431
31511
  const data = await client.request("POST", "/create_friend", body);
31432
31512
  return textResult(data);
31433
31513
  });
31434
31514
  server.registerTool("sw_delete_friend", {
31435
- description: "Remove a Splitwise friendship by user id.",
31515
+ description: "Remove a Splitwise friendship by user id. Without confirm:true this returns a dry-run preview and makes NO network call; with confirm:true it removes the friendship.",
31436
31516
  annotations: { destructiveHint: true },
31437
31517
  inputSchema: {
31438
- id: external_exports.number().describe("User ID of the friend to remove")
31518
+ id: external_exports.number().describe("User ID of the friend to remove"),
31519
+ confirm: schemaConfirm
31439
31520
  }
31440
- }, async ({ id }) => {
31521
+ }, async ({ id, confirm }) => {
31522
+ const gate = previewUnlessConfirmed(confirm, `Remove Splitwise friendship with user ${id}`, "POST", `/delete_friend/${id}`);
31523
+ if (gate) return gate;
31441
31524
  const data = await client.request("POST", `/delete_friend/${id}`);
31442
31525
  return textResult(data);
31443
31526
  });
@@ -31454,7 +31537,7 @@ function flattenUsers(users) {
31454
31537
  return flat;
31455
31538
  }
31456
31539
  function buildExpenseBody(args) {
31457
- const { split_equally, users, expense_id: _id, ...rest } = args;
31540
+ const { split_equally, users, expense_id: _id, confirm: _confirm, ...rest } = args;
31458
31541
  if (split_equally && users) {
31459
31542
  throw new Error("Provide either split_equally or users, not both");
31460
31543
  }
@@ -31510,7 +31593,7 @@ function registerExpenseTools(server) {
31510
31593
  return textResult(data);
31511
31594
  });
31512
31595
  server.registerTool("sw_create_expense", {
31513
- description: 'Create a Splitwise expense. Use split_equally:true to split evenly among group members, or provide a users array for custom per-person splits (paid_share and owed_share as decimal strings like "25.00"). cost must be a decimal string.',
31596
+ description: 'Create a Splitwise expense. Use split_equally:true to split evenly among group members, or provide a users array for custom per-person splits (paid_share and owed_share as decimal strings like "25.00"). cost must be a decimal string. Without confirm:true this returns a dry-run preview and makes NO network call; with confirm:true it creates the expense.',
31514
31597
  inputSchema: {
31515
31598
  group_id: external_exports.number().describe("Group to add expense to (use 0 for no group)"),
31516
31599
  description: external_exports.string().describe("Short description of the expense"),
@@ -31520,15 +31603,18 @@ function registerExpenseTools(server) {
31520
31603
  currency_code: external_exports.string().describe('Currency code, e.g. "USD". Defaults to group/user default.').optional(),
31521
31604
  date: external_exports.string().describe("ISO 8601 datetime").optional(),
31522
31605
  category_id: external_exports.number().describe("Category id from sw_get_categories").optional(),
31523
- details: external_exports.string().describe("Notes").optional()
31606
+ details: external_exports.string().describe("Notes").optional(),
31607
+ confirm: schemaConfirm
31524
31608
  }
31525
31609
  }, async (args) => {
31526
31610
  const body = buildExpenseBody(args);
31611
+ const gate = previewUnlessConfirmed(args.confirm, `Create a Splitwise expense "${args.description}" (${args.cost}) \u2014 notifies group members`, "POST", "/create_expense", body);
31612
+ if (gate) return gate;
31527
31613
  const data = await client.request("POST", "/create_expense", body);
31528
31614
  return textResult(data);
31529
31615
  });
31530
31616
  server.registerTool("sw_update_expense", {
31531
- description: "Edit an existing Splitwise expense. Provide expense_id and any fields to change. For custom split updates, the full users array must be provided (the API replaces the entire split).",
31617
+ description: "Edit an existing Splitwise expense. Provide expense_id and any fields to change. For custom split updates, the full users array must be provided (the API replaces the entire split). Without confirm:true this returns a dry-run preview and makes NO network call; with confirm:true it updates the expense.",
31532
31618
  inputSchema: {
31533
31619
  expense_id: external_exports.number().describe("ID of the expense to update"),
31534
31620
  description: external_exports.string().optional(),
@@ -31538,21 +31624,27 @@ function registerExpenseTools(server) {
31538
31624
  currency_code: external_exports.string().optional(),
31539
31625
  date: external_exports.string().optional(),
31540
31626
  category_id: external_exports.number().optional(),
31541
- details: external_exports.string().optional()
31627
+ details: external_exports.string().optional(),
31628
+ confirm: schemaConfirm
31542
31629
  }
31543
31630
  }, async (args) => {
31544
31631
  const { expense_id } = args;
31545
31632
  const body = buildExpenseBody(args);
31633
+ const gate = previewUnlessConfirmed(args.confirm, `Update Splitwise expense ${expense_id} \u2014 notifies group members`, "POST", `/update_expense/${expense_id}`, body);
31634
+ if (gate) return gate;
31546
31635
  const data = await client.request("POST", `/update_expense/${expense_id}`, body);
31547
31636
  return textResult(data);
31548
31637
  });
31549
31638
  server.registerTool("sw_delete_expense", {
31550
- description: "Soft-delete a Splitwise expense by id. Returns {success: true} on success. Use sw_undelete_expense to restore.",
31639
+ description: "Soft-delete a Splitwise expense by id. Returns {success: true} on success. Use sw_undelete_expense to restore. Without confirm:true this returns a dry-run preview and makes NO network call; with confirm:true it deletes.",
31551
31640
  annotations: { destructiveHint: true },
31552
31641
  inputSchema: {
31553
- id: external_exports.number().describe("Expense ID to delete")
31642
+ id: external_exports.number().describe("Expense ID to delete"),
31643
+ confirm: schemaConfirm
31554
31644
  }
31555
- }, async ({ id }) => {
31645
+ }, async ({ id, confirm }) => {
31646
+ const gate = previewUnlessConfirmed(confirm, `Soft-delete Splitwise expense ${id}`, "POST", `/delete_expense/${id}`);
31647
+ if (gate) return gate;
31556
31648
  const data = await client.request("POST", `/delete_expense/${id}`);
31557
31649
  return textResult(data);
31558
31650
  });
@@ -31601,22 +31693,29 @@ function registerUtilityTools(server) {
31601
31693
  return textResult(data);
31602
31694
  });
31603
31695
  server.registerTool("sw_create_comment", {
31604
- description: "Add a comment to a Splitwise expense.",
31696
+ description: "Add a comment to a Splitwise expense (visible to other participants). Without confirm:true this returns a dry-run preview and makes NO network call; with confirm:true it posts.",
31697
+ annotations: { destructiveHint: true },
31605
31698
  inputSchema: {
31606
31699
  expense_id: external_exports.number().describe("Expense ID to comment on"),
31607
- content: external_exports.string().describe("Comment text")
31700
+ content: external_exports.string().describe("Comment text"),
31701
+ confirm: schemaConfirm
31608
31702
  }
31609
- }, async ({ expense_id, content }) => {
31703
+ }, async ({ expense_id, content, confirm }) => {
31704
+ const gate = previewUnlessConfirmed(confirm, `Comment on Splitwise expense ${expense_id} (visible to participants)`, "POST", "/create_comment", { expense_id, content });
31705
+ if (gate) return gate;
31610
31706
  const data = await client.request("POST", "/create_comment", { expense_id, content });
31611
31707
  return textResult(data);
31612
31708
  });
31613
31709
  server.registerTool("sw_delete_comment", {
31614
- description: "Delete a comment by id.",
31710
+ description: "Delete a comment by id. Without confirm:true this returns a dry-run preview and makes NO network call; with confirm:true it deletes.",
31615
31711
  annotations: { destructiveHint: true },
31616
31712
  inputSchema: {
31617
- id: external_exports.number().describe("Comment ID to delete")
31713
+ id: external_exports.number().describe("Comment ID to delete"),
31714
+ confirm: schemaConfirm
31618
31715
  }
31619
- }, async ({ id }) => {
31716
+ }, async ({ id, confirm }) => {
31717
+ const gate = previewUnlessConfirmed(confirm, `Delete Splitwise comment ${id}`, "POST", `/delete_comment/${id}`);
31718
+ if (gate) return gate;
31620
31719
  const data = await client.request("POST", `/delete_comment/${id}`);
31621
31720
  return textResult(data);
31622
31721
  });
@@ -31625,7 +31724,7 @@ function registerUtilityTools(server) {
31625
31724
  // src/index.ts
31626
31725
  await runMcp({
31627
31726
  name: "splitwise-mcp",
31628
- version: "2.0.9",
31727
+ version: "2.0.11",
31629
31728
  // x-release-please-version
31630
31729
  banner: "[splitwise-mcp] This project was developed and is maintained by AI (Claude Sonnet 4.6). Use at your own discretion.",
31631
31730
  tools: [
package/dist/index.js CHANGED
@@ -12,7 +12,7 @@ import { registerUtilityTools } from './tools/utilities.js';
12
12
  // the configuration error only surfaces on the first tool call.
13
13
  await runMcp({
14
14
  name: 'splitwise-mcp',
15
- version: '2.0.9', // x-release-please-version
15
+ version: '2.0.11', // x-release-please-version
16
16
  banner: '[splitwise-mcp] This project was developed and is maintained by AI (Claude Sonnet 4.6). Use at your own discretion.',
17
17
  tools: [
18
18
  registerUserTools,
@@ -0,0 +1,21 @@
1
+ import { schemaConfirm, textResult } from '@chrischall/mcp-utils';
2
+ export { schemaConfirm };
3
+ /**
4
+ * Confirm-gate for a mutating tool (the fleet convention). When `confirm` is not
5
+ * `true`, returns a no-network dry-run preview of exactly what would be sent;
6
+ * when it is `true`, returns `null` so the caller proceeds with the write.
7
+ * Splitwise writes notify other people (friends / group members), so a single
8
+ * hallucinated call shouldn't fire silently.
9
+ */
10
+ export function previewUnlessConfirmed(confirm, action, method, path, body) {
11
+ if (confirm === true)
12
+ return null;
13
+ return textResult({
14
+ dryRun: true,
15
+ action,
16
+ method,
17
+ path,
18
+ ...(body !== undefined ? { willSend: body } : {}),
19
+ note: 'Re-run with confirm: true to execute.',
20
+ });
21
+ }
@@ -1,6 +1,7 @@
1
1
  import { z } from 'zod';
2
2
  import { textResult, buildQueryString } from '@chrischall/mcp-utils';
3
3
  import { client } from '../client.js';
4
+ import { previewUnlessConfirmed, schemaConfirm } from './_confirm.js';
4
5
  /** Flattens a users array into Splitwise's flat-param JSON format. */
5
6
  export function flattenUsers(users) {
6
7
  const flat = {};
@@ -12,7 +13,8 @@ export function flattenUsers(users) {
12
13
  return flat;
13
14
  }
14
15
  function buildExpenseBody(args) {
15
- const { split_equally, users, expense_id: _id, ...rest } = args;
16
+ // `confirm` is a tool-gate flag, not part of the Splitwise expense payload.
17
+ const { split_equally, users, expense_id: _id, confirm: _confirm, ...rest } = args;
16
18
  if (split_equally && users) {
17
19
  throw new Error('Provide either split_equally or users, not both');
18
20
  }
@@ -69,7 +71,7 @@ export function registerExpenseTools(server) {
69
71
  return textResult(data);
70
72
  });
71
73
  server.registerTool('sw_create_expense', {
72
- description: 'Create a Splitwise expense. Use split_equally:true to split evenly among group members, or provide a users array for custom per-person splits (paid_share and owed_share as decimal strings like "25.00"). cost must be a decimal string.',
74
+ description: 'Create a Splitwise expense. Use split_equally:true to split evenly among group members, or provide a users array for custom per-person splits (paid_share and owed_share as decimal strings like "25.00"). cost must be a decimal string. Without confirm:true this returns a dry-run preview and makes NO network call; with confirm:true it creates the expense.',
73
75
  inputSchema: {
74
76
  group_id: z.number().describe('Group to add expense to (use 0 for no group)'),
75
77
  description: z.string().describe('Short description of the expense'),
@@ -80,14 +82,18 @@ export function registerExpenseTools(server) {
80
82
  date: z.string().describe('ISO 8601 datetime').optional(),
81
83
  category_id: z.number().describe('Category id from sw_get_categories').optional(),
82
84
  details: z.string().describe('Notes').optional(),
85
+ confirm: schemaConfirm,
83
86
  },
84
87
  }, async (args) => {
85
88
  const body = buildExpenseBody(args);
89
+ const gate = previewUnlessConfirmed(args.confirm, `Create a Splitwise expense "${args.description}" (${args.cost}) — notifies group members`, 'POST', '/create_expense', body);
90
+ if (gate)
91
+ return gate;
86
92
  const data = await client.request('POST', '/create_expense', body);
87
93
  return textResult(data);
88
94
  });
89
95
  server.registerTool('sw_update_expense', {
90
- description: 'Edit an existing Splitwise expense. Provide expense_id and any fields to change. For custom split updates, the full users array must be provided (the API replaces the entire split).',
96
+ description: 'Edit an existing Splitwise expense. Provide expense_id and any fields to change. For custom split updates, the full users array must be provided (the API replaces the entire split). Without confirm:true this returns a dry-run preview and makes NO network call; with confirm:true it updates the expense.',
91
97
  inputSchema: {
92
98
  expense_id: z.number().describe('ID of the expense to update'),
93
99
  description: z.string().optional(),
@@ -98,20 +104,28 @@ export function registerExpenseTools(server) {
98
104
  date: z.string().optional(),
99
105
  category_id: z.number().optional(),
100
106
  details: z.string().optional(),
107
+ confirm: schemaConfirm,
101
108
  },
102
109
  }, async (args) => {
103
110
  const { expense_id } = args;
104
111
  const body = buildExpenseBody(args);
112
+ const gate = previewUnlessConfirmed(args.confirm, `Update Splitwise expense ${expense_id} — notifies group members`, 'POST', `/update_expense/${expense_id}`, body);
113
+ if (gate)
114
+ return gate;
105
115
  const data = await client.request('POST', `/update_expense/${expense_id}`, body);
106
116
  return textResult(data);
107
117
  });
108
118
  server.registerTool('sw_delete_expense', {
109
- description: 'Soft-delete a Splitwise expense by id. Returns {success: true} on success. Use sw_undelete_expense to restore.',
119
+ description: 'Soft-delete a Splitwise expense by id. Returns {success: true} on success. Use sw_undelete_expense to restore. Without confirm:true this returns a dry-run preview and makes NO network call; with confirm:true it deletes.',
110
120
  annotations: { destructiveHint: true },
111
121
  inputSchema: {
112
122
  id: z.number().describe('Expense ID to delete'),
123
+ confirm: schemaConfirm,
113
124
  },
114
- }, async ({ id }) => {
125
+ }, async ({ id, confirm }) => {
126
+ const gate = previewUnlessConfirmed(confirm, `Soft-delete Splitwise expense ${id}`, 'POST', `/delete_expense/${id}`);
127
+ if (gate)
128
+ return gate;
115
129
  const data = await client.request('POST', `/delete_expense/${id}`);
116
130
  return textResult(data);
117
131
  });
@@ -1,6 +1,7 @@
1
1
  import { z } from 'zod';
2
2
  import { textResult } from '@chrischall/mcp-utils';
3
3
  import { client } from '../client.js';
4
+ import { previewUnlessConfirmed, schemaConfirm } from './_confirm.js';
4
5
  export function registerFriendTools(server) {
5
6
  server.registerTool('sw_list_friends', {
6
7
  description: "List all Splitwise friends with their id, first_name, last_name, and email. Use this to resolve a friend's name to a user_id before adding them to a group or building a custom expense split.",
@@ -10,28 +11,37 @@ export function registerFriendTools(server) {
10
11
  return textResult(data);
11
12
  });
12
13
  server.registerTool('sw_create_friend', {
13
- description: 'Add a Splitwise friend by email.',
14
+ description: 'Add a Splitwise friend by email (sends them an invite). Without confirm:true this returns a dry-run preview and makes NO network call; with confirm:true it adds the friend.',
15
+ annotations: { destructiveHint: true },
14
16
  inputSchema: {
15
17
  user_email: z.string().describe('Email of the user to add as a friend'),
16
18
  user_first_name: z.string().describe('First name of the user').optional(),
17
19
  user_last_name: z.string().describe('Last name of the user').optional(),
20
+ confirm: schemaConfirm,
18
21
  },
19
- }, async ({ user_email, user_first_name, user_last_name }) => {
22
+ }, async ({ user_email, user_first_name, user_last_name, confirm }) => {
20
23
  const body = { user_email };
21
24
  if (user_first_name !== undefined)
22
25
  body.user_first_name = user_first_name;
23
26
  if (user_last_name !== undefined)
24
27
  body.user_last_name = user_last_name;
28
+ const gate = previewUnlessConfirmed(confirm, `Add ${user_email} as a Splitwise friend`, 'POST', '/create_friend', body);
29
+ if (gate)
30
+ return gate;
25
31
  const data = await client.request('POST', '/create_friend', body);
26
32
  return textResult(data);
27
33
  });
28
34
  server.registerTool('sw_delete_friend', {
29
- description: 'Remove a Splitwise friendship by user id.',
35
+ description: 'Remove a Splitwise friendship by user id. Without confirm:true this returns a dry-run preview and makes NO network call; with confirm:true it removes the friendship.',
30
36
  annotations: { destructiveHint: true },
31
37
  inputSchema: {
32
38
  id: z.number().describe('User ID of the friend to remove'),
39
+ confirm: schemaConfirm,
33
40
  },
34
- }, async ({ id }) => {
41
+ }, async ({ id, confirm }) => {
42
+ const gate = previewUnlessConfirmed(confirm, `Remove Splitwise friendship with user ${id}`, 'POST', `/delete_friend/${id}`);
43
+ if (gate)
44
+ return gate;
35
45
  const data = await client.request('POST', `/delete_friend/${id}`);
36
46
  return textResult(data);
37
47
  });
@@ -1,6 +1,7 @@
1
1
  import { z } from 'zod';
2
2
  import { textResult } from '@chrischall/mcp-utils';
3
3
  import { client } from '../client.js';
4
+ import { previewUnlessConfirmed, schemaConfirm } from './_confirm.js';
4
5
  export function registerGroupTools(server) {
5
6
  server.registerTool('sw_list_groups', {
6
7
  description: 'List all Splitwise groups the current user belongs to. Returns id, name, and members for each group. Use this to resolve a group name to its id.',
@@ -21,17 +22,22 @@ export function registerGroupTools(server) {
21
22
  });
22
23
  server.registerTool('sw_create_group', {
23
24
  description: 'Create a new Splitwise group.',
25
+ annotations: { destructiveHint: true },
24
26
  inputSchema: {
25
27
  name: z.string().describe('Group name'),
26
28
  group_type: z.enum(['apartment', 'house', 'trip', 'other']).describe('Type of group').optional(),
27
29
  simplify_by_default: z.boolean().describe('Whether to simplify debts by default').optional(),
30
+ confirm: schemaConfirm,
28
31
  },
29
- }, async ({ name, group_type, simplify_by_default }) => {
32
+ }, async ({ name, group_type, simplify_by_default, confirm }) => {
30
33
  const body = { name };
31
34
  if (group_type !== undefined)
32
35
  body.group_type = group_type;
33
36
  if (simplify_by_default !== undefined)
34
37
  body.simplify_by_default = simplify_by_default;
38
+ const gate = previewUnlessConfirmed(confirm, `Create Splitwise group "${name}"`, 'POST', '/create_group', body);
39
+ if (gate)
40
+ return gate;
35
41
  const data = await client.request('POST', '/create_group', body);
36
42
  return textResult(data);
37
43
  });
@@ -43,8 +49,9 @@ export function registerGroupTools(server) {
43
49
  first_name: z.string().optional(),
44
50
  last_name: z.string().optional(),
45
51
  email: z.string().optional(),
52
+ confirm: schemaConfirm,
46
53
  },
47
- }, async ({ group_id, user_id, first_name, last_name, email }) => {
54
+ }, async ({ group_id, user_id, first_name, last_name, email, confirm }) => {
48
55
  let body;
49
56
  if (user_id !== undefined) {
50
57
  body = { group_id, user_id };
@@ -55,26 +62,38 @@ export function registerGroupTools(server) {
55
62
  }
56
63
  body = { group_id, first_name, last_name, email };
57
64
  }
65
+ const gate = previewUnlessConfirmed(confirm, `Add a user to Splitwise group ${group_id} (may send an invite email)`, 'POST', '/add_user_to_group', body);
66
+ if (gate)
67
+ return gate;
58
68
  const data = await client.request('POST', '/add_user_to_group', body);
59
69
  return textResult(data);
60
70
  });
61
71
  server.registerTool('sw_remove_user_from_group', {
62
- description: 'Remove a user from a Splitwise group.',
72
+ description: 'Remove a user from a Splitwise group. Without confirm:true this returns a dry-run preview and makes NO network call; with confirm:true it removes the user.',
73
+ annotations: { destructiveHint: true },
63
74
  inputSchema: {
64
75
  group_id: z.number().describe('Group ID'),
65
76
  user_id: z.number().describe('User ID to remove'),
77
+ confirm: schemaConfirm,
66
78
  },
67
- }, async ({ group_id, user_id }) => {
79
+ }, async ({ group_id, user_id, confirm }) => {
80
+ const gate = previewUnlessConfirmed(confirm, `Remove user ${user_id} from Splitwise group ${group_id}`, 'POST', '/remove_user_from_group', { group_id, user_id });
81
+ if (gate)
82
+ return gate;
68
83
  const data = await client.request('POST', '/remove_user_from_group', { group_id, user_id });
69
84
  return textResult(data);
70
85
  });
71
86
  server.registerTool('sw_delete_group', {
72
- description: 'Soft-delete a Splitwise group.',
87
+ description: 'Soft-delete a Splitwise group. Without confirm:true this returns a dry-run preview and makes NO network call; with confirm:true it deletes.',
73
88
  annotations: { destructiveHint: true },
74
89
  inputSchema: {
75
90
  id: z.number().describe('Group ID to delete'),
91
+ confirm: schemaConfirm,
76
92
  },
77
- }, async ({ id }) => {
93
+ }, async ({ id, confirm }) => {
94
+ const gate = previewUnlessConfirmed(confirm, `Soft-delete Splitwise group ${id}`, 'POST', `/delete_group/${id}`);
95
+ if (gate)
96
+ return gate;
78
97
  const data = await client.request('POST', `/delete_group/${id}`);
79
98
  return textResult(data);
80
99
  });
@@ -1,6 +1,7 @@
1
1
  import { z } from 'zod';
2
2
  import { textResult } from '@chrischall/mcp-utils';
3
3
  import { client } from '../client.js';
4
+ import { previewUnlessConfirmed, schemaConfirm } from './_confirm.js';
4
5
  export function registerUserTools(server) {
5
6
  server.registerTool('sw_get_current_user', {
6
7
  description: "Get the authenticated Splitwise user's profile (id, first_name, last_name, email). Use the returned id when building custom expense splits.",
@@ -29,8 +30,9 @@ export function registerUserTools(server) {
29
30
  password: z.string().optional(),
30
31
  locale: z.string().optional(),
31
32
  default_currency: z.string().optional(),
33
+ confirm: schemaConfirm,
32
34
  },
33
- }, async ({ id, first_name, last_name, email, password, locale, default_currency }) => {
35
+ }, async ({ id, first_name, last_name, email, password, locale, default_currency, confirm }) => {
34
36
  const body = {};
35
37
  if (first_name !== undefined)
36
38
  body.first_name = first_name;
@@ -44,6 +46,11 @@ export function registerUserTools(server) {
44
46
  body.locale = locale;
45
47
  if (default_currency !== undefined)
46
48
  body.default_currency = default_currency;
49
+ // Never echo the password in the dry-run preview.
50
+ const previewBody = { ...body, ...(password !== undefined ? { password: '[hidden]' } : {}) };
51
+ const gate = previewUnlessConfirmed(confirm, `Update current Splitwise user ${id} profile`, 'POST', `/update_user/${id}`, previewBody);
52
+ if (gate)
53
+ return gate;
47
54
  const data = await client.request('POST', `/update_user/${id}`, body);
48
55
  return textResult(data);
49
56
  });
@@ -1,6 +1,7 @@
1
1
  import { z } from 'zod';
2
2
  import { textResult, buildQueryString } from '@chrischall/mcp-utils';
3
3
  import { client } from '../client.js';
4
+ import { previewUnlessConfirmed, schemaConfirm } from './_confirm.js';
4
5
  export function registerUtilityTools(server) {
5
6
  server.registerTool('sw_get_notifications', {
6
7
  description: 'Get recent Splitwise activity notifications for the current user.',
@@ -36,22 +37,31 @@ export function registerUtilityTools(server) {
36
37
  return textResult(data);
37
38
  });
38
39
  server.registerTool('sw_create_comment', {
39
- description: 'Add a comment to a Splitwise expense.',
40
+ description: 'Add a comment to a Splitwise expense (visible to other participants). Without confirm:true this returns a dry-run preview and makes NO network call; with confirm:true it posts.',
41
+ annotations: { destructiveHint: true },
40
42
  inputSchema: {
41
43
  expense_id: z.number().describe('Expense ID to comment on'),
42
44
  content: z.string().describe('Comment text'),
45
+ confirm: schemaConfirm,
43
46
  },
44
- }, async ({ expense_id, content }) => {
47
+ }, async ({ expense_id, content, confirm }) => {
48
+ const gate = previewUnlessConfirmed(confirm, `Comment on Splitwise expense ${expense_id} (visible to participants)`, 'POST', '/create_comment', { expense_id, content });
49
+ if (gate)
50
+ return gate;
45
51
  const data = await client.request('POST', '/create_comment', { expense_id, content });
46
52
  return textResult(data);
47
53
  });
48
54
  server.registerTool('sw_delete_comment', {
49
- description: 'Delete a comment by id.',
55
+ description: 'Delete a comment by id. Without confirm:true this returns a dry-run preview and makes NO network call; with confirm:true it deletes.',
50
56
  annotations: { destructiveHint: true },
51
57
  inputSchema: {
52
58
  id: z.number().describe('Comment ID to delete'),
59
+ confirm: schemaConfirm,
53
60
  },
54
- }, async ({ id }) => {
61
+ }, async ({ id, confirm }) => {
62
+ const gate = previewUnlessConfirmed(confirm, `Delete Splitwise comment ${id}`, 'POST', `/delete_comment/${id}`);
63
+ if (gate)
64
+ return gate;
55
65
  const data = await client.request('POST', `/delete_comment/${id}`);
56
66
  return textResult(data);
57
67
  });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "splitwise-mcp",
3
- "version": "2.0.9",
3
+ "version": "2.0.11",
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>",
@@ -43,13 +43,13 @@
43
43
  "test:coverage": "vitest run --coverage"
44
44
  },
45
45
  "dependencies": {
46
- "@chrischall/mcp-utils": "^0.7.0",
46
+ "@chrischall/mcp-utils": "^0.12.0",
47
47
  "@modelcontextprotocol/sdk": "^1.29.0",
48
48
  "dotenv": "^17.4.0",
49
49
  "zod": "^4.4.2"
50
50
  },
51
51
  "devDependencies": {
52
- "@types/node": "^25.5.2",
52
+ "@types/node": "^26.0.0",
53
53
  "@vitest/coverage-v8": "^4.1.2",
54
54
  "esbuild": "^0.28.0",
55
55
  "typescript": "^6.0.2",
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.0.9",
9
+ "version": "2.0.11",
10
10
  "packages": [
11
11
  {
12
12
  "registryType": "npm",
13
13
  "identifier": "splitwise-mcp",
14
- "version": "2.0.9",
14
+ "version": "2.0.11",
15
15
  "transport": {
16
16
  "type": "stdio"
17
17
  },