splitwise-mcp 2.2.3 → 2.3.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.
@@ -7,7 +7,7 @@
7
7
  },
8
8
  "metadata": {
9
9
  "description": "MCP server for Splitwise — natural-language expense and group management via the Splitwise API",
10
- "version": "2.2.3"
10
+ "version": "2.3.0"
11
11
  },
12
12
  "plugins": [
13
13
  {
@@ -15,7 +15,7 @@
15
15
  "displayName": "Splitwise",
16
16
  "source": "./",
17
17
  "description": "MCP server for Splitwise — manage expenses, groups, and friends via natural language",
18
- "version": "2.2.3",
18
+ "version": "2.3.0",
19
19
  "author": {
20
20
  "name": "Chris Hall"
21
21
  },
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "splitwise-mcp",
3
3
  "displayName": "Splitwise",
4
- "version": "2.2.3",
4
+ "version": "2.3.0",
5
5
  "description": "MCP server for Splitwise — natural-language expense and group management via the Splitwise API",
6
6
  "author": {
7
7
  "name": "Chris Hall",
package/README.md CHANGED
@@ -124,6 +124,7 @@ Add to Claude Desktop config:
124
124
  | Tool | What it does |
125
125
  |------|-------------|
126
126
  | `sw_get_current_user` | Get the authenticated user's profile |
127
+ | `sw_healthcheck` | Verify the API key and Splitwise reachability; says which of the two failed |
127
128
  | `sw_get_user` | Get another user's profile by ID |
128
129
  | `sw_update_user` | Update the current user's profile fields |
129
130
 
@@ -202,6 +203,7 @@ src/
202
203
  index.ts MCP server entry point
203
204
  tools/
204
205
  user.ts sw_get_current_user, sw_get_user
206
+ healthcheck.ts sw_healthcheck
205
207
  groups.ts group CRUD and membership
206
208
  friends.ts friend list and management
207
209
  expenses.ts expense CRUD
package/dist/bundle.js CHANGED
@@ -70944,6 +70944,11 @@ function truncateErrorMessage(text, max = DEFAULT_ERROR_MESSAGE_MAX) {
70944
70944
  return redacted;
70945
70945
  return `${redacted.slice(0, max)}\u2026 [truncated]`;
70946
70946
  }
70947
+ function messageOf(err) {
70948
+ if (err instanceof Error)
70949
+ return err.message;
70950
+ return String(err);
70951
+ }
70947
70952
 
70948
70953
  // node_modules/@chrischall/mcp-utils/dist/response/index.js
70949
70954
  function textResult(data) {
@@ -71380,6 +71385,8 @@ function redactAssetQuery(err, search) {
71380
71385
  }
71381
71386
  var SplitwiseClient = class {
71382
71387
  apiKey;
71388
+ /** Which source supplied the API key — a LABEL, never the value. */
71389
+ credentialSource;
71383
71390
  configError;
71384
71391
  api;
71385
71392
  /** Per-origin clients for asset hosts, built on first use by `fetchAsset`. */
@@ -71395,7 +71402,9 @@ var SplitwiseClient = class {
71395
71402
  * byte-for-byte identical behaviour.
71396
71403
  */
71397
71404
  constructor(opts) {
71398
- const key = opts?.apiKey ?? readEnvVar("SPLITWISE_API_KEY");
71405
+ const injected = opts?.apiKey;
71406
+ const key = injected ?? readEnvVar("SPLITWISE_API_KEY");
71407
+ this.credentialSource = !key ? null : injected !== void 0 ? "injected" : "env";
71399
71408
  if (!key) {
71400
71409
  this.apiKey = null;
71401
71410
  this.configError = new Error("SPLITWISE_API_KEY environment variable is required");
@@ -71405,6 +71414,14 @@ var SplitwiseClient = class {
71405
71414
  }
71406
71415
  this.api = this.buildApiClient(BASE_URL, true);
71407
71416
  }
71417
+ /**
71418
+ * Which source supplied the API key, for `sw_healthcheck`. Returns a LABEL
71419
+ * or `null` — deliberately never the key, since the healthcheck result is
71420
+ * the thing people paste into a chat when something is broken.
71421
+ */
71422
+ describeCredential() {
71423
+ return { source: this.credentialSource };
71424
+ }
71408
71425
  /**
71409
71426
  * One API client per origin, sharing the fleet's retry/timeout/error policy.
71410
71427
  * `authenticated` decides whether the API key is attached: `getToken` defers
@@ -71514,6 +71531,134 @@ function registerUserTools(server, client2) {
71514
71531
  });
71515
71532
  }
71516
71533
 
71534
+ // node_modules/@chrischall/mcp-utils/dist/healthcheck/index.js
71535
+ function statusOf(err) {
71536
+ if (typeof err !== "object" || err === null)
71537
+ return void 0;
71538
+ const s2 = err.status ?? err.statusCode;
71539
+ return typeof s2 === "number" ? s2 : void 0;
71540
+ }
71541
+ function credentialHint(arm, prefix, hostLabel, source) {
71542
+ switch (arm) {
71543
+ case "ok":
71544
+ return `Credential from '${source}' works: ${hostLabel} accepted an authenticated request. If a real tool still fails, the problem is that tool, not auth.`;
71545
+ case "no_credential":
71546
+ return `No credential resolved. Nothing was available to authenticate with \u2014 sign in and reconnect the connector so ${prefix} receives a token, or set the documented environment variable.`;
71547
+ case "credential_rejected":
71548
+ return `${hostLabel} rejected the credential from '${source}'. It is present but no longer valid \u2014 most often expired or revoked upstream. Re-authenticate and reconnect; retrying will not fix it.`;
71549
+ case "timeout":
71550
+ return `The credential from '${source}' resolved, but ${hostLabel} did not answer in time. Usually transient \u2014 retry. If it persists, ${hostLabel} is slow or unreachable from here.`;
71551
+ case "http":
71552
+ return `${hostLabel} answered with an error status that is not an auth rejection. That is USUALLY a ${hostLabel}-side problem rather than an auth one \u2014 but a 404 here more often means the probe path is wrong than that ${hostLabel} is broken, so check error.message and probe.url before concluding anything about the credential.`;
71553
+ case "transport":
71554
+ return `Could not reach ${hostLabel} at all. Check network egress; the credential itself was never judged.`;
71555
+ default:
71556
+ return `Unexpected failure \u2014 see error.message.`;
71557
+ }
71558
+ }
71559
+ function registerCredentialHealthcheckTool(args) {
71560
+ const { server, prefix, hostLabel, probePath, resolveCredential, probeFn, classifyThrown, hints } = args;
71561
+ const probeUrl = probePath ? `https://${hostLabel}${probePath}` : void 0;
71562
+ server.registerTool(`${prefix}_healthcheck`, {
71563
+ title: "Verify credentials and upstream reachability",
71564
+ description: `Resolves the credential the way real tools do, then makes one authenticated request to ${hostLabel}. Reports which source supplied the credential, whether ${hostLabel} accepted it, the round-trip time, and a plain-English hint distinguishing 'no credential' from 'credential rejected' from 'a ${hostLabel}-side problem'. Call this when a real tool fails and you want to know which hop broke. Read-only; never returns the credential itself.`,
71565
+ annotations: {
71566
+ title: "Verify credentials and upstream reachability",
71567
+ readOnlyHint: true,
71568
+ idempotentHint: true,
71569
+ openWorldHint: true
71570
+ },
71571
+ inputSchema: {}
71572
+ }, async () => {
71573
+ let probeStarted = 0;
71574
+ let state;
71575
+ try {
71576
+ state = await resolveCredential();
71577
+ } catch (e2) {
71578
+ const result2 = {
71579
+ ok: false,
71580
+ credential: { source: null, resolved: false },
71581
+ // No `url`: nothing was probed, and naming one implies it was tried.
71582
+ probe: { elapsed_ms: 0 },
71583
+ error: { kind: "no_credential", message: truncateErrorMessage(messageOf(e2)) },
71584
+ hint: hints?.no_credential ?? credentialHint("no_credential", prefix, hostLabel, null)
71585
+ };
71586
+ return { content: [{ type: "text", text: JSON.stringify(result2, null, 2) }] };
71587
+ }
71588
+ const credential = {
71589
+ source: state.source,
71590
+ resolved: state.source !== null,
71591
+ ...state.detail !== void 0 ? { detail: state.detail } : {}
71592
+ };
71593
+ if (!credential.resolved) {
71594
+ const result2 = {
71595
+ ok: false,
71596
+ credential,
71597
+ probe: { elapsed_ms: 0 },
71598
+ error: { kind: "no_credential", message: "no credential source resolved" },
71599
+ hint: hints?.no_credential ?? credentialHint("no_credential", prefix, hostLabel, null)
71600
+ };
71601
+ return { content: [{ type: "text", text: JSON.stringify(result2, null, 2) }] };
71602
+ }
71603
+ let arm = "ok";
71604
+ let error51;
71605
+ let status;
71606
+ let customHint;
71607
+ probeStarted = Date.now();
71608
+ try {
71609
+ await probeFn();
71610
+ } catch (e2) {
71611
+ status = statusOf(e2);
71612
+ const aborted2 = e2 instanceof Error && e2.name === "AbortError";
71613
+ arm = status === 401 || status === 403 ? "credential_rejected" : status !== void 0 ? "http" : aborted2 || /timeout|timed out|ETIMEDOUT/i.test(messageOf(e2)) ? "timeout" : /fetch failed|ENOTFOUND|ECONNREFUSED|ECONNRESET|network/i.test(messageOf(e2)) ? "transport" : "unknown";
71614
+ let kind = arm;
71615
+ let detail;
71616
+ const custom2 = classifyThrown?.(e2);
71617
+ if (custom2) {
71618
+ kind = custom2.kind;
71619
+ customHint = custom2.hint;
71620
+ detail = custom2.detail;
71621
+ }
71622
+ error51 = {
71623
+ kind,
71624
+ // Redacted AND bounded before it reaches the result: an upstream
71625
+ // failure routinely quotes what it was sent, and a healthcheck is
71626
+ // the tool people paste into a chat when something is broken.
71627
+ message: truncateErrorMessage(messageOf(e2)),
71628
+ ...detail !== void 0 ? { detail } : {}
71629
+ };
71630
+ }
71631
+ const result = {
71632
+ ok: error51 === void 0,
71633
+ credential,
71634
+ probe: {
71635
+ ...probeUrl ? { url: probeUrl } : {},
71636
+ elapsed_ms: Date.now() - probeStarted,
71637
+ ...status !== void 0 ? { status } : {}
71638
+ },
71639
+ ...error51 ? { error: error51 } : {},
71640
+ hint: customHint ?? hints?.[arm] ?? credentialHint(arm, prefix, hostLabel, state.source)
71641
+ };
71642
+ return { content: [{ type: "text", text: JSON.stringify(result, null, 2) }] };
71643
+ });
71644
+ }
71645
+
71646
+ // src/tools/healthcheck.ts
71647
+ function registerHealthcheckTools(server, client2) {
71648
+ registerCredentialHealthcheckTool({
71649
+ server,
71650
+ prefix: "sw",
71651
+ hostLabel: "secure.splitwise.com",
71652
+ probePath: "/api/v3.0/get_current_user",
71653
+ resolveCredential: async () => client2.describeCredential(),
71654
+ probeFn: () => client2.request("GET", "/get_current_user"),
71655
+ hints: {
71656
+ no_credential: "No Splitwise API key resolved. Set SPLITWISE_API_KEY, or reconnect the connector so it supplies one.",
71657
+ credential_rejected: "Splitwise rejected the API key. Generate a new one at https://secure.splitwise.com/apps and update SPLITWISE_API_KEY (or reconnect the connector)."
71658
+ }
71659
+ });
71660
+ }
71661
+
71517
71662
  // src/tools/groups.ts
71518
71663
  function registerGroupTools(server, client2) {
71519
71664
  server.registerTool("sw_list_groups", {
@@ -72010,12 +72155,13 @@ function registerUtilityTools(server, client2) {
72010
72155
  // src/index.ts
72011
72156
  await runMcp({
72012
72157
  name: "splitwise-mcp",
72013
- version: "2.2.3",
72158
+ version: "2.3.0",
72014
72159
  // x-release-please-version
72015
72160
  deps: client,
72016
72161
  banner: "[splitwise-mcp] This project was developed and is maintained by AI (Claude Sonnet 4.6). Use at your own discretion.",
72017
72162
  tools: [
72018
72163
  registerUserTools,
72164
+ registerHealthcheckTools,
72019
72165
  registerGroupTools,
72020
72166
  registerFriendTools,
72021
72167
  registerExpenseTools,
package/dist/client.js CHANGED
@@ -61,6 +61,8 @@ function redactAssetQuery(err, search) {
61
61
  }
62
62
  export class SplitwiseClient {
63
63
  apiKey;
64
+ /** Which source supplied the API key — a LABEL, never the value. */
65
+ credentialSource;
64
66
  configError;
65
67
  api;
66
68
  /** Per-origin clients for asset hosts, built on first use by `fetchAsset`. */
@@ -80,7 +82,16 @@ export class SplitwiseClient {
80
82
  // back to the env var. readEnvVar trims whitespace and treats
81
83
  // blank/`undefined`/`null`/`${...}` placeholder values as unset — defends
82
84
  // against MCP hosts that pass .mcp.json env blocks through unexpanded.
83
- const key = opts?.apiKey ?? readEnvVar('SPLITWISE_API_KEY');
85
+ const injected = opts?.apiKey;
86
+ const key = injected ?? readEnvVar('SPLITWISE_API_KEY');
87
+ // Which source supplied it, for `sw_healthcheck`. Derived from the SAME
88
+ // `key` and `injected` bindings the resolution above uses, so the two
89
+ // cannot disagree. An earlier version re-tested `opts?.apiKey` for
90
+ // TRUTHINESS while the resolution used `??`: an injected empty string is
91
+ // not nullish, so `key` became `''` and the client reported a config error
92
+ // while the source said `env` — the healthcheck naming a source the client
93
+ // had not used.
94
+ this.credentialSource = !key ? null : injected !== undefined ? 'injected' : 'env';
84
95
  if (!key) {
85
96
  this.apiKey = null;
86
97
  this.configError = new Error('SPLITWISE_API_KEY environment variable is required');
@@ -91,6 +102,14 @@ export class SplitwiseClient {
91
102
  }
92
103
  this.api = this.buildApiClient(BASE_URL, true);
93
104
  }
105
+ /**
106
+ * Which source supplied the API key, for `sw_healthcheck`. Returns a LABEL
107
+ * or `null` — deliberately never the key, since the healthcheck result is
108
+ * the thing people paste into a chat when something is broken.
109
+ */
110
+ describeCredential() {
111
+ return { source: this.credentialSource };
112
+ }
94
113
  /**
95
114
  * One API client per origin, sharing the fleet's retry/timeout/error policy.
96
115
  * `authenticated` decides whether the API key is attached: `getToken` defers
package/dist/index.js CHANGED
@@ -2,6 +2,7 @@
2
2
  import { runMcp } from '@chrischall/mcp-utils';
3
3
  import { client } from './client.js';
4
4
  import { registerUserTools } from './tools/user.js';
5
+ import { registerHealthcheckTools } from './tools/healthcheck.js';
5
6
  import { registerGroupTools } from './tools/groups.js';
6
7
  import { registerFriendTools } from './tools/friends.js';
7
8
  import { registerExpenseTools } from './tools/expenses.js';
@@ -18,11 +19,12 @@ import { registerUtilityTools } from './tools/utilities.js';
18
19
  // client into the same registrars instead of this singleton.
19
20
  await runMcp({
20
21
  name: 'splitwise-mcp',
21
- version: '2.2.3', // x-release-please-version
22
+ version: '2.3.0', // x-release-please-version
22
23
  deps: client,
23
24
  banner: '[splitwise-mcp] This project was developed and is maintained by AI (Claude Sonnet 4.6). Use at your own discretion.',
24
25
  tools: [
25
26
  registerUserTools,
27
+ registerHealthcheckTools,
26
28
  registerGroupTools,
27
29
  registerFriendTools,
28
30
  registerExpenseTools,
@@ -0,0 +1,28 @@
1
+ import { registerCredentialHealthcheckTool } from '@chrischall/mcp-utils/healthcheck';
2
+ /**
3
+ * Register `sw_healthcheck` — resolves the API key the way real tools do, then
4
+ * makes one authenticated call to `/get_current_user`.
5
+ *
6
+ * Splitwise has no browser bridge, so "health" here is entirely about the
7
+ * credential: whether one resolved at all, and whether Splitwise still accepts
8
+ * it. Those are different problems with different fixes and, without this,
9
+ * both surface as the same opaque tool error.
10
+ *
11
+ * `/get_current_user` is the probe because it is the cheapest endpoint that
12
+ * REQUIRES auth — an unauthenticated endpoint would pass while the key was
13
+ * expired, which is the failure most worth catching.
14
+ */
15
+ export function registerHealthcheckTools(server, client) {
16
+ registerCredentialHealthcheckTool({
17
+ server,
18
+ prefix: 'sw',
19
+ hostLabel: 'secure.splitwise.com',
20
+ probePath: '/api/v3.0/get_current_user',
21
+ resolveCredential: async () => client.describeCredential(),
22
+ probeFn: () => client.request('GET', '/get_current_user'),
23
+ hints: {
24
+ no_credential: 'No Splitwise API key resolved. Set SPLITWISE_API_KEY, or reconnect the connector so it supplies one.',
25
+ credential_rejected: 'Splitwise rejected the API key. Generate a new one at https://secure.splitwise.com/apps and update SPLITWISE_API_KEY (or reconnect the connector).',
26
+ },
27
+ });
28
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "splitwise-mcp",
3
- "version": "2.2.3",
3
+ "version": "2.3.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.15.0",
48
+ "@chrischall/mcp-utils": "^0.19.2",
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.2.3",
9
+ "version": "2.3.0",
10
10
  "packages": [
11
11
  {
12
12
  "registryType": "npm",
13
13
  "identifier": "splitwise-mcp",
14
- "version": "2.2.3",
14
+ "version": "2.3.0",
15
15
  "transport": {
16
16
  "type": "stdio"
17
17
  },