splitwise-mcp 2.3.0 → 2.4.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/.claude-plugin/marketplace.json +2 -2
- package/.claude-plugin/plugin.json +1 -1
- package/dist/bundle.js +742 -223
- package/dist/index.js +1 -1
- package/dist/project.js +184 -0
- package/dist/tools/_confirm.js +2 -2
- package/dist/tools/expenses.js +11 -8
- package/dist/tools/friends.js +10 -6
- package/dist/tools/groups.js +15 -10
- package/dist/tools/receipts.js +2 -2
- package/dist/tools/user.js +13 -8
- package/dist/tools/utilities.js +14 -9
- package/package.json +2 -2
- package/server.json +2 -2
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: '2.
|
|
22
|
+
version: '2.4.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: [
|
package/dist/project.js
ADDED
|
@@ -0,0 +1,184 @@
|
|
|
1
|
+
import { projectOrRaw, pruneUndefined, stripMediaUrls } from '@chrischall/mcp-utils';
|
|
2
|
+
/**
|
|
3
|
+
* The compact projection (`@chrischall/mcp-utils`' `view` vocabulary;
|
|
4
|
+
* `chrischall/workflows` `docs/fleet-conventions.md`, "Response shape").
|
|
5
|
+
*
|
|
6
|
+
* `sw_list_groups` did not merely return a large response — it returned one
|
|
7
|
+
* that DOES NOT FIT. A live call on a 51-group account came back as 192,123
|
|
8
|
+
* characters and was refused by the host before the model saw a byte of it:
|
|
9
|
+
* the tool was unusable, not merely expensive.
|
|
10
|
+
*
|
|
11
|
+
* Where the weight was, measured on that payload:
|
|
12
|
+
*
|
|
13
|
+
* - `avatar` + `tall_avatar` + `cover_photo` — 51.7 KB, 27%
|
|
14
|
+
* - a `picture` object per member, across 51 groups — 37.7 KB, 20%
|
|
15
|
+
*
|
|
16
|
+
* 60% of the whole response was image URLs a model cannot see, cannot fetch,
|
|
17
|
+
* and would not benefit from if it could. `stripMediaUrls` alone takes the
|
|
18
|
+
* payload to 51.4 KB (−73%); the field projections below take it to 29.3 KB.
|
|
19
|
+
*
|
|
20
|
+
* `full` returns Splitwise's records untouched. There is no `raw` rung: `full`
|
|
21
|
+
* already is the upstream payload.
|
|
22
|
+
*/
|
|
23
|
+
export const SW_VIEWS = ['compact', 'full'];
|
|
24
|
+
const LABEL = 'splitwise-mcp';
|
|
25
|
+
function asDict(v) {
|
|
26
|
+
return v !== null && typeof v === 'object' && !Array.isArray(v) ? v : undefined;
|
|
27
|
+
}
|
|
28
|
+
function asList(v) {
|
|
29
|
+
return Array.isArray(v) ? v : [];
|
|
30
|
+
}
|
|
31
|
+
/** "Alison Hall" from the first/last pair Splitwise returns on every user shape. */
|
|
32
|
+
function nameOf(user) {
|
|
33
|
+
if (!user)
|
|
34
|
+
return undefined;
|
|
35
|
+
const parts = [user.first_name, user.last_name].filter((p) => typeof p === 'string' && p !== '');
|
|
36
|
+
return parts.length > 0 ? parts.join(' ') : undefined;
|
|
37
|
+
}
|
|
38
|
+
/**
|
|
39
|
+
* What `compact` does to a person, in the words the caller sees.
|
|
40
|
+
*
|
|
41
|
+
* It lives here rather than at the three call sites because it is a
|
|
42
|
+
* description of `compactPerson` below — and the note it replaced described
|
|
43
|
+
* something else entirely: "compact drops the avatar URLs" is `viewGeneric`'s
|
|
44
|
+
* behaviour (`sw_get_notifications`, `sw_get_comments`), not this projection's.
|
|
45
|
+
* This one keeps five fields and MERGES first_name + last_name into `name`,
|
|
46
|
+
* which is a good deal more than dropping an avatar and is the part a caller
|
|
47
|
+
* has to know before it goes looking for `last_name`.
|
|
48
|
+
*/
|
|
49
|
+
export const PERSON_VIEW_NOTE = 'compact returns {id, name, email, registration_status, balance} per person — `name` is first_name + last_name joined, so the separate fields are on "full" only; "full" returns Splitwise\'s whole record.';
|
|
50
|
+
/**
|
|
51
|
+
* One member or friend. `balance` survives — it is the whole reason to look a
|
|
52
|
+
* person up — and so does `registration_status`, which is how a caller knows
|
|
53
|
+
* an invite has not been accepted.
|
|
54
|
+
*/
|
|
55
|
+
export function compactPerson(raw) {
|
|
56
|
+
const u = asDict(raw) ?? {};
|
|
57
|
+
// `pruneUndefined`, so an optional field is ABSENT rather than
|
|
58
|
+
// present-and-undefined: "not reported" and "none" have to stay different
|
|
59
|
+
// facts at every layer, not only after serialisation.
|
|
60
|
+
return pruneUndefined({
|
|
61
|
+
id: u.id,
|
|
62
|
+
name: nameOf(u),
|
|
63
|
+
email: u.email,
|
|
64
|
+
registration_status: u.registration_status,
|
|
65
|
+
balance: asList(u.balance).length > 0 ? u.balance : undefined,
|
|
66
|
+
});
|
|
67
|
+
}
|
|
68
|
+
/**
|
|
69
|
+
* One group.
|
|
70
|
+
*
|
|
71
|
+
* `whiteboard`, `whiteboard_lock_version`, `whiteboard_updated_at` and
|
|
72
|
+
* `whiteboard_updated_by` go: the whiteboard is a UI feature with no tool that
|
|
73
|
+
* reads it. `simplify_by_default`, `custom_avatar` and `group_reminders` go as
|
|
74
|
+
* settings nothing here acts on. `original_debts` goes because
|
|
75
|
+
* `simplified_debts` answers "who owes whom" and the pair differ only when
|
|
76
|
+
* simplification is on — `full` has both.
|
|
77
|
+
*/
|
|
78
|
+
export function compactGroup(raw) {
|
|
79
|
+
const g = asDict(raw) ?? {};
|
|
80
|
+
return pruneUndefined({
|
|
81
|
+
id: g.id,
|
|
82
|
+
name: g.name,
|
|
83
|
+
group_type: g.group_type,
|
|
84
|
+
updated_at: g.updated_at,
|
|
85
|
+
members: asList(g.members).map(compactPerson),
|
|
86
|
+
simplified_debts: asList(g.simplified_debts).length > 0 ? g.simplified_debts : undefined,
|
|
87
|
+
invite_link: g.invite_link,
|
|
88
|
+
});
|
|
89
|
+
}
|
|
90
|
+
/**
|
|
91
|
+
* One expense.
|
|
92
|
+
*
|
|
93
|
+
* The share breakdown is the point of an expense, so `users` keeps every
|
|
94
|
+
* share — but as `{id, name, paid_share, owed_share, net_balance}` rather than
|
|
95
|
+
* Splitwise's nested `user` object, which repeats the name and an avatar the
|
|
96
|
+
* record already carried under `created_by`.
|
|
97
|
+
*
|
|
98
|
+
* `receipt` survives when there IS one: a receipt is evidence, and
|
|
99
|
+
* `sw_get_receipt` needs the id. The repeat/reminder/transaction block goes —
|
|
100
|
+
* eleven near-constant fields (`repeats`, `repeat_interval`, `email_reminder`,
|
|
101
|
+
* `email_reminder_in_advance`, `next_repeat`, `transaction_method`,
|
|
102
|
+
* `transaction_confirmed`, `transaction_id`, `transaction_status`,
|
|
103
|
+
* `expense_bundle_id`, `creation_method`) that were `false`/`null`/`"offline"`
|
|
104
|
+
* on every expense in a live account.
|
|
105
|
+
*/
|
|
106
|
+
export function compactExpense(raw) {
|
|
107
|
+
const e = asDict(raw) ?? {};
|
|
108
|
+
const receipt = asDict(e.receipt);
|
|
109
|
+
const original = receipt?.original;
|
|
110
|
+
return pruneUndefined({
|
|
111
|
+
id: e.id,
|
|
112
|
+
group_id: e.group_id,
|
|
113
|
+
description: e.description,
|
|
114
|
+
details: e.details,
|
|
115
|
+
cost: e.cost,
|
|
116
|
+
currency_code: e.currency_code,
|
|
117
|
+
date: e.date,
|
|
118
|
+
category: asDict(e.category)?.name,
|
|
119
|
+
payment: e.payment === true ? true : undefined,
|
|
120
|
+
created_by: nameOf(asDict(e.created_by)),
|
|
121
|
+
updated_at: e.updated_at,
|
|
122
|
+
// Kept only when set. A deleted expense still comes back from the list
|
|
123
|
+
// endpoint, and a caller that cannot see `deleted_at` will count it.
|
|
124
|
+
deleted_at: e.deleted_at ?? undefined,
|
|
125
|
+
comments_count: e.comments_count,
|
|
126
|
+
repayments: e.repayments,
|
|
127
|
+
users: asList(e.users).map((entry) => {
|
|
128
|
+
const u = asDict(entry) ?? {};
|
|
129
|
+
return {
|
|
130
|
+
id: u.user_id ?? asDict(u.user)?.id,
|
|
131
|
+
name: nameOf(asDict(u.user)),
|
|
132
|
+
paid_share: u.paid_share,
|
|
133
|
+
owed_share: u.owed_share,
|
|
134
|
+
net_balance: u.net_balance,
|
|
135
|
+
};
|
|
136
|
+
}),
|
|
137
|
+
// A receipt is evidence; `sw_get_receipt` is how a caller fetches it.
|
|
138
|
+
has_receipt: typeof original === 'string' ? true : undefined,
|
|
139
|
+
});
|
|
140
|
+
}
|
|
141
|
+
/**
|
|
142
|
+
* Project a payload's `<key>` array, or hand the whole thing back.
|
|
143
|
+
*
|
|
144
|
+
* Whole-array, never per record: one odd record projected to nothing among
|
|
145
|
+
* fifty good ones is a hole in the middle of an answer, and indistinguishable
|
|
146
|
+
* from an expense with no content.
|
|
147
|
+
*/
|
|
148
|
+
function projectList(view, payload, key, map, context) {
|
|
149
|
+
if (view !== 'compact')
|
|
150
|
+
return payload;
|
|
151
|
+
return projectOrRaw(payload, (p) => {
|
|
152
|
+
const box = asDict(p);
|
|
153
|
+
const items = box?.[key];
|
|
154
|
+
if (!box || !Array.isArray(items))
|
|
155
|
+
throw new Error(`expected a ${key}[] array`);
|
|
156
|
+
return { ...box, [key]: items.map(map) };
|
|
157
|
+
}, { label: LABEL, context });
|
|
158
|
+
}
|
|
159
|
+
export const viewGroups = (view, p) => projectList(view, p, 'groups', compactGroup, 'GET /get_groups');
|
|
160
|
+
export const viewFriends = (view, p) => projectList(view, p, 'friends', compactPerson, 'GET /get_friends');
|
|
161
|
+
export const viewExpenses = (view, p) => projectList(view, p, 'expenses', compactExpense, 'GET /get_expenses');
|
|
162
|
+
/** One record under `<key>` rather than an array of them. */
|
|
163
|
+
function projectOne(view, payload, key, map, context) {
|
|
164
|
+
if (view !== 'compact')
|
|
165
|
+
return payload;
|
|
166
|
+
return projectOrRaw(payload, (p) => {
|
|
167
|
+
const box = asDict(p);
|
|
168
|
+
if (!box || box[key] === undefined)
|
|
169
|
+
throw new Error(`expected a ${key} object`);
|
|
170
|
+
return { ...box, [key]: map(box[key]) };
|
|
171
|
+
}, { label: LABEL, context });
|
|
172
|
+
}
|
|
173
|
+
export const viewGroup = (view, p) => projectOne(view, p, 'group', compactGroup, 'GET /get_group/{id}');
|
|
174
|
+
export const viewExpense = (view, p) => projectOne(view, p, 'expense', compactExpense, 'GET /get_expense/{id}');
|
|
175
|
+
export const viewUser = (view, p) => projectOne(view, p, 'user', compactPerson, 'GET /get_user/{id}');
|
|
176
|
+
/**
|
|
177
|
+
* The rung for a payload with no hand-written projection: `compact` still
|
|
178
|
+
* strips the avatars, which is where most of the weight is anyway.
|
|
179
|
+
*
|
|
180
|
+
* NEVER used by `sw_get_receipt`, whose product IS the image URL.
|
|
181
|
+
*/
|
|
182
|
+
export function viewGeneric(view, payload) {
|
|
183
|
+
return view === 'compact' ? stripMediaUrls(payload) : payload;
|
|
184
|
+
}
|
package/dist/tools/_confirm.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { schemaConfirm,
|
|
1
|
+
import { schemaConfirm, minifiedResult } from '@chrischall/mcp-utils';
|
|
2
2
|
export { schemaConfirm };
|
|
3
3
|
/**
|
|
4
4
|
* Confirm-gate for a mutating tool (the fleet convention). When `confirm` is not
|
|
@@ -10,7 +10,7 @@ export { schemaConfirm };
|
|
|
10
10
|
export function previewUnlessConfirmed(confirm, action, method, path, body) {
|
|
11
11
|
if (confirm === true)
|
|
12
12
|
return null;
|
|
13
|
-
return
|
|
13
|
+
return minifiedResult({
|
|
14
14
|
dryRun: true,
|
|
15
15
|
action,
|
|
16
16
|
method,
|
package/dist/tools/expenses.js
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { z } from 'zod';
|
|
2
|
-
import {
|
|
2
|
+
import { SW_VIEWS, viewExpense, viewExpenses } from '../project.js';
|
|
3
|
+
import { buildQueryString, minifiedResult, resolveView, viewParam } from '@chrischall/mcp-utils';
|
|
3
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) {
|
|
@@ -36,6 +37,7 @@ export function registerExpenseTools(server, client) {
|
|
|
36
37
|
description: 'List or search Splitwise expenses. All filters are optional. Use group_id to filter by group, dated_after/dated_before for date ranges.',
|
|
37
38
|
annotations: { readOnlyHint: true },
|
|
38
39
|
inputSchema: {
|
|
40
|
+
view: viewParam(SW_VIEWS, { note: 'compact keeps the share breakdown, repayments and receipt presence and drops the avatars and the repeat/reminder/transaction block; "full" returns Splitwise\'s whole records.' }),
|
|
39
41
|
group_id: z.number().describe('Only expenses in this group').optional(),
|
|
40
42
|
friend_id: z.number().describe('Only expenses with this friend').optional(),
|
|
41
43
|
dated_after: z.string().describe('ISO 8601 date — only expenses on or after this date').optional(),
|
|
@@ -57,17 +59,18 @@ export function registerExpenseTools(server, client) {
|
|
|
57
59
|
offset: args.offset,
|
|
58
60
|
});
|
|
59
61
|
const data = await client.request('GET', `/get_expenses${qs}`);
|
|
60
|
-
return
|
|
62
|
+
return minifiedResult(viewExpenses(resolveView(args.view, SW_VIEWS), data));
|
|
61
63
|
});
|
|
62
64
|
server.registerTool('sw_get_expense', {
|
|
63
65
|
description: 'Get full details of a single Splitwise expense by id.',
|
|
64
66
|
annotations: { readOnlyHint: true },
|
|
65
67
|
inputSchema: {
|
|
68
|
+
view: viewParam(SW_VIEWS, { note: 'compact keeps the share breakdown, repayments and receipt presence and drops the avatars and the repeat/reminder/transaction block; "full" returns Splitwise\'s whole records.' }),
|
|
66
69
|
id: z.number().describe('Expense ID'),
|
|
67
70
|
},
|
|
68
|
-
}, async ({ id }) => {
|
|
71
|
+
}, async ({ id, view }) => {
|
|
69
72
|
const data = await client.request('GET', `/get_expense/${id}`);
|
|
70
|
-
return
|
|
73
|
+
return minifiedResult(viewExpense(resolveView(view, SW_VIEWS), data));
|
|
71
74
|
});
|
|
72
75
|
server.registerTool('sw_create_expense', {
|
|
73
76
|
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.',
|
|
@@ -89,7 +92,7 @@ export function registerExpenseTools(server, client) {
|
|
|
89
92
|
if (gate)
|
|
90
93
|
return gate;
|
|
91
94
|
const data = await client.request('POST', '/create_expense', body);
|
|
92
|
-
return
|
|
95
|
+
return minifiedResult(data);
|
|
93
96
|
});
|
|
94
97
|
server.registerTool('sw_update_expense', {
|
|
95
98
|
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.',
|
|
@@ -112,7 +115,7 @@ export function registerExpenseTools(server, client) {
|
|
|
112
115
|
if (gate)
|
|
113
116
|
return gate;
|
|
114
117
|
const data = await client.request('POST', `/update_expense/${expense_id}`, body);
|
|
115
|
-
return
|
|
118
|
+
return minifiedResult(data);
|
|
116
119
|
});
|
|
117
120
|
server.registerTool('sw_delete_expense', {
|
|
118
121
|
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.',
|
|
@@ -126,7 +129,7 @@ export function registerExpenseTools(server, client) {
|
|
|
126
129
|
if (gate)
|
|
127
130
|
return gate;
|
|
128
131
|
const data = await client.request('POST', `/delete_expense/${id}`);
|
|
129
|
-
return
|
|
132
|
+
return minifiedResult(data);
|
|
130
133
|
});
|
|
131
134
|
server.registerTool('sw_undelete_expense', {
|
|
132
135
|
description: 'Restore a soft-deleted Splitwise expense.',
|
|
@@ -135,6 +138,6 @@ export function registerExpenseTools(server, client) {
|
|
|
135
138
|
},
|
|
136
139
|
}, async ({ id }) => {
|
|
137
140
|
const data = await client.request('POST', `/undelete_expense/${id}`);
|
|
138
|
-
return
|
|
141
|
+
return minifiedResult(data);
|
|
139
142
|
});
|
|
140
143
|
}
|
package/dist/tools/friends.js
CHANGED
|
@@ -1,13 +1,17 @@
|
|
|
1
1
|
import { z } from 'zod';
|
|
2
|
-
import {
|
|
2
|
+
import { PERSON_VIEW_NOTE, SW_VIEWS, viewFriends } from '../project.js';
|
|
3
|
+
import { minifiedResult, pruneUndefined, resolveView, viewParam } from '@chrischall/mcp-utils';
|
|
3
4
|
import { previewUnlessConfirmed, schemaConfirm } from './_confirm.js';
|
|
4
5
|
export function registerFriendTools(server, client) {
|
|
5
6
|
server.registerTool('sw_list_friends', {
|
|
6
|
-
description: "List all Splitwise friends
|
|
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.",
|
|
7
8
|
annotations: { readOnlyHint: true },
|
|
8
|
-
|
|
9
|
+
inputSchema: {
|
|
10
|
+
view: viewParam(SW_VIEWS, { note: PERSON_VIEW_NOTE }),
|
|
11
|
+
},
|
|
12
|
+
}, async ({ view }) => {
|
|
9
13
|
const data = await client.request('GET', '/get_friends');
|
|
10
|
-
return
|
|
14
|
+
return minifiedResult(viewFriends(resolveView(view, SW_VIEWS), data));
|
|
11
15
|
});
|
|
12
16
|
server.registerTool('sw_create_friend', {
|
|
13
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.',
|
|
@@ -24,7 +28,7 @@ export function registerFriendTools(server, client) {
|
|
|
24
28
|
if (gate)
|
|
25
29
|
return gate;
|
|
26
30
|
const data = await client.request('POST', '/create_friend', body);
|
|
27
|
-
return
|
|
31
|
+
return minifiedResult(data);
|
|
28
32
|
});
|
|
29
33
|
server.registerTool('sw_delete_friend', {
|
|
30
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.',
|
|
@@ -38,6 +42,6 @@ export function registerFriendTools(server, client) {
|
|
|
38
42
|
if (gate)
|
|
39
43
|
return gate;
|
|
40
44
|
const data = await client.request('POST', `/delete_friend/${id}`);
|
|
41
|
-
return
|
|
45
|
+
return minifiedResult(data);
|
|
42
46
|
});
|
|
43
47
|
}
|
package/dist/tools/groups.js
CHANGED
|
@@ -1,23 +1,28 @@
|
|
|
1
1
|
import { z } from 'zod';
|
|
2
|
-
import {
|
|
2
|
+
import { SW_VIEWS, viewGroup, viewGroups } from '../project.js';
|
|
3
|
+
import { minifiedResult, pruneUndefined, resolveView, viewParam } from '@chrischall/mcp-utils';
|
|
3
4
|
import { previewUnlessConfirmed, schemaConfirm } from './_confirm.js';
|
|
4
5
|
export function registerGroupTools(server, client) {
|
|
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.',
|
|
7
8
|
annotations: { readOnlyHint: true },
|
|
8
|
-
|
|
9
|
+
inputSchema: {
|
|
10
|
+
view: viewParam(SW_VIEWS, { note: 'compact drops the avatar/cover-photo URLs (60% of a live 51-group response, which does not fit in a tool result at all) and the whiteboard/reminder settings; "full" returns Splitwise\'s whole records.' }),
|
|
11
|
+
},
|
|
12
|
+
}, async ({ view }) => {
|
|
9
13
|
const data = await client.request('GET', '/get_groups');
|
|
10
|
-
return
|
|
14
|
+
return minifiedResult(viewGroups(resolveView(view, SW_VIEWS), data));
|
|
11
15
|
});
|
|
12
16
|
server.registerTool('sw_get_group', {
|
|
13
17
|
description: 'Get details of a single Splitwise group including all members and balances.',
|
|
14
18
|
annotations: { readOnlyHint: true },
|
|
15
19
|
inputSchema: {
|
|
20
|
+
view: viewParam(SW_VIEWS, { note: 'compact drops the avatar/cover-photo URLs (60% of a live 51-group response, which does not fit in a tool result at all) and the whiteboard/reminder settings; "full" returns Splitwise\'s whole records.' }),
|
|
16
21
|
id: z.number().describe('Group ID'),
|
|
17
22
|
},
|
|
18
|
-
}, async ({ id }) => {
|
|
23
|
+
}, async ({ id, view }) => {
|
|
19
24
|
const data = await client.request('GET', `/get_group/${id}`);
|
|
20
|
-
return
|
|
25
|
+
return minifiedResult(viewGroup(resolveView(view, SW_VIEWS), data));
|
|
21
26
|
});
|
|
22
27
|
server.registerTool('sw_create_group', {
|
|
23
28
|
description: 'Create a new Splitwise group.',
|
|
@@ -34,7 +39,7 @@ export function registerGroupTools(server, client) {
|
|
|
34
39
|
if (gate)
|
|
35
40
|
return gate;
|
|
36
41
|
const data = await client.request('POST', '/create_group', body);
|
|
37
|
-
return
|
|
42
|
+
return minifiedResult(data);
|
|
38
43
|
});
|
|
39
44
|
server.registerTool('sw_add_user_to_group', {
|
|
40
45
|
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.",
|
|
@@ -61,7 +66,7 @@ export function registerGroupTools(server, client) {
|
|
|
61
66
|
if (gate)
|
|
62
67
|
return gate;
|
|
63
68
|
const data = await client.request('POST', '/add_user_to_group', body);
|
|
64
|
-
return
|
|
69
|
+
return minifiedResult(data);
|
|
65
70
|
});
|
|
66
71
|
server.registerTool('sw_remove_user_from_group', {
|
|
67
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.',
|
|
@@ -76,7 +81,7 @@ export function registerGroupTools(server, client) {
|
|
|
76
81
|
if (gate)
|
|
77
82
|
return gate;
|
|
78
83
|
const data = await client.request('POST', '/remove_user_from_group', { group_id, user_id });
|
|
79
|
-
return
|
|
84
|
+
return minifiedResult(data);
|
|
80
85
|
});
|
|
81
86
|
server.registerTool('sw_delete_group', {
|
|
82
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.',
|
|
@@ -90,7 +95,7 @@ export function registerGroupTools(server, client) {
|
|
|
90
95
|
if (gate)
|
|
91
96
|
return gate;
|
|
92
97
|
const data = await client.request('POST', `/delete_group/${id}`);
|
|
93
|
-
return
|
|
98
|
+
return minifiedResult(data);
|
|
94
99
|
});
|
|
95
100
|
server.registerTool('sw_undelete_group', {
|
|
96
101
|
description: 'Restore a soft-deleted Splitwise group.',
|
|
@@ -99,6 +104,6 @@ export function registerGroupTools(server, client) {
|
|
|
99
104
|
},
|
|
100
105
|
}, async ({ id }) => {
|
|
101
106
|
const data = await client.request('POST', `/undelete_group/${id}`);
|
|
102
|
-
return
|
|
107
|
+
return minifiedResult(data);
|
|
103
108
|
});
|
|
104
109
|
}
|
package/dist/tools/receipts.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { z } from 'zod';
|
|
2
|
-
import {
|
|
2
|
+
import { minifiedResult, imageResult, 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. */
|
|
@@ -210,7 +210,7 @@ export function registerReceiptTools(server, client) {
|
|
|
210
210
|
advice,
|
|
211
211
|
].join(' '));
|
|
212
212
|
}
|
|
213
|
-
const result =
|
|
213
|
+
const result = minifiedResult({
|
|
214
214
|
expense_id: id,
|
|
215
215
|
size: served,
|
|
216
216
|
...(served !== wanted
|
package/dist/tools/user.js
CHANGED
|
@@ -1,23 +1,28 @@
|
|
|
1
1
|
import { z } from 'zod';
|
|
2
|
-
import {
|
|
2
|
+
import { PERSON_VIEW_NOTE, SW_VIEWS, viewUser } from '../project.js';
|
|
3
|
+
import { minifiedResult, pruneUndefined, resolveView, viewParam } from '@chrischall/mcp-utils';
|
|
3
4
|
import { previewUnlessConfirmed, schemaConfirm } from './_confirm.js';
|
|
4
5
|
export function registerUserTools(server, client) {
|
|
5
6
|
server.registerTool('sw_get_current_user', {
|
|
6
|
-
description: "Get the authenticated Splitwise user's profile
|
|
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.",
|
|
7
8
|
annotations: { readOnlyHint: true },
|
|
8
|
-
|
|
9
|
+
inputSchema: {
|
|
10
|
+
view: viewParam(SW_VIEWS, { note: PERSON_VIEW_NOTE }),
|
|
11
|
+
},
|
|
12
|
+
}, async ({ view }) => {
|
|
9
13
|
const data = await client.request('GET', '/get_current_user');
|
|
10
|
-
return
|
|
14
|
+
return minifiedResult(viewUser(resolveView(view, SW_VIEWS), data));
|
|
11
15
|
});
|
|
12
16
|
server.registerTool('sw_get_user', {
|
|
13
|
-
description: "Get another Splitwise user's profile by id.",
|
|
17
|
+
description: "Get another Splitwise user's profile by id. Same shape as sw_get_current_user: the compact default merges first_name/last_name into name, and view:'full' keeps them separate.",
|
|
14
18
|
annotations: { readOnlyHint: true },
|
|
15
19
|
inputSchema: {
|
|
20
|
+
view: viewParam(SW_VIEWS, { note: PERSON_VIEW_NOTE }),
|
|
16
21
|
id: z.number().describe('User ID'),
|
|
17
22
|
},
|
|
18
|
-
}, async ({ id }) => {
|
|
23
|
+
}, async ({ id, view }) => {
|
|
19
24
|
const data = await client.request('GET', `/get_user/${id}`);
|
|
20
|
-
return
|
|
25
|
+
return minifiedResult(viewUser(resolveView(view, SW_VIEWS), data));
|
|
21
26
|
});
|
|
22
27
|
server.registerTool('sw_update_user', {
|
|
23
28
|
description: "Update the current user's profile fields. id must be the current user's id.",
|
|
@@ -39,6 +44,6 @@ export function registerUserTools(server, client) {
|
|
|
39
44
|
if (gate)
|
|
40
45
|
return gate;
|
|
41
46
|
const data = await client.request('POST', `/update_user/${id}`, body);
|
|
42
|
-
return
|
|
47
|
+
return minifiedResult(data);
|
|
43
48
|
});
|
|
44
49
|
}
|
package/dist/tools/utilities.js
CHANGED
|
@@ -1,39 +1,44 @@
|
|
|
1
1
|
import { z } from 'zod';
|
|
2
|
-
import {
|
|
2
|
+
import { SW_VIEWS, viewGeneric } from '../project.js';
|
|
3
|
+
import { buildQueryString, minifiedResult, resolveView, viewParam } from '@chrischall/mcp-utils';
|
|
3
4
|
import { previewUnlessConfirmed, schemaConfirm } from './_confirm.js';
|
|
4
5
|
export function registerUtilityTools(server, client) {
|
|
5
6
|
server.registerTool('sw_get_notifications', {
|
|
6
7
|
description: 'Get recent Splitwise activity notifications for the current user.',
|
|
7
8
|
annotations: { readOnlyHint: true },
|
|
8
|
-
|
|
9
|
+
inputSchema: {
|
|
10
|
+
view: viewParam(SW_VIEWS, { note: 'compact drops the avatar URLs; "full" returns Splitwise\'s whole record.' }),
|
|
11
|
+
},
|
|
12
|
+
}, async ({ view }) => {
|
|
9
13
|
const data = await client.request('GET', '/get_notifications');
|
|
10
|
-
return
|
|
14
|
+
return minifiedResult(viewGeneric(resolveView(view, SW_VIEWS), data));
|
|
11
15
|
});
|
|
12
16
|
server.registerTool('sw_get_categories', {
|
|
13
17
|
description: 'Get the hierarchical list of Splitwise expense categories. Use the returned id as category_id when creating expenses.',
|
|
14
18
|
annotations: { readOnlyHint: true },
|
|
15
19
|
}, async () => {
|
|
16
20
|
const data = await client.request('GET', '/get_categories');
|
|
17
|
-
return
|
|
21
|
+
return minifiedResult(data);
|
|
18
22
|
});
|
|
19
23
|
server.registerTool('sw_get_currencies', {
|
|
20
24
|
description: 'Get all Splitwise-supported currency codes and units. Use the currency_code value when creating expenses in non-default currencies.',
|
|
21
25
|
annotations: { readOnlyHint: true },
|
|
22
26
|
}, async () => {
|
|
23
27
|
const data = await client.request('GET', '/get_currencies');
|
|
24
|
-
return
|
|
28
|
+
return minifiedResult(data);
|
|
25
29
|
});
|
|
26
30
|
server.registerTool('sw_get_comments', {
|
|
27
31
|
description: 'Get all comments on a Splitwise expense.',
|
|
28
32
|
annotations: { readOnlyHint: true },
|
|
29
33
|
inputSchema: {
|
|
34
|
+
view: viewParam(SW_VIEWS, { note: 'compact drops the avatar URLs; "full" returns Splitwise\'s whole record.' }),
|
|
30
35
|
expense_id: z.number().describe('Expense ID to get comments for'),
|
|
31
36
|
},
|
|
32
|
-
}, async ({ expense_id }) => {
|
|
37
|
+
}, async ({ expense_id, view }) => {
|
|
33
38
|
// buildQueryString percent-encodes the value — defense-in-depth against
|
|
34
39
|
// query-param injection (already constrained to a number by the schema).
|
|
35
40
|
const data = await client.request('GET', `/get_comments${buildQueryString({ expense_id })}`);
|
|
36
|
-
return
|
|
41
|
+
return minifiedResult(viewGeneric(resolveView(view, SW_VIEWS), data));
|
|
37
42
|
});
|
|
38
43
|
server.registerTool('sw_create_comment', {
|
|
39
44
|
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,7 +53,7 @@ export function registerUtilityTools(server, client) {
|
|
|
48
53
|
if (gate)
|
|
49
54
|
return gate;
|
|
50
55
|
const data = await client.request('POST', '/create_comment', { expense_id, content });
|
|
51
|
-
return
|
|
56
|
+
return minifiedResult(data);
|
|
52
57
|
});
|
|
53
58
|
server.registerTool('sw_delete_comment', {
|
|
54
59
|
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.',
|
|
@@ -62,6 +67,6 @@ export function registerUtilityTools(server, client) {
|
|
|
62
67
|
if (gate)
|
|
63
68
|
return gate;
|
|
64
69
|
const data = await client.request('POST', `/delete_comment/${id}`);
|
|
65
|
-
return
|
|
70
|
+
return minifiedResult(data);
|
|
66
71
|
});
|
|
67
72
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "splitwise-mcp",
|
|
3
|
-
"version": "2.
|
|
3
|
+
"version": "2.4.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": "^0.
|
|
48
|
+
"@chrischall/mcp-utils": "^0.23.0",
|
|
49
49
|
"@modelcontextprotocol/sdk": "^1.29.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": "2.
|
|
9
|
+
"version": "2.4.0",
|
|
10
10
|
"packages": [
|
|
11
11
|
{
|
|
12
12
|
"registryType": "npm",
|
|
13
13
|
"identifier": "splitwise-mcp",
|
|
14
|
-
"version": "2.
|
|
14
|
+
"version": "2.4.0",
|
|
15
15
|
"transport": {
|
|
16
16
|
"type": "stdio"
|
|
17
17
|
},
|