splitwise-mcp 3.1.1 → 3.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/client.js CHANGED
@@ -59,6 +59,37 @@ function redactAssetQuery(err, search) {
59
59
  return err;
60
60
  return err instanceof ApiError ? new ApiError(err.status, message) : new Error(message);
61
61
  }
62
+ /** Flatten Splitwise's `errors` value (`{field: [msg]}`, `[msg]` or a string) into messages. */
63
+ function flattenErrors(errors) {
64
+ if (errors == null)
65
+ return [];
66
+ if (typeof errors === 'string')
67
+ return errors ? [errors] : [];
68
+ if (Array.isArray(errors))
69
+ return errors.flatMap((e) => flattenErrors(e));
70
+ if (typeof errors === 'object') {
71
+ return Object.entries(errors).flatMap(([field, value]) => flattenErrors(value).map((msg) => (field === 'base' ? msg : `${field}: ${msg}`)));
72
+ }
73
+ return [String(errors)];
74
+ }
75
+ /**
76
+ * Splitwise answers a failed write with HTTP 200, not an error status:
77
+ * create/update return `{expenses: [], errors: {...}}` and delete/undelete
78
+ * return `{success: false, errors: {...}}`. `fetchJson` only throws on
79
+ * non-2xx, so without this check a rejected write reaches the tool as an
80
+ * ordinary result and reads as "created"/"deleted". Throwing here makes every
81
+ * write tool report it with `isError`.
82
+ */
83
+ function assertWriteSucceeded(data) {
84
+ if (data === null || typeof data !== 'object' || Array.isArray(data))
85
+ return;
86
+ const record = data;
87
+ const messages = flattenErrors(record.errors);
88
+ if (messages.length === 0 && record.success !== false)
89
+ return;
90
+ const detail = messages.length > 0 ? messages.join('; ') : 'success: false with no error detail';
91
+ throw new Error(`Splitwise rejected the request: ${detail}`);
92
+ }
62
93
  export class SplitwiseClient {
63
94
  apiKey;
64
95
  /** Which source supplied the API key — a LABEL, never the value. */
@@ -138,7 +169,10 @@ export class SplitwiseClient {
138
169
  return this.apiKey;
139
170
  }
140
171
  async request(method, path, body) {
141
- return this.api.fetchJson(method, path, body !== undefined ? { body } : {});
172
+ const data = await this.api.fetchJson(method, path, body !== undefined ? { body } : {});
173
+ if (method.toUpperCase() !== 'GET')
174
+ assertWriteSucceeded(data);
175
+ return data;
142
176
  }
143
177
  /**
144
178
  * Fetch the raw bytes behind an absolute asset URL the API handed back (an
package/dist/index.js CHANGED
@@ -19,7 +19,7 @@ import { registerUtilityTools } from './tools/utilities.js';
19
19
  // client into the same registrars instead of this singleton.
20
20
  await runMcp({
21
21
  name: 'splitwise-mcp',
22
- version: '3.1.1', // x-release-please-version
22
+ version: '3.2.0', // x-release-please-version
23
23
  deps: client,
24
24
  banner: '[splitwise-mcp] This project was developed and is maintained by AI (Claude Sonnet 4.6). Use at your own discretion.',
25
25
  tools: [
@@ -1,21 +1,31 @@
1
- import { schemaConfirm, minifiedResult } from '@chrischall/mcp-utils';
2
- export { schemaConfirm };
1
+ import { confirmationFromEnv, confirmTokenParam, requireConfirmationWithFallback, } from '@chrischall/mcp-utils';
2
+ export { confirmTokenParam };
3
+ /** The sentence every gated tool's description ends with. */
4
+ export const CONFIRM_NOTE = 'Asks the user to confirm first: a confirmation prompt where the client supports one; otherwise the first call returns a preview and a confirmToken, and only a repeat call with that token proceeds (see MCP_CONFIRM_MODE).';
3
5
  /**
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.
6
+ * Confirm-gate for a mutating tool. Splitwise writes notify other people
7
+ * (friends / group members), so a single hallucinated call shouldn't fire
8
+ * silently.
9
+ *
10
+ * A client that can show a prompt is asked; one that cannot gets the two-step
11
+ * token flow (`MCP_CONFIRM_MODE`): the first call does nothing and returns the
12
+ * preview below plus a confirmToken bound to exactly this request, and only a
13
+ * repeat call with that token proceeds. `undefined` means proceed; anything
14
+ * else is the result to return unchanged.
9
15
  */
10
- export function previewUnlessConfirmed(confirm, action, method, path, body) {
11
- if (confirm === true)
12
- return null;
13
- return minifiedResult({
14
- dryRun: true,
15
- action,
16
- method,
17
- path,
18
- ...(body !== undefined ? { willSend: body } : {}),
19
- note: 'Re-run with confirm: true to execute.',
20
- });
16
+ export function confirmWrite(ctx, gate) {
17
+ const request = {
18
+ method: gate.method,
19
+ path: gate.path,
20
+ ...(gate.body !== undefined ? { willSend: gate.body } : {}),
21
+ };
22
+ const preview = { action: gate.summary, ...request };
23
+ return requireConfirmationWithFallback(ctx, confirmationFromEnv({
24
+ action: gate.action,
25
+ message: `Review and confirm: ${gate.summary}`,
26
+ details: preview,
27
+ tool: gate.tool,
28
+ confirmToken: gate.confirmToken,
29
+ subject: () => ({ target: String(gate.target), payload: request, preview }),
30
+ }));
21
31
  }
@@ -1,7 +1,7 @@
1
1
  import { z } from 'zod';
2
2
  import { SW_VIEWS, viewExpense, viewExpenses } from '../project.js';
3
3
  import { buildQueryString, minifiedResult, resolveView, viewParam } from '@chrischall/mcp-utils';
4
- import { previewUnlessConfirmed, schemaConfirm } from './_confirm.js';
4
+ import { CONFIRM_NOTE, confirmTokenParam, confirmWrite } from './_confirm.js';
5
5
  /** Flattens a users array into Splitwise's flat-param JSON format. */
6
6
  export function flattenUsers(users) {
7
7
  const flat = {};
@@ -13,8 +13,8 @@ export function flattenUsers(users) {
13
13
  return flat;
14
14
  }
15
15
  function buildExpenseBody(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
+ // `confirmToken` is a tool-gate input, not part of the Splitwise expense payload.
17
+ const { split_equally, users, expense_id: _id, confirmToken: _confirmToken, ...rest } = args;
18
18
  if (split_equally && users) {
19
19
  throw new Error('Provide either split_equally or users, not both');
20
20
  }
@@ -83,7 +83,7 @@ export function registerExpenseTools(server, client) {
83
83
  return minifiedResult(viewExpense(resolveView(view, SW_VIEWS), data));
84
84
  });
85
85
  server.registerTool('sw_create_expense', {
86
- 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.',
86
+ 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. ${CONFIRM_NOTE}`,
87
87
  inputSchema: z.object({
88
88
  group_id: z.number().describe('Group to add expense to (use 0 for no group)'),
89
89
  description: z.string().describe('Short description of the expense'),
@@ -103,18 +103,27 @@ export function registerExpenseTools(server, client) {
103
103
  date: z.string().describe('ISO 8601 datetime').optional(),
104
104
  category_id: z.number().describe('Category id from sw_get_categories').optional(),
105
105
  details: z.string().describe('Notes').optional(),
106
- confirm: schemaConfirm,
106
+ confirmToken: confirmTokenParam,
107
107
  }),
108
- }, async (args) => {
108
+ }, async (args, ctx) => {
109
109
  const body = buildExpenseBody(args);
110
- const gate = previewUnlessConfirmed(args.confirm, `Create a Splitwise expense "${args.description}" (${args.cost}) — notifies group members`, 'POST', '/create_expense', body);
110
+ const gate = await confirmWrite(ctx, {
111
+ tool: 'sw_create_expense',
112
+ action: 'expense.create',
113
+ summary: `Create a Splitwise expense "${args.description}" (${args.cost}) — notifies group members`,
114
+ method: 'POST',
115
+ path: '/create_expense',
116
+ body,
117
+ target: args.group_id,
118
+ confirmToken: args.confirmToken,
119
+ });
111
120
  if (gate)
112
121
  return gate;
113
122
  const data = await client.request('POST', '/create_expense', body);
114
123
  return minifiedResult(data);
115
124
  });
116
125
  server.registerTool('sw_update_expense', {
117
- 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.',
126
+ 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). ${CONFIRM_NOTE}`,
118
127
  inputSchema: z.object({
119
128
  expense_id: z.number().describe('ID of the expense to update'),
120
129
  description: z.string().optional(),
@@ -128,26 +137,43 @@ export function registerExpenseTools(server, client) {
128
137
  date: z.string().optional(),
129
138
  category_id: z.number().optional(),
130
139
  details: z.string().optional(),
131
- confirm: schemaConfirm,
140
+ confirmToken: confirmTokenParam,
132
141
  }),
133
- }, async (args) => {
142
+ }, async (args, ctx) => {
134
143
  const { expense_id } = args;
135
144
  const body = buildExpenseBody(args);
136
- const gate = previewUnlessConfirmed(args.confirm, `Update Splitwise expense ${expense_id} — notifies group members`, 'POST', `/update_expense/${expense_id}`, body);
145
+ const gate = await confirmWrite(ctx, {
146
+ tool: 'sw_update_expense',
147
+ action: 'expense.update',
148
+ summary: `Update Splitwise expense ${expense_id} — notifies group members`,
149
+ method: 'POST',
150
+ path: `/update_expense/${expense_id}`,
151
+ body,
152
+ target: expense_id,
153
+ confirmToken: args.confirmToken,
154
+ });
137
155
  if (gate)
138
156
  return gate;
139
157
  const data = await client.request('POST', `/update_expense/${expense_id}`, body);
140
158
  return minifiedResult(data);
141
159
  });
142
160
  server.registerTool('sw_delete_expense', {
143
- 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.',
161
+ description: `Soft-delete a Splitwise expense by id. Returns {success: true} on success. Use sw_undelete_expense to restore. ${CONFIRM_NOTE}`,
144
162
  annotations: { destructiveHint: true },
145
163
  inputSchema: z.object({
146
164
  id: z.number().describe('Expense ID to delete'),
147
- confirm: schemaConfirm,
165
+ confirmToken: confirmTokenParam,
148
166
  }),
149
- }, async ({ id, confirm }) => {
150
- const gate = previewUnlessConfirmed(confirm, `Soft-delete Splitwise expense ${id}`, 'POST', `/delete_expense/${id}`);
167
+ }, async ({ id, confirmToken }, ctx) => {
168
+ const gate = await confirmWrite(ctx, {
169
+ tool: 'sw_delete_expense',
170
+ action: 'expense.delete',
171
+ summary: `Soft-delete Splitwise expense ${id}`,
172
+ method: 'POST',
173
+ path: `/delete_expense/${id}`,
174
+ target: id,
175
+ confirmToken,
176
+ });
151
177
  if (gate)
152
178
  return gate;
153
179
  const data = await client.request('POST', `/delete_expense/${id}`);
@@ -1,7 +1,7 @@
1
1
  import { z } from 'zod';
2
2
  import { PERSON_VIEW_NOTE, SW_VIEWS, viewFriends } from '../project.js';
3
3
  import { minifiedResult, pruneUndefined, resolveView, viewParam } from '@chrischall/mcp-utils';
4
- import { previewUnlessConfirmed, schemaConfirm } from './_confirm.js';
4
+ import { CONFIRM_NOTE, confirmTokenParam, confirmWrite } from './_confirm.js';
5
5
  export function registerFriendTools(server, client) {
6
6
  server.registerTool('sw_list_friends', {
7
7
  description: "List all Splitwise friends. Use this to resolve a friend's name to a user_id before adding them to a group or building a custom expense split. The compact default returns id, name (first_name + last_name joined), email, registration_status and any non-empty balance per friend; pass view:'full' for Splitwise's raw records, which keep first_name and last_name separate.",
@@ -14,31 +14,48 @@ export function registerFriendTools(server, client) {
14
14
  return minifiedResult(viewFriends(resolveView(view, SW_VIEWS), data));
15
15
  });
16
16
  server.registerTool('sw_create_friend', {
17
- 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.',
17
+ description: `Add a Splitwise friend by email (sends them an invite). ${CONFIRM_NOTE}`,
18
18
  annotations: { destructiveHint: true },
19
19
  inputSchema: z.object({
20
20
  user_email: z.string().describe('Email of the user to add as a friend'),
21
21
  user_first_name: z.string().describe('First name of the user').optional(),
22
22
  user_last_name: z.string().describe('Last name of the user').optional(),
23
- confirm: schemaConfirm,
23
+ confirmToken: confirmTokenParam,
24
24
  }),
25
- }, async ({ user_email, user_first_name, user_last_name, confirm }) => {
25
+ }, async ({ user_email, user_first_name, user_last_name, confirmToken }, ctx) => {
26
26
  const body = pruneUndefined({ user_email, user_first_name, user_last_name });
27
- const gate = previewUnlessConfirmed(confirm, `Add ${user_email} as a Splitwise friend`, 'POST', '/create_friend', body);
27
+ const gate = await confirmWrite(ctx, {
28
+ tool: 'sw_create_friend',
29
+ action: 'friend.create',
30
+ summary: `Add ${user_email} as a Splitwise friend`,
31
+ method: 'POST',
32
+ path: '/create_friend',
33
+ body,
34
+ target: user_email,
35
+ confirmToken,
36
+ });
28
37
  if (gate)
29
38
  return gate;
30
39
  const data = await client.request('POST', '/create_friend', body);
31
40
  return minifiedResult(data);
32
41
  });
33
42
  server.registerTool('sw_delete_friend', {
34
- 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.',
43
+ description: `Remove a Splitwise friendship by user id. ${CONFIRM_NOTE}`,
35
44
  annotations: { destructiveHint: true },
36
45
  inputSchema: z.object({
37
46
  id: z.number().describe('User ID of the friend to remove'),
38
- confirm: schemaConfirm,
47
+ confirmToken: confirmTokenParam,
39
48
  }),
40
- }, async ({ id, confirm }) => {
41
- const gate = previewUnlessConfirmed(confirm, `Remove Splitwise friendship with user ${id}`, 'POST', `/delete_friend/${id}`);
49
+ }, async ({ id, confirmToken }, ctx) => {
50
+ const gate = await confirmWrite(ctx, {
51
+ tool: 'sw_delete_friend',
52
+ action: 'friend.delete',
53
+ summary: `Remove Splitwise friendship with user ${id}`,
54
+ method: 'POST',
55
+ path: `/delete_friend/${id}`,
56
+ target: id,
57
+ confirmToken,
58
+ });
42
59
  if (gate)
43
60
  return gate;
44
61
  const data = await client.request('POST', `/delete_friend/${id}`);
@@ -1,7 +1,7 @@
1
1
  import { z } from 'zod';
2
2
  import { SW_VIEWS, viewGroup, viewGroups } from '../project.js';
3
3
  import { minifiedResult, pruneUndefined, resolveView, viewParam } from '@chrischall/mcp-utils';
4
- import { previewUnlessConfirmed, schemaConfirm } from './_confirm.js';
4
+ import { CONFIRM_NOTE, confirmTokenParam, confirmWrite } from './_confirm.js';
5
5
  export function registerGroupTools(server, client) {
6
6
  server.registerTool('sw_list_groups', {
7
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.',
@@ -29,7 +29,7 @@ export function registerGroupTools(server, client) {
29
29
  return minifiedResult(viewGroup(resolveView(view, SW_VIEWS), data));
30
30
  });
31
31
  server.registerTool('sw_create_group', {
32
- description: 'Create a new Splitwise group.',
32
+ description: `Create a new Splitwise group. ${CONFIRM_NOTE}`,
33
33
  annotations: { destructiveHint: true },
34
34
  inputSchema: z.object({
35
35
  name: z.string().describe('Group name'),
@@ -41,27 +41,36 @@ export function registerGroupTools(server, client) {
41
41
  .boolean()
42
42
  .describe('Whether to simplify debts by default')
43
43
  .optional(),
44
- confirm: schemaConfirm,
44
+ confirmToken: confirmTokenParam,
45
45
  }),
46
- }, async ({ name, group_type, simplify_by_default, confirm }) => {
46
+ }, async ({ name, group_type, simplify_by_default, confirmToken }, ctx) => {
47
47
  const body = pruneUndefined({ name, group_type, simplify_by_default });
48
- const gate = previewUnlessConfirmed(confirm, `Create Splitwise group "${name}"`, 'POST', '/create_group', body);
48
+ const gate = await confirmWrite(ctx, {
49
+ tool: 'sw_create_group',
50
+ action: 'group.create',
51
+ summary: `Create Splitwise group "${name}"`,
52
+ method: 'POST',
53
+ path: '/create_group',
54
+ body,
55
+ target: '',
56
+ confirmToken,
57
+ });
49
58
  if (gate)
50
59
  return gate;
51
60
  const data = await client.request('POST', '/create_group', body);
52
61
  return minifiedResult(data);
53
62
  });
54
63
  server.registerTool('sw_add_user_to_group', {
55
- description: 'Add a user to a Splitwise group. Provide user_id (preferred, use sw_list_friends to resolve a name) or first_name + last_name + email to invite by email.',
64
+ description: `Add a user to a Splitwise group. Provide user_id (preferred, use sw_list_friends to resolve a name) or first_name + last_name + email to invite by email. ${CONFIRM_NOTE}`,
56
65
  inputSchema: z.object({
57
66
  group_id: z.number().describe('Group ID'),
58
67
  user_id: z.number().describe('User ID (preferred)').optional(),
59
68
  first_name: z.string().optional(),
60
69
  last_name: z.string().optional(),
61
70
  email: z.string().optional(),
62
- confirm: schemaConfirm,
71
+ confirmToken: confirmTokenParam,
63
72
  }),
64
- }, async ({ group_id, user_id, first_name, last_name, email, confirm }) => {
73
+ }, async ({ group_id, user_id, first_name, last_name, email, confirmToken }, ctx) => {
65
74
  let body;
66
75
  if (user_id !== undefined) {
67
76
  body = { group_id, user_id };
@@ -72,36 +81,62 @@ export function registerGroupTools(server, client) {
72
81
  }
73
82
  body = { group_id, first_name, last_name, email };
74
83
  }
75
- const gate = previewUnlessConfirmed(confirm, `Add a user to Splitwise group ${group_id} (may send an invite email)`, 'POST', '/add_user_to_group', body);
84
+ const gate = await confirmWrite(ctx, {
85
+ tool: 'sw_add_user_to_group',
86
+ action: 'group.add_user',
87
+ summary: `Add a user to Splitwise group ${group_id} (may send an invite email)`,
88
+ method: 'POST',
89
+ path: '/add_user_to_group',
90
+ body,
91
+ target: group_id,
92
+ confirmToken,
93
+ });
76
94
  if (gate)
77
95
  return gate;
78
96
  const data = await client.request('POST', '/add_user_to_group', body);
79
97
  return minifiedResult(data);
80
98
  });
81
99
  server.registerTool('sw_remove_user_from_group', {
82
- 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.',
100
+ description: `Remove a user from a Splitwise group. ${CONFIRM_NOTE}`,
83
101
  annotations: { destructiveHint: true },
84
102
  inputSchema: z.object({
85
103
  group_id: z.number().describe('Group ID'),
86
104
  user_id: z.number().describe('User ID to remove'),
87
- confirm: schemaConfirm,
105
+ confirmToken: confirmTokenParam,
88
106
  }),
89
- }, async ({ group_id, user_id, confirm }) => {
90
- const gate = previewUnlessConfirmed(confirm, `Remove user ${user_id} from Splitwise group ${group_id}`, 'POST', '/remove_user_from_group', { group_id, user_id });
107
+ }, async ({ group_id, user_id, confirmToken }, ctx) => {
108
+ const gate = await confirmWrite(ctx, {
109
+ tool: 'sw_remove_user_from_group',
110
+ action: 'group.remove_user',
111
+ summary: `Remove user ${user_id} from Splitwise group ${group_id}`,
112
+ method: 'POST',
113
+ path: '/remove_user_from_group',
114
+ body: { group_id, user_id },
115
+ target: group_id,
116
+ confirmToken,
117
+ });
91
118
  if (gate)
92
119
  return gate;
93
120
  const data = await client.request('POST', '/remove_user_from_group', { group_id, user_id });
94
121
  return minifiedResult(data);
95
122
  });
96
123
  server.registerTool('sw_delete_group', {
97
- 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.',
124
+ description: `Soft-delete a Splitwise group. ${CONFIRM_NOTE}`,
98
125
  annotations: { destructiveHint: true },
99
126
  inputSchema: z.object({
100
127
  id: z.number().describe('Group ID to delete'),
101
- confirm: schemaConfirm,
128
+ confirmToken: confirmTokenParam,
102
129
  }),
103
- }, async ({ id, confirm }) => {
104
- const gate = previewUnlessConfirmed(confirm, `Soft-delete Splitwise group ${id}`, 'POST', `/delete_group/${id}`);
130
+ }, async ({ id, confirmToken }, ctx) => {
131
+ const gate = await confirmWrite(ctx, {
132
+ tool: 'sw_delete_group',
133
+ action: 'group.delete',
134
+ summary: `Soft-delete Splitwise group ${id}`,
135
+ method: 'POST',
136
+ path: `/delete_group/${id}`,
137
+ target: id,
138
+ confirmToken,
139
+ });
105
140
  if (gate)
106
141
  return gate;
107
142
  const data = await client.request('POST', `/delete_group/${id}`);
@@ -1,7 +1,7 @@
1
1
  import { z } from 'zod';
2
2
  import { PERSON_VIEW_NOTE, SW_VIEWS, viewUser } from '../project.js';
3
3
  import { minifiedResult, pruneUndefined, resolveView, viewParam } from '@chrischall/mcp-utils';
4
- import { previewUnlessConfirmed, schemaConfirm } from './_confirm.js';
4
+ import { CONFIRM_NOTE, confirmTokenParam, confirmWrite } from './_confirm.js';
5
5
  export function registerUserTools(server, client) {
6
6
  server.registerTool('sw_get_current_user', {
7
7
  description: "Get the authenticated Splitwise user's profile. Use the returned id when building custom expense splits. The compact default returns id, name (first_name + last_name joined), email and registration_status; pass view:'full' for Splitwise's raw record, which keeps first_name and last_name separate — the form sw_update_user takes.",
@@ -25,29 +25,39 @@ export function registerUserTools(server, client) {
25
25
  return minifiedResult(viewUser(resolveView(view, SW_VIEWS), data));
26
26
  });
27
27
  server.registerTool('sw_update_user', {
28
- description: "Update the current user's profile fields. id must be the current user's id.",
28
+ description: "Update the current user's profile fields: name, locale and default currency. id must be the current user's id. The login email and password are deliberately not settable here — account credentials are changed in the Splitwise app, not by an assistant. " + CONFIRM_NOTE,
29
+ annotations: { readOnlyHint: false, destructiveHint: true },
30
+ // No `email` / `password`: changing the login email is an account
31
+ // takeover primitive (a password reset to the new address follows), and
32
+ // the only gate would be a model-driven confirmation that injected text from
33
+ // comments, notifications or receipts could talk it into (fleet-audit
34
+ // #250). z.object strips unknown keys, so a caller passing them anyway
35
+ // has them dropped before the request is built.
29
36
  inputSchema: z.object({
30
37
  id: z.number().describe("User ID (must be the current user's id)"),
31
38
  first_name: z.string().optional(),
32
39
  last_name: z.string().optional(),
33
- email: z.string().optional(),
34
- password: z.string().optional(),
35
40
  locale: z.string().optional(),
36
41
  default_currency: z.string().optional(),
37
- confirm: schemaConfirm,
42
+ confirmToken: confirmTokenParam,
38
43
  }),
39
- }, async ({ id, first_name, last_name, email, password, locale, default_currency, confirm }) => {
44
+ }, async ({ id, first_name, last_name, locale, default_currency, confirmToken }, ctx) => {
40
45
  const body = pruneUndefined({
41
46
  first_name,
42
47
  last_name,
43
- email,
44
- password,
45
48
  locale,
46
49
  default_currency,
47
50
  });
48
- // Never echo the password in the dry-run preview.
49
- const previewBody = { ...body, ...(password !== undefined ? { password: '[hidden]' } : {}) };
50
- const gate = previewUnlessConfirmed(confirm, `Update current Splitwise user ${id} profile`, 'POST', `/update_user/${id}`, previewBody);
51
+ const gate = await confirmWrite(ctx, {
52
+ tool: 'sw_update_user',
53
+ action: 'user.update',
54
+ summary: `Update current Splitwise user ${id} profile`,
55
+ method: 'POST',
56
+ path: `/update_user/${id}`,
57
+ body,
58
+ target: id,
59
+ confirmToken,
60
+ });
51
61
  if (gate)
52
62
  return gate;
53
63
  const data = await client.request('POST', `/update_user/${id}`, body);
@@ -1,7 +1,7 @@
1
1
  import { z } from 'zod';
2
2
  import { SW_VIEWS, viewGeneric } from '../project.js';
3
3
  import { buildQueryString, minifiedResult, resolveView, viewParam } from '@chrischall/mcp-utils';
4
- import { previewUnlessConfirmed, schemaConfirm } from './_confirm.js';
4
+ import { CONFIRM_NOTE, confirmTokenParam, confirmWrite } from './_confirm.js';
5
5
  export function registerUtilityTools(server, client) {
6
6
  server.registerTool('sw_get_notifications', {
7
7
  description: 'Get recent Splitwise activity notifications for the current user.',
@@ -45,29 +45,46 @@ export function registerUtilityTools(server, client) {
45
45
  return minifiedResult(viewGeneric(resolveView(view, SW_VIEWS), data));
46
46
  });
47
47
  server.registerTool('sw_create_comment', {
48
- 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.',
48
+ description: `Add a comment to a Splitwise expense (visible to other participants). ${CONFIRM_NOTE}`,
49
49
  annotations: { destructiveHint: true },
50
50
  inputSchema: z.object({
51
51
  expense_id: z.number().describe('Expense ID to comment on'),
52
52
  content: z.string().describe('Comment text'),
53
- confirm: schemaConfirm,
53
+ confirmToken: confirmTokenParam,
54
54
  }),
55
- }, async ({ expense_id, content, confirm }) => {
56
- const gate = previewUnlessConfirmed(confirm, `Comment on Splitwise expense ${expense_id} (visible to participants)`, 'POST', '/create_comment', { expense_id, content });
55
+ }, async ({ expense_id, content, confirmToken }, ctx) => {
56
+ const gate = await confirmWrite(ctx, {
57
+ tool: 'sw_create_comment',
58
+ action: 'comment.create',
59
+ summary: `Comment on Splitwise expense ${expense_id} (visible to participants)`,
60
+ method: 'POST',
61
+ path: '/create_comment',
62
+ body: { expense_id, content },
63
+ target: expense_id,
64
+ confirmToken,
65
+ });
57
66
  if (gate)
58
67
  return gate;
59
68
  const data = await client.request('POST', '/create_comment', { expense_id, content });
60
69
  return minifiedResult(data);
61
70
  });
62
71
  server.registerTool('sw_delete_comment', {
63
- 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.',
72
+ description: `Delete a comment by id. ${CONFIRM_NOTE}`,
64
73
  annotations: { destructiveHint: true },
65
74
  inputSchema: z.object({
66
75
  id: z.number().describe('Comment ID to delete'),
67
- confirm: schemaConfirm,
76
+ confirmToken: confirmTokenParam,
68
77
  }),
69
- }, async ({ id, confirm }) => {
70
- const gate = previewUnlessConfirmed(confirm, `Delete Splitwise comment ${id}`, 'POST', `/delete_comment/${id}`);
78
+ }, async ({ id, confirmToken }, ctx) => {
79
+ const gate = await confirmWrite(ctx, {
80
+ tool: 'sw_delete_comment',
81
+ action: 'comment.delete',
82
+ summary: `Delete Splitwise comment ${id}`,
83
+ method: 'POST',
84
+ path: `/delete_comment/${id}`,
85
+ target: id,
86
+ confirmToken,
87
+ });
71
88
  if (gate)
72
89
  return gate;
73
90
  const data = await client.request('POST', `/delete_comment/${id}`);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "splitwise-mcp",
3
- "version": "3.1.1",
3
+ "version": "3.2.0",
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": "^2.4.0",
48
+ "@chrischall/mcp-utils": "^2.6.0",
49
49
  "@modelcontextprotocol/server": "^2.0.0",
50
50
  "dotenv": "^17.4.0",
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.1.1",
9
+ "version": "3.2.0",
10
10
  "packages": [
11
11
  {
12
12
  "registryType": "npm",
13
13
  "identifier": "splitwise-mcp",
14
- "version": "3.1.1",
14
+ "version": "3.2.0",
15
15
  "transport": {
16
16
  "type": "stdio"
17
17
  },
@@ -221,6 +221,7 @@ sw_update_expense(expense_id, description: "Corrected description", cost: "95.00
221
221
 
222
222
  ## Notes
223
223
 
224
+ - Every write except `sw_undelete_expense` / `sw_undelete_group` asks the user to confirm first. Where the client can show a prompt, it does. Otherwise the first call changes nothing and returns `status: "confirmation-required"` with a `preview` (method, path, `willSend`) and a `confirmToken`: show the preview to the user, get their approval in chat, then repeat the same call with the same arguments plus `confirmToken` (see `MCP_CONFIRM_MODE`). A token acts once, expires, and is refused (`DRAFT_CHANGED`) if any argument changed
224
225
  - `cost` is always a decimal string (e.g. `"25.00"`)
225
226
  - `split_equally: true` and `users` array are mutually exclusive
226
227
  - For custom split updates, the **full `users` array is required** — the API replaces the entire split