splitwise-mcp 2.3.0 → 2.4.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/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.3.0', // x-release-please-version
22
+ version: '2.4.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: [
@@ -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
+ }
@@ -1,4 +1,4 @@
1
- import { schemaConfirm, textResult } from '@chrischall/mcp-utils';
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 textResult({
13
+ return minifiedResult({
14
14
  dryRun: true,
15
15
  action,
16
16
  method,
@@ -1,5 +1,6 @@
1
1
  import { z } from 'zod';
2
- import { textResult, buildQueryString } from '@chrischall/mcp-utils';
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 textResult(data);
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 textResult(data);
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 textResult(data);
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 textResult(data);
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 textResult(data);
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 textResult(data);
141
+ return minifiedResult(data);
139
142
  });
140
143
  }
@@ -1,13 +1,17 @@
1
1
  import { z } from 'zod';
2
- import { textResult, pruneUndefined } from '@chrischall/mcp-utils';
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 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.",
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
- }, async () => {
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 textResult(data);
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 textResult(data);
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 textResult(data);
45
+ return minifiedResult(data);
42
46
  });
43
47
  }
@@ -1,23 +1,28 @@
1
1
  import { z } from 'zod';
2
- import { textResult, pruneUndefined } from '@chrischall/mcp-utils';
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
- }, async () => {
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 textResult(data);
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 textResult(data);
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 textResult(data);
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 textResult(data);
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 textResult(data);
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 textResult(data);
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 textResult(data);
107
+ return minifiedResult(data);
103
108
  });
104
109
  }
@@ -1,5 +1,5 @@
1
1
  import { z } from 'zod';
2
- import { textResult, imageResult, resolveOutputDir, writeBinaryOutput, sniffMimeBytes, } from '@chrischall/mcp-utils';
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 = textResult({
213
+ const result = minifiedResult({
214
214
  expense_id: id,
215
215
  size: served,
216
216
  ...(served !== wanted
@@ -1,23 +1,28 @@
1
1
  import { z } from 'zod';
2
- import { textResult, pruneUndefined } from '@chrischall/mcp-utils';
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 (id, first_name, last_name, email). Use the returned id when building custom expense splits.",
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
- }, async () => {
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 textResult(data);
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 textResult(data);
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 textResult(data);
47
+ return minifiedResult(data);
43
48
  });
44
49
  }
@@ -1,39 +1,44 @@
1
1
  import { z } from 'zod';
2
- import { textResult, buildQueryString } from '@chrischall/mcp-utils';
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
- }, async () => {
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 textResult(data);
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 textResult(data);
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 textResult(data);
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 textResult(data);
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 textResult(data);
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 textResult(data);
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.0",
3
+ "version": "2.4.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,7 +45,7 @@
45
45
  "typecheck": "tsc -p tsconfig.json --noEmit"
46
46
  },
47
47
  "dependencies": {
48
- "@chrischall/mcp-utils": "^0.19.2",
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.3.0",
9
+ "version": "2.4.1",
10
10
  "packages": [
11
11
  {
12
12
  "registryType": "npm",
13
13
  "identifier": "splitwise-mcp",
14
- "version": "2.3.0",
14
+ "version": "2.4.1",
15
15
  "transport": {
16
16
  "type": "stdio"
17
17
  },