splitwise-mcp 2.0.10 → 2.0.12

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.10"
10
+ "version": "2.0.12"
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.10",
18
+ "version": "2.0.12",
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.10",
4
+ "version": "2.0.12",
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",
@@ -16,6 +16,6 @@
16
16
  "mcp",
17
17
  "finance"
18
18
  ],
19
- "skills": "./SKILL.md",
19
+ "skills": "./skills/",
20
20
  "mcp": "./.mcp.json"
21
21
  }
package/dist/bundle.js CHANGED
@@ -31021,8 +31021,12 @@ var API_KEY_RE = new RegExp([
31021
31021
  // webhook signing secret (Stripe-style)
31022
31022
  ].map((p) => `\\b${p}`).join("|"), "g");
31023
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");
31024
31028
  function redactSecrets(text) {
31025
- 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]");
31026
31030
  }
31027
31031
  function truncateErrorMessage(text, max = DEFAULT_ERROR_MESSAGE_MAX) {
31028
31032
  const str = text === null || text === void 0 ? "" : String(text);
@@ -31069,6 +31073,18 @@ async function loadDotenvSafely(opts = {}) {
31069
31073
  }
31070
31074
  }
31071
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
+
31072
31088
  // node_modules/@chrischall/mcp-utils/dist/http/index.js
31073
31089
  var DEFAULT_RETRY = { count: 1, delayMs: 2e3 };
31074
31090
  var defaultSleep = (ms) => new Promise((r) => setTimeout(r, ms));
@@ -31160,7 +31176,11 @@ function createApiClient(opts) {
31160
31176
  const res = await once();
31161
31177
  if (retryStatuses.includes(res.status) && attempt < retry.count) {
31162
31178
  attempt += 1;
31163
- 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);
31164
31184
  continue;
31165
31185
  }
31166
31186
  return res;
@@ -31195,7 +31215,26 @@ function createApiClient(opts) {
31195
31215
  }
31196
31216
  return text;
31197
31217
  }
31198
- 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 };
31199
31238
  }
31200
31239
  function buildQueryString(params) {
31201
31240
  const parts = [];
@@ -31285,6 +31324,19 @@ var SplitwiseClient = class {
31285
31324
  };
31286
31325
  var client = new SplitwiseClient();
31287
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
+
31288
31340
  // src/tools/user.ts
31289
31341
  function registerUserTools(server) {
31290
31342
  server.registerTool("sw_get_current_user", {
@@ -31313,9 +31365,10 @@ function registerUserTools(server) {
31313
31365
  email: external_exports.string().optional(),
31314
31366
  password: external_exports.string().optional(),
31315
31367
  locale: external_exports.string().optional(),
31316
- default_currency: external_exports.string().optional()
31368
+ default_currency: external_exports.string().optional(),
31369
+ confirm: schemaConfirm
31317
31370
  }
31318
- }, 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 }) => {
31319
31372
  const body = {};
31320
31373
  if (first_name !== void 0) body.first_name = first_name;
31321
31374
  if (last_name !== void 0) body.last_name = last_name;
@@ -31323,6 +31376,9 @@ function registerUserTools(server) {
31323
31376
  if (password !== void 0) body.password = password;
31324
31377
  if (locale !== void 0) body.locale = locale;
31325
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;
31326
31382
  const data = await client.request("POST", `/update_user/${id}`, body);
31327
31383
  return textResult(data);
31328
31384
  });
@@ -31349,15 +31405,19 @@ function registerGroupTools(server) {
31349
31405
  });
31350
31406
  server.registerTool("sw_create_group", {
31351
31407
  description: "Create a new Splitwise group.",
31408
+ annotations: { destructiveHint: true },
31352
31409
  inputSchema: {
31353
31410
  name: external_exports.string().describe("Group name"),
31354
31411
  group_type: external_exports.enum(["apartment", "house", "trip", "other"]).describe("Type of group").optional(),
31355
- 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
31356
31414
  }
31357
- }, async ({ name, group_type, simplify_by_default }) => {
31415
+ }, async ({ name, group_type, simplify_by_default, confirm }) => {
31358
31416
  const body = { name };
31359
31417
  if (group_type !== void 0) body.group_type = group_type;
31360
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;
31361
31421
  const data = await client.request("POST", "/create_group", body);
31362
31422
  return textResult(data);
31363
31423
  });
@@ -31368,9 +31428,10 @@ function registerGroupTools(server) {
31368
31428
  user_id: external_exports.number().describe("User ID (preferred)").optional(),
31369
31429
  first_name: external_exports.string().optional(),
31370
31430
  last_name: external_exports.string().optional(),
31371
- email: external_exports.string().optional()
31431
+ email: external_exports.string().optional(),
31432
+ confirm: schemaConfirm
31372
31433
  }
31373
- }, async ({ group_id, user_id, first_name, last_name, email: email3 }) => {
31434
+ }, async ({ group_id, user_id, first_name, last_name, email: email3, confirm }) => {
31374
31435
  let body;
31375
31436
  if (user_id !== void 0) {
31376
31437
  body = { group_id, user_id };
@@ -31380,26 +31441,35 @@ function registerGroupTools(server) {
31380
31441
  }
31381
31442
  body = { group_id, first_name, last_name, email: email3 };
31382
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;
31383
31446
  const data = await client.request("POST", "/add_user_to_group", body);
31384
31447
  return textResult(data);
31385
31448
  });
31386
31449
  server.registerTool("sw_remove_user_from_group", {
31387
- 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 },
31388
31452
  inputSchema: {
31389
31453
  group_id: external_exports.number().describe("Group ID"),
31390
- user_id: external_exports.number().describe("User ID to remove")
31454
+ user_id: external_exports.number().describe("User ID to remove"),
31455
+ confirm: schemaConfirm
31391
31456
  }
31392
- }, 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;
31393
31460
  const data = await client.request("POST", "/remove_user_from_group", { group_id, user_id });
31394
31461
  return textResult(data);
31395
31462
  });
31396
31463
  server.registerTool("sw_delete_group", {
31397
- 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.",
31398
31465
  annotations: { destructiveHint: true },
31399
31466
  inputSchema: {
31400
- id: external_exports.number().describe("Group ID to delete")
31467
+ id: external_exports.number().describe("Group ID to delete"),
31468
+ confirm: schemaConfirm
31401
31469
  }
31402
- }, 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;
31403
31473
  const data = await client.request("POST", `/delete_group/${id}`);
31404
31474
  return textResult(data);
31405
31475
  });
@@ -31424,26 +31494,33 @@ function registerFriendTools(server) {
31424
31494
  return textResult(data);
31425
31495
  });
31426
31496
  server.registerTool("sw_create_friend", {
31427
- 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 },
31428
31499
  inputSchema: {
31429
31500
  user_email: external_exports.string().describe("Email of the user to add as a friend"),
31430
31501
  user_first_name: external_exports.string().describe("First name of the user").optional(),
31431
- 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
31432
31504
  }
31433
- }, async ({ user_email, user_first_name, user_last_name }) => {
31505
+ }, async ({ user_email, user_first_name, user_last_name, confirm }) => {
31434
31506
  const body = { user_email };
31435
31507
  if (user_first_name !== void 0) body.user_first_name = user_first_name;
31436
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;
31437
31511
  const data = await client.request("POST", "/create_friend", body);
31438
31512
  return textResult(data);
31439
31513
  });
31440
31514
  server.registerTool("sw_delete_friend", {
31441
- 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.",
31442
31516
  annotations: { destructiveHint: true },
31443
31517
  inputSchema: {
31444
- 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
31445
31520
  }
31446
- }, 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;
31447
31524
  const data = await client.request("POST", `/delete_friend/${id}`);
31448
31525
  return textResult(data);
31449
31526
  });
@@ -31460,7 +31537,7 @@ function flattenUsers(users) {
31460
31537
  return flat;
31461
31538
  }
31462
31539
  function buildExpenseBody(args) {
31463
- const { split_equally, users, expense_id: _id, ...rest } = args;
31540
+ const { split_equally, users, expense_id: _id, confirm: _confirm, ...rest } = args;
31464
31541
  if (split_equally && users) {
31465
31542
  throw new Error("Provide either split_equally or users, not both");
31466
31543
  }
@@ -31516,7 +31593,7 @@ function registerExpenseTools(server) {
31516
31593
  return textResult(data);
31517
31594
  });
31518
31595
  server.registerTool("sw_create_expense", {
31519
- 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.',
31520
31597
  inputSchema: {
31521
31598
  group_id: external_exports.number().describe("Group to add expense to (use 0 for no group)"),
31522
31599
  description: external_exports.string().describe("Short description of the expense"),
@@ -31526,15 +31603,18 @@ function registerExpenseTools(server) {
31526
31603
  currency_code: external_exports.string().describe('Currency code, e.g. "USD". Defaults to group/user default.').optional(),
31527
31604
  date: external_exports.string().describe("ISO 8601 datetime").optional(),
31528
31605
  category_id: external_exports.number().describe("Category id from sw_get_categories").optional(),
31529
- details: external_exports.string().describe("Notes").optional()
31606
+ details: external_exports.string().describe("Notes").optional(),
31607
+ confirm: schemaConfirm
31530
31608
  }
31531
31609
  }, async (args) => {
31532
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;
31533
31613
  const data = await client.request("POST", "/create_expense", body);
31534
31614
  return textResult(data);
31535
31615
  });
31536
31616
  server.registerTool("sw_update_expense", {
31537
- 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.",
31538
31618
  inputSchema: {
31539
31619
  expense_id: external_exports.number().describe("ID of the expense to update"),
31540
31620
  description: external_exports.string().optional(),
@@ -31544,21 +31624,27 @@ function registerExpenseTools(server) {
31544
31624
  currency_code: external_exports.string().optional(),
31545
31625
  date: external_exports.string().optional(),
31546
31626
  category_id: external_exports.number().optional(),
31547
- details: external_exports.string().optional()
31627
+ details: external_exports.string().optional(),
31628
+ confirm: schemaConfirm
31548
31629
  }
31549
31630
  }, async (args) => {
31550
31631
  const { expense_id } = args;
31551
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;
31552
31635
  const data = await client.request("POST", `/update_expense/${expense_id}`, body);
31553
31636
  return textResult(data);
31554
31637
  });
31555
31638
  server.registerTool("sw_delete_expense", {
31556
- 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.",
31557
31640
  annotations: { destructiveHint: true },
31558
31641
  inputSchema: {
31559
- id: external_exports.number().describe("Expense ID to delete")
31642
+ id: external_exports.number().describe("Expense ID to delete"),
31643
+ confirm: schemaConfirm
31560
31644
  }
31561
- }, 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;
31562
31648
  const data = await client.request("POST", `/delete_expense/${id}`);
31563
31649
  return textResult(data);
31564
31650
  });
@@ -31607,22 +31693,29 @@ function registerUtilityTools(server) {
31607
31693
  return textResult(data);
31608
31694
  });
31609
31695
  server.registerTool("sw_create_comment", {
31610
- 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 },
31611
31698
  inputSchema: {
31612
31699
  expense_id: external_exports.number().describe("Expense ID to comment on"),
31613
- content: external_exports.string().describe("Comment text")
31700
+ content: external_exports.string().describe("Comment text"),
31701
+ confirm: schemaConfirm
31614
31702
  }
31615
- }, 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;
31616
31706
  const data = await client.request("POST", "/create_comment", { expense_id, content });
31617
31707
  return textResult(data);
31618
31708
  });
31619
31709
  server.registerTool("sw_delete_comment", {
31620
- 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.",
31621
31711
  annotations: { destructiveHint: true },
31622
31712
  inputSchema: {
31623
- id: external_exports.number().describe("Comment ID to delete")
31713
+ id: external_exports.number().describe("Comment ID to delete"),
31714
+ confirm: schemaConfirm
31624
31715
  }
31625
- }, async ({ id }) => {
31716
+ }, async ({ id, confirm }) => {
31717
+ const gate = previewUnlessConfirmed(confirm, `Delete Splitwise comment ${id}`, "POST", `/delete_comment/${id}`);
31718
+ if (gate) return gate;
31626
31719
  const data = await client.request("POST", `/delete_comment/${id}`);
31627
31720
  return textResult(data);
31628
31721
  });
@@ -31631,7 +31724,7 @@ function registerUtilityTools(server) {
31631
31724
  // src/index.ts
31632
31725
  await runMcp({
31633
31726
  name: "splitwise-mcp",
31634
- version: "2.0.10",
31727
+ version: "2.0.12",
31635
31728
  // x-release-please-version
31636
31729
  banner: "[splitwise-mcp] This project was developed and is maintained by AI (Claude Sonnet 4.6). Use at your own discretion.",
31637
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.10', // x-release-please-version
15
+ version: '2.0.12', // 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.10",
3
+ "version": "2.0.12",
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>",
@@ -30,7 +30,7 @@
30
30
  "files": [
31
31
  "dist",
32
32
  ".claude-plugin",
33
- "SKILL.md",
33
+ "skills/",
34
34
  ".mcp.json",
35
35
  "server.json"
36
36
  ],
@@ -43,7 +43,7 @@
43
43
  "test:coverage": "vitest run --coverage"
44
44
  },
45
45
  "dependencies": {
46
- "@chrischall/mcp-utils": "^0.10.3",
46
+ "@chrischall/mcp-utils": "^0.13.0",
47
47
  "@modelcontextprotocol/sdk": "^1.29.0",
48
48
  "dotenv": "^17.4.0",
49
49
  "zod": "^4.4.2"
@@ -52,7 +52,7 @@
52
52
  "@types/node": "^26.0.0",
53
53
  "@vitest/coverage-v8": "^4.1.2",
54
54
  "esbuild": "^0.28.0",
55
- "typescript": "^6.0.2",
55
+ "typescript": "^7.0.2",
56
56
  "vitest": "^4.1.2"
57
57
  }
58
58
  }
package/server.json CHANGED
@@ -6,12 +6,12 @@
6
6
  "url": "https://github.com/chrischall/splitwise-mcp",
7
7
  "source": "github"
8
8
  },
9
- "version": "2.0.10",
9
+ "version": "2.0.12",
10
10
  "packages": [
11
11
  {
12
12
  "registryType": "npm",
13
13
  "identifier": "splitwise-mcp",
14
- "version": "2.0.10",
14
+ "version": "2.0.12",
15
15
  "transport": {
16
16
  "type": "stdio"
17
17
  },
File without changes