creditkarma-mcp 2.2.0 → 2.2.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/client.js CHANGED
@@ -1,6 +1,7 @@
1
1
  import { readFileSync } from 'fs';
2
2
  import { fileURLToPath } from 'url';
3
3
  import { join, dirname } from 'path';
4
+ import { truncateErrorMessage } from '@chrischall/mcp-utils';
4
5
  const TOKEN_TTL_MS = 10 * 60 * 1000; // 10 minutes
5
6
  export const GRAPHQL_ENDPOINT = 'https://api.creditkarma.com/graphql';
6
7
  export const CK_REFRESH_ENDPOINT = 'https://www.creditkarma.com/member/oauth2/refresh';
@@ -9,6 +10,8 @@ export class CreditKarmaClient {
9
10
  tokenSetAt = null;
10
11
  refreshToken = null;
11
12
  cookies = null;
13
+ /** In-flight refresh, shared across concurrent callers (see refreshAccessToken). */
14
+ refreshInFlight = null;
12
15
  constructor(token, refreshToken, cookies) {
13
16
  if (token)
14
17
  this.setToken(token);
@@ -60,18 +63,33 @@ export class CreditKarmaClient {
60
63
  if (retry.status === 401)
61
64
  throw new Error('TOKEN_EXPIRED');
62
65
  if (!retry.ok)
63
- throw new Error(`HTTP ${retry.status}`);
66
+ throw new Error(await httpErrorMessage(retry));
64
67
  return parseTransactionPage(await retry.json());
65
68
  }
66
69
  if (!response.ok)
67
- throw new Error(`HTTP ${response.status}`);
70
+ throw new Error(await httpErrorMessage(response));
68
71
  return parseTransactionPage(await response.json());
69
72
  }
70
73
  /**
71
74
  * Refresh the access token using CK's native refresh endpoint.
72
75
  * Requires a refresh token and session cookies (captured after login).
76
+ *
77
+ * Concurrent callers share a single in-flight request: the first call starts
78
+ * the refresh and stores its promise; overlapping callers (e.g. a multi-page
79
+ * sync that 401s on several pages at once) await that same promise instead of
80
+ * firing duplicate POSTs to /member/oauth2/refresh (wasted quota, rate-limit
81
+ * risk). The slot is cleared in `finally`, so a later expiry refreshes anew.
73
82
  */
74
- async refreshAccessToken() {
83
+ refreshAccessToken() {
84
+ if (this.refreshInFlight)
85
+ return this.refreshInFlight;
86
+ const p = this.doRefreshAccessToken().finally(() => {
87
+ this.refreshInFlight = null;
88
+ });
89
+ this.refreshInFlight = p;
90
+ return p;
91
+ }
92
+ async doRefreshAccessToken() {
75
93
  if (!this.refreshToken)
76
94
  throw new Error('NO_REFRESH_TOKEN: Call ck_set_session first.');
77
95
  const headers = {
@@ -155,6 +173,17 @@ export function isJwtExpired(token) {
155
173
  return false;
156
174
  return p.exp * 1000 < Date.now();
157
175
  }
176
+ /**
177
+ * Emit the standard stderr warning when a refresh JWT is present but already
178
+ * expired. Single source of truth for the message that previously lived in
179
+ * both `src/index.ts` (startup) and was conceptually mirrored in
180
+ * `ck_set_session`. No-op when the token is absent or still valid.
181
+ */
182
+ export function warnIfRefreshTokenExpired(refreshToken) {
183
+ if (refreshToken && isJwtExpired(refreshToken)) {
184
+ console.error('[creditkarma-mcp] Warning: refresh token in CK_COOKIES has expired. Sign back into creditkarma.com (with the fetchproxy extension installed) or call ck_set_session with a fresh Cookie header.');
185
+ }
186
+ }
158
187
  function extractGlidFromJwt(token) {
159
188
  const p = decodeJwtPayload(token);
160
189
  const glid = p?.glid;
@@ -196,3 +225,20 @@ function parseTransactionPage(json) {
196
225
  function sleep(ms) {
197
226
  return new Promise(resolve => setTimeout(resolve, ms));
198
227
  }
228
+ /**
229
+ * Build an `HTTP <status>: <body>` error message for a failed GraphQL response,
230
+ * attaching the upstream body (redacted + length-capped via mcp-utils'
231
+ * `truncateErrorMessage`) so failures are debuggable instead of a bare status.
232
+ * Falls back to just the status when the body can't be read.
233
+ */
234
+ async function httpErrorMessage(res) {
235
+ let body = '';
236
+ try {
237
+ body = typeof res.text === 'function' ? await res.text() : '';
238
+ }
239
+ catch {
240
+ body = '';
241
+ }
242
+ const safe = truncateErrorMessage(body, 200).trim();
243
+ return safe.length > 0 ? `HTTP ${res.status}: ${safe}` : `HTTP ${res.status}`;
244
+ }
package/dist/index.js CHANGED
@@ -1,45 +1,22 @@
1
- import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
2
- import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
1
+ import { readEnvVar, loadDotenvSafely, runMcp } from '@chrischall/mcp-utils';
3
2
  import { homedir } from 'os';
4
3
  import { join, dirname } from 'path';
5
4
  import { fileURLToPath } from 'url';
6
- import { CreditKarmaClient, isJwtExpired, extractCookieValue } from './client.js';
5
+ import { CreditKarmaClient, extractCookieValue, warnIfRefreshTokenExpired } from './client.js';
7
6
  import { initDb, backfillAccountIds } from './db.js';
8
7
  import { registerAuthTools } from './tools/auth.js';
9
8
  import { registerSyncTools } from './tools/sync.js';
10
9
  import { registerQueryTools } from './tools/query.js';
11
10
  import { registerSqlTools } from './tools/sql.js';
12
- /**
13
- * Read an env var, trim whitespace, and treat as unset if blank or if the value
14
- * looks like an unsubstituted shell placeholder (e.g. `${FOO}`) — defends
15
- * against MCP hosts that pass .mcp.json env blocks through unexpanded.
16
- */
17
- function readVar(key) {
18
- const raw = process.env[key];
19
- if (typeof raw !== 'string')
20
- return undefined;
21
- const trimmed = raw.trim();
22
- if (trimmed.length === 0)
23
- return undefined;
24
- if (trimmed === 'undefined' || trimmed === 'null')
25
- return undefined;
26
- if (/^\$\{[^}]*\}$/.test(trimmed))
27
- return undefined;
28
- return trimmed;
29
- }
30
11
  const __dirname = dirname(fileURLToPath(import.meta.url));
31
- // Load .env for local dev; silently skip if dotenv is unavailable (e.g. mcpb bundle)
32
- try {
33
- const { config } = await import('dotenv');
34
- config({ path: join(__dirname, '..', '.env'), override: false, quiet: true });
35
- }
36
- catch {
37
- // not available — rely on process.env (mcpb sets credentials via mcp_config.env)
38
- }
12
+ // Load .env for local dev; no-throw when absent (e.g. mcpb bundle relies on
13
+ // mcp_config.env). `readEnvVar` below already hardens against blank /
14
+ // `${UNEXPANDED}` placeholders passed through by some MCP hosts.
15
+ await loadDotenvSafely({ path: join(__dirname, '..', '.env') });
39
16
  async function main() {
40
- const dbPath = readVar('CK_DB_PATH') || join(homedir(), '.creditkarma-mcp', 'transactions.db');
17
+ const dbPath = readEnvVar('CK_DB_PATH') || join(homedir(), '.creditkarma-mcp', 'transactions.db');
41
18
  const mcpJsonPath = join(__dirname, '..', '.mcp.json');
42
- const cookies = readVar('CK_COOKIES') || undefined;
19
+ const cookies = readEnvVar('CK_COOKIES') || undefined;
43
20
  // Canonical CK_COOKIES is a full Cookie header. Parser stays lenient and
44
21
  // also accepts a bare CKAT value or `CKAT=<value>` from legacy configs.
45
22
  let token;
@@ -50,26 +27,31 @@ async function main() {
50
27
  token = parts[0]?.trim() || undefined;
51
28
  refreshToken = parts[1]?.trim() || undefined;
52
29
  }
53
- if (refreshToken && isJwtExpired(refreshToken)) {
54
- console.error('[creditkarma-mcp] Warning: refresh token in CK_COOKIES has expired. Sign back into creditkarma.com (with the fetchproxy extension installed) or call ck_set_session with a fresh Cookie header.');
55
- }
30
+ warnIfRefreshTokenExpired(refreshToken);
56
31
  const db = initDb(dbPath);
57
32
  const repaired = backfillAccountIds(db);
58
33
  if (repaired.txsUpdated > 0) {
59
34
  console.error(`[creditkarma-mcp] Repaired ${repaired.txsUpdated} transactions across ${repaired.accountsCreated} accounts (CK returned empty account.id for legacy rows).`);
60
35
  }
36
+ // Build the client/context here so the deferred-config-error pattern is
37
+ // preserved: the server boots (and answers the host's install-time
38
+ // tools/list) even when CK_COOKIES is absent — the auth error surfaces on
39
+ // the first tool call that needs credentials instead.
61
40
  const ctx = {
62
41
  client: new CreditKarmaClient(token, refreshToken, cookies),
63
42
  db,
64
43
  mcpJsonPath
65
44
  };
66
- const server = new McpServer({ name: 'creditkarma-mcp', version: '2.2.0' } // x-release-please-version
67
- );
68
- registerAuthTools(server, ctx);
69
- registerSyncTools(server, ctx);
70
- registerQueryTools(server, ctx);
71
- registerSqlTools(server, ctx);
72
- const transport = new StdioServerTransport();
73
- await server.connect(transport);
45
+ await runMcp({
46
+ name: 'creditkarma-mcp',
47
+ version: '2.2.2', // x-release-please-version
48
+ deps: ctx,
49
+ tools: [
50
+ registerAuthTools,
51
+ registerSyncTools,
52
+ registerQueryTools,
53
+ registerSqlTools,
54
+ ],
55
+ });
74
56
  }
75
57
  main().catch(console.error);
@@ -1,4 +1,5 @@
1
1
  import { z } from 'zod';
2
+ import { rawTextResult } from '@chrischall/mcp-utils';
2
3
  import { readFileSync, writeFileSync, existsSync } from 'fs';
3
4
  import { join, dirname } from 'path';
4
5
  import { isJwtExpired, extractCookieValue } from '../client.js';
@@ -65,6 +66,6 @@ export function registerAuthTools(server, ctx) {
65
66
  },
66
67
  }, async (args) => {
67
68
  const result = await handleSetSession(args, ctx);
68
- return { content: [{ type: 'text', text: result }] };
69
+ return rawTextResult(result);
69
70
  });
70
71
  }
@@ -1,4 +1,5 @@
1
1
  import { z } from 'zod';
2
+ import { textResult } from '@chrischall/mcp-utils';
2
3
  // ---------------------------------------------------------------------------
3
4
  // Helpers
4
5
  // ---------------------------------------------------------------------------
@@ -188,7 +189,7 @@ export function registerQueryTools(server, ctx) {
188
189
  },
189
190
  }, async (args) => {
190
191
  const result = await handleListTransactions(args, ctx);
191
- return { content: [{ type: 'text', text: JSON.stringify(result, null, 2) }] };
192
+ return textResult(result);
192
193
  });
193
194
  server.registerTool('ck_get_recent_transactions', {
194
195
  description: 'Return the N most recent transactions. Convenience shortcut for ck_list_transactions.',
@@ -198,7 +199,7 @@ export function registerQueryTools(server, ctx) {
198
199
  },
199
200
  }, async (args) => {
200
201
  const result = await handleGetRecentTransactions(args, ctx);
201
- return { content: [{ type: 'text', text: JSON.stringify(result, null, 2) }] };
202
+ return textResult(result);
202
203
  });
203
204
  server.registerTool('ck_get_spending_by_category', {
204
205
  description: 'Group debit transactions by category and return totals.',
@@ -210,7 +211,7 @@ export function registerQueryTools(server, ctx) {
210
211
  },
211
212
  }, async (args) => {
212
213
  const result = await handleGetSpendingByCategory(args, ctx);
213
- return { content: [{ type: 'text', text: JSON.stringify(result, null, 2) }] };
214
+ return textResult(result);
214
215
  });
215
216
  server.registerTool('ck_get_spending_by_merchant', {
216
217
  description: 'Return top merchants by total debit spend.',
@@ -223,7 +224,7 @@ export function registerQueryTools(server, ctx) {
223
224
  },
224
225
  }, async (args) => {
225
226
  const result = await handleGetSpendingByMerchant(args, ctx);
226
- return { content: [{ type: 'text', text: JSON.stringify(result, null, 2) }] };
227
+ return textResult(result);
227
228
  });
228
229
  server.registerTool('ck_get_account_summary', {
229
230
  description: 'Return per-account debit, credit, and net totals.',
@@ -234,6 +235,6 @@ export function registerQueryTools(server, ctx) {
234
235
  },
235
236
  }, async (args) => {
236
237
  const result = await handleGetAccountSummary(args, ctx);
237
- return { content: [{ type: 'text', text: JSON.stringify(result, null, 2) }] };
238
+ return textResult(result);
238
239
  });
239
240
  }
package/dist/tools/sql.js CHANGED
@@ -1,4 +1,5 @@
1
1
  import { z } from 'zod';
2
+ import { textResult } from '@chrischall/mcp-utils';
2
3
  export async function handleQuerySql(args, ctx) {
3
4
  const trimmed = args.sql.replace(/\/\*[\s\S]*?\*\//g, '').replace(/--[^\n]*/g, '').trim();
4
5
  if (!/^SELECT\s/i.test(trimmed)) {
@@ -18,6 +19,6 @@ export function registerSqlTools(server, ctx) {
18
19
  },
19
20
  }, async (args) => {
20
21
  const result = await handleQuerySql(args, ctx);
21
- return { content: [{ type: 'text', text: JSON.stringify(result, null, 2) }] };
22
+ return textResult(result);
22
23
  });
23
24
  }
@@ -1,4 +1,5 @@
1
1
  import { z } from 'zod';
2
+ import { textResult } from '@chrischall/mcp-utils';
2
3
  import { upsertAccount, upsertCategory, upsertMerchant, upsertTransaction, getSyncState, setSyncState } from '../db.js';
3
4
  import { deriveAccountId } from '../accountId.js';
4
5
  import { loadAuthIntoClient } from '../auth.js';
@@ -137,6 +138,6 @@ export function registerSyncTools(server, ctx) {
137
138
  },
138
139
  }, async (args) => {
139
140
  const result = await handleSyncTransactions(args, ctx);
140
- return { content: [{ type: 'text', text: JSON.stringify(result, null, 2) }] };
141
+ return textResult(result);
141
142
  });
142
143
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "creditkarma-mcp",
3
- "version": "2.2.0",
3
+ "version": "2.2.2",
4
4
  "mcpName": "io.github.chrischall/creditkarma-mcp",
5
5
  "description": "MCP server for Credit Karma — natural-language access to your transactions, spending, and accounts",
6
6
  "author": "Claude Code (AI) <https://www.anthropic.com/claude>",
@@ -44,8 +44,9 @@
44
44
  "test:coverage": "vitest run --coverage"
45
45
  },
46
46
  "dependencies": {
47
- "@fetchproxy/bootstrap": "^0.8.0",
48
- "@fetchproxy/server": "^0.8.0",
47
+ "@chrischall/mcp-utils": "^0.5.0",
48
+ "@fetchproxy/bootstrap": "^1.0.0",
49
+ "@fetchproxy/server": "^1.0.0",
49
50
  "@modelcontextprotocol/sdk": "^1.29.0",
50
51
  "dotenv": "^17.4.2",
51
52
  "zod": "^4.4.3"
package/server.json CHANGED
@@ -6,12 +6,12 @@
6
6
  "url": "https://github.com/chrischall/creditkarma-mcp",
7
7
  "source": "github"
8
8
  },
9
- "version": "2.2.0",
9
+ "version": "2.2.2",
10
10
  "packages": [
11
11
  {
12
12
  "registryType": "npm",
13
13
  "identifier": "creditkarma-mcp",
14
- "version": "2.2.0",
14
+ "version": "2.2.2",
15
15
  "transport": {
16
16
  "type": "stdio"
17
17
  },