creditkarma-mcp 2.2.1 → 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/auth.js +7 -25
- package/dist/bundle.js +581 -153
- package/dist/client.js +49 -3
- package/dist/index.js +24 -42
- package/dist/tools/auth.js +2 -1
- package/dist/tools/query.js +6 -5
- package/dist/tools/sql.js +2 -1
- package/dist/tools/sync.js +2 -1
- package/package.json +4 -3
- package/server.json +2 -2
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(
|
|
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(
|
|
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
|
-
|
|
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 {
|
|
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,
|
|
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;
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
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 =
|
|
17
|
+
const dbPath = readEnvVar('CK_DB_PATH') || join(homedir(), '.creditkarma-mcp', 'transactions.db');
|
|
41
18
|
const mcpJsonPath = join(__dirname, '..', '.mcp.json');
|
|
42
|
-
const cookies =
|
|
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
|
-
|
|
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
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
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);
|
package/dist/tools/auth.js
CHANGED
|
@@ -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
|
|
69
|
+
return rawTextResult(result);
|
|
69
70
|
});
|
|
70
71
|
}
|
package/dist/tools/query.js
CHANGED
|
@@ -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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
22
|
+
return textResult(result);
|
|
22
23
|
});
|
|
23
24
|
}
|
package/dist/tools/sync.js
CHANGED
|
@@ -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
|
|
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.
|
|
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
|
-
"@
|
|
48
|
-
"@fetchproxy/
|
|
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.
|
|
9
|
+
"version": "2.2.2",
|
|
10
10
|
"packages": [
|
|
11
11
|
{
|
|
12
12
|
"registryType": "npm",
|
|
13
13
|
"identifier": "creditkarma-mcp",
|
|
14
|
-
"version": "2.2.
|
|
14
|
+
"version": "2.2.2",
|
|
15
15
|
"transport": {
|
|
16
16
|
"type": "stdio"
|
|
17
17
|
},
|