splitwise-mcp 3.1.2 → 3.2.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.claude-plugin/marketplace.json +2 -2
- package/.claude-plugin/plugin.json +1 -1
- package/README.md +12 -1
- package/dist/bundle.js +658 -176
- package/dist/index.js +1 -1
- package/dist/tools/_confirm.js +28 -18
- package/dist/tools/expenses.js +41 -15
- package/dist/tools/friends.js +26 -9
- package/dist/tools/groups.js +52 -17
- package/dist/tools/receipts.js +10 -3
- package/dist/tools/user.js +15 -6
- package/dist/tools/utilities.js +26 -9
- package/package.json +3 -3
- package/server.json +2 -2
- package/skills/splitwise-mcp/SKILL.md +1 -0
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
|
|
22
|
+
version: '3.2.1', // 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: [
|
package/dist/tools/_confirm.js
CHANGED
|
@@ -1,21 +1,31 @@
|
|
|
1
|
-
import {
|
|
2
|
-
export {
|
|
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
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
8
|
-
*
|
|
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
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
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
|
}
|
package/dist/tools/expenses.js
CHANGED
|
@@ -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 {
|
|
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
|
-
// `
|
|
17
|
-
const { split_equally, users, expense_id: _id,
|
|
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:
|
|
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
|
-
|
|
106
|
+
confirmToken: confirmTokenParam,
|
|
107
107
|
}),
|
|
108
|
-
}, async (args) => {
|
|
108
|
+
}, async (args, ctx) => {
|
|
109
109
|
const body = buildExpenseBody(args);
|
|
110
|
-
const gate =
|
|
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:
|
|
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
|
-
|
|
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 =
|
|
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:
|
|
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
|
-
|
|
165
|
+
confirmToken: confirmTokenParam,
|
|
148
166
|
}),
|
|
149
|
-
}, async ({ id,
|
|
150
|
-
const gate =
|
|
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}`);
|
package/dist/tools/friends.js
CHANGED
|
@@ -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 {
|
|
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:
|
|
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
|
-
|
|
23
|
+
confirmToken: confirmTokenParam,
|
|
24
24
|
}),
|
|
25
|
-
}, async ({ user_email, user_first_name, user_last_name,
|
|
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 =
|
|
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:
|
|
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
|
-
|
|
47
|
+
confirmToken: confirmTokenParam,
|
|
39
48
|
}),
|
|
40
|
-
}, async ({ id,
|
|
41
|
-
const gate =
|
|
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}`);
|
package/dist/tools/groups.js
CHANGED
|
@@ -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 {
|
|
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:
|
|
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
|
-
|
|
44
|
+
confirmToken: confirmTokenParam,
|
|
45
45
|
}),
|
|
46
|
-
}, async ({ name, group_type, simplify_by_default,
|
|
46
|
+
}, async ({ name, group_type, simplify_by_default, confirmToken }, ctx) => {
|
|
47
47
|
const body = pruneUndefined({ name, group_type, simplify_by_default });
|
|
48
|
-
const gate =
|
|
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:
|
|
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
|
-
|
|
71
|
+
confirmToken: confirmTokenParam,
|
|
63
72
|
}),
|
|
64
|
-
}, async ({ group_id, user_id, first_name, last_name, email,
|
|
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 =
|
|
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:
|
|
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
|
-
|
|
105
|
+
confirmToken: confirmTokenParam,
|
|
88
106
|
}),
|
|
89
|
-
}, async ({ group_id, user_id,
|
|
90
|
-
const gate =
|
|
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:
|
|
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
|
-
|
|
128
|
+
confirmToken: confirmTokenParam,
|
|
102
129
|
}),
|
|
103
|
-
}, async ({ id,
|
|
104
|
-
const gate =
|
|
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}`);
|
package/dist/tools/receipts.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { z } from 'zod';
|
|
2
|
-
import { minifiedResult, imageResult, resolveOutputDir, writeBinaryOutput, sniffMimeBytes, } from '@chrischall/mcp-utils';
|
|
2
|
+
import { minifiedResult, imageResult, readEnvVar, resolveOutputDir, writeBinaryOutput, sniffMimeBytes, } from '@chrischall/mcp-utils';
|
|
3
3
|
/** Cap on bytes returned as base64 in the tool result. */
|
|
4
4
|
const MAX_INLINE_BYTES = 4 * 1024 * 1024;
|
|
5
5
|
/** Cap on extracted characters, so a long PDF can't flood the result. */
|
|
@@ -97,7 +97,7 @@ export function registerReceiptTools(server, client) {
|
|
|
97
97
|
.optional(),
|
|
98
98
|
output_dir: z
|
|
99
99
|
.string()
|
|
100
|
-
.describe('Directory to write the receipt into. Defaults to $SPLITWISE_OUTPUT_DIR, else the current working directory.')
|
|
100
|
+
.describe('Directory to write the receipt into. Defaults to $SPLITWISE_OUTPUT_DIR, else the current working directory. When SPLITWISE_OUTPUT_DIR is set, this must be inside it.')
|
|
101
101
|
.optional(),
|
|
102
102
|
write: z
|
|
103
103
|
.boolean()
|
|
@@ -128,8 +128,15 @@ export function registerReceiptTools(server, client) {
|
|
|
128
128
|
let writeError;
|
|
129
129
|
if (write !== false) {
|
|
130
130
|
try {
|
|
131
|
+
// output_dir is model-chosen: once the operator configures
|
|
132
|
+
// SPLITWISE_OUTPUT_DIR, a per-call directory must stay inside it
|
|
133
|
+
// (an escape throws, landing in writeError below). Unset keeps the
|
|
134
|
+
// old, unconfined behaviour.
|
|
135
|
+
const configuredDir = readEnvVar('SPLITWISE_OUTPUT_DIR');
|
|
131
136
|
path = writeBinaryOutput({
|
|
132
|
-
dir: resolveOutputDir(output_dir, 'SPLITWISE_OUTPUT_DIR'
|
|
137
|
+
dir: resolveOutputDir(output_dir, 'SPLITWISE_OUTPUT_DIR', {
|
|
138
|
+
...(configuredDir ? { allowedRoots: [configuredDir] } : {}),
|
|
139
|
+
}),
|
|
133
140
|
baseName: `splitwise-receipt-${id}`,
|
|
134
141
|
base64,
|
|
135
142
|
extension: extensionFor(mimeType, url),
|
package/dist/tools/user.js
CHANGED
|
@@ -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 {
|
|
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,11 +25,11 @@ 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: 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.",
|
|
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
29
|
annotations: { readOnlyHint: false, destructiveHint: true },
|
|
30
30
|
// No `email` / `password`: changing the login email is an account
|
|
31
31
|
// takeover primitive (a password reset to the new address follows), and
|
|
32
|
-
// the only gate would be a model-
|
|
32
|
+
// the only gate would be a model-driven confirmation that injected text from
|
|
33
33
|
// comments, notifications or receipts could talk it into (fleet-audit
|
|
34
34
|
// #250). z.object strips unknown keys, so a caller passing them anyway
|
|
35
35
|
// has them dropped before the request is built.
|
|
@@ -39,16 +39,25 @@ export function registerUserTools(server, client) {
|
|
|
39
39
|
last_name: z.string().optional(),
|
|
40
40
|
locale: z.string().optional(),
|
|
41
41
|
default_currency: z.string().optional(),
|
|
42
|
-
|
|
42
|
+
confirmToken: confirmTokenParam,
|
|
43
43
|
}),
|
|
44
|
-
}, async ({ id, first_name, last_name, locale, default_currency,
|
|
44
|
+
}, async ({ id, first_name, last_name, locale, default_currency, confirmToken }, ctx) => {
|
|
45
45
|
const body = pruneUndefined({
|
|
46
46
|
first_name,
|
|
47
47
|
last_name,
|
|
48
48
|
locale,
|
|
49
49
|
default_currency,
|
|
50
50
|
});
|
|
51
|
-
const gate =
|
|
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
|
+
});
|
|
52
61
|
if (gate)
|
|
53
62
|
return gate;
|
|
54
63
|
const data = await client.request('POST', `/update_user/${id}`, body);
|
package/dist/tools/utilities.js
CHANGED
|
@@ -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 {
|
|
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:
|
|
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
|
-
|
|
53
|
+
confirmToken: confirmTokenParam,
|
|
54
54
|
}),
|
|
55
|
-
}, async ({ expense_id, content,
|
|
56
|
-
const gate =
|
|
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:
|
|
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
|
-
|
|
76
|
+
confirmToken: confirmTokenParam,
|
|
68
77
|
}),
|
|
69
|
-
}, async ({ id,
|
|
70
|
-
const gate =
|
|
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
|
|
3
|
+
"version": "3.2.1",
|
|
4
4
|
"mcpName": "io.github.chrischall/splitwise-mcp",
|
|
5
5
|
"description": "Splitwise MCP server for Claude — developed and maintained by AI (Claude Code)",
|
|
6
6
|
"author": "Claude Code (AI) <https://www.anthropic.com/claude>",
|
|
@@ -45,9 +45,9 @@
|
|
|
45
45
|
"typecheck": "tsc -p tsconfig.json --noEmit"
|
|
46
46
|
},
|
|
47
47
|
"dependencies": {
|
|
48
|
-
"@chrischall/mcp-utils": "^2.
|
|
48
|
+
"@chrischall/mcp-utils": "^2.6.0",
|
|
49
49
|
"@modelcontextprotocol/server": "^2.0.0",
|
|
50
|
-
"dotenv": "^
|
|
50
|
+
"dotenv": "^18.0.2",
|
|
51
51
|
"unpdf": "^1.8.1",
|
|
52
52
|
"zod": "^4.6.5"
|
|
53
53
|
},
|
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
|
|
9
|
+
"version": "3.2.1",
|
|
10
10
|
"packages": [
|
|
11
11
|
{
|
|
12
12
|
"registryType": "npm",
|
|
13
13
|
"identifier": "splitwise-mcp",
|
|
14
|
-
"version": "3.1
|
|
14
|
+
"version": "3.2.1",
|
|
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
|