creditkarma-mcp 2.0.6 → 2.0.8
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/README.md +4 -0
- package/SKILL.md +1 -0
- package/dist/accountId.js +11 -0
- package/dist/bundle.js +62 -4
- package/dist/db.js +57 -0
- package/dist/index.js +8 -3
- package/dist/tools/sync.js +4 -2
- package/package.json +4 -2
- package/server.json +2 -2
package/README.md
CHANGED
|
@@ -188,6 +188,10 @@ npm run test:coverage # coverage report (CI enforces 100% on src/**)
|
|
|
188
188
|
|
|
189
189
|
Versions are bumped automatically by the **Tag & Bump** GitHub Action (`.github/workflows/tag-and-bump.yml`). Do not bump manually.
|
|
190
190
|
|
|
191
|
+
### Pull requests
|
|
192
|
+
|
|
193
|
+
Changes land via PR, including for solo work — release notes are generated from merged PRs only (config in `.github/release.yml`). Apply one of these labels to every PR: `enhancement`, `bug`, `security`, `refactor`, `documentation`, `test`, `dependencies`, `ci`, or `ignore-for-release` (excludes from notes). The PR title becomes the changelog bullet, so write it like a user-facing entry.
|
|
194
|
+
|
|
191
195
|
### Project structure
|
|
192
196
|
|
|
193
197
|
```
|
package/SKILL.md
CHANGED
|
@@ -149,3 +149,4 @@ sync_state (key, value)
|
|
|
149
149
|
- Amounts: negative = expense/debit, positive = credit/income
|
|
150
150
|
- `ck_query_sql` only allows SELECT — no writes to Credit Karma data
|
|
151
151
|
- Sync saves a resume cursor — interrupted syncs can be resumed automatically
|
|
152
|
+
- `accounts.id` is a synthesized stable key in the form `<provider>|<last4>` (e.g. `Citi|2630`, `Ally|7133`) because CK's API returns empty `account.id` strings. The same card under two provider-name spellings shows as two rows.
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
export function deriveAccountId(account) {
|
|
2
|
+
if (account.id && account.id.trim() !== '')
|
|
3
|
+
return account.id;
|
|
4
|
+
const provider = (account.providerName ?? '').trim();
|
|
5
|
+
const last4 = extractLast4(account.accountTypeAndNumberDisplay ?? '');
|
|
6
|
+
return `${provider}|${last4}`;
|
|
7
|
+
}
|
|
8
|
+
function extractLast4(display) {
|
|
9
|
+
const m = display.match(/\(\.\.([^)]+)\)/);
|
|
10
|
+
return m?.[1] ?? display;
|
|
11
|
+
}
|
package/dist/bundle.js
CHANGED
|
@@ -31118,6 +31118,20 @@ function sleep(ms) {
|
|
|
31118
31118
|
import { DatabaseSync } from "node:sqlite";
|
|
31119
31119
|
import { mkdirSync } from "fs";
|
|
31120
31120
|
import { dirname as dirname2 } from "path";
|
|
31121
|
+
|
|
31122
|
+
// src/accountId.ts
|
|
31123
|
+
function deriveAccountId(account) {
|
|
31124
|
+
if (account.id && account.id.trim() !== "") return account.id;
|
|
31125
|
+
const provider = (account.providerName ?? "").trim();
|
|
31126
|
+
const last4 = extractLast4(account.accountTypeAndNumberDisplay ?? "");
|
|
31127
|
+
return `${provider}|${last4}`;
|
|
31128
|
+
}
|
|
31129
|
+
function extractLast4(display) {
|
|
31130
|
+
const m = display.match(/\(\.\.([^)]+)\)/);
|
|
31131
|
+
return m?.[1] ?? display;
|
|
31132
|
+
}
|
|
31133
|
+
|
|
31134
|
+
// src/db.ts
|
|
31121
31135
|
var CURRENT_VERSION = 1;
|
|
31122
31136
|
var MIGRATIONS = {
|
|
31123
31137
|
1: `
|
|
@@ -31236,6 +31250,44 @@ function getSyncState(db, key) {
|
|
|
31236
31250
|
function setSyncState(db, key, value) {
|
|
31237
31251
|
db.prepare("INSERT INTO sync_state (key, value) VALUES (?, ?) ON CONFLICT(key) DO UPDATE SET value = excluded.value").run(key, value);
|
|
31238
31252
|
}
|
|
31253
|
+
function backfillAccountIds(db) {
|
|
31254
|
+
const rows = db.prepare("SELECT id, raw_json FROM transactions WHERE account_id IS NULL OR account_id = ''").all();
|
|
31255
|
+
if (rows.length === 0) return { txsUpdated: 0, accountsCreated: 0 };
|
|
31256
|
+
const accounts = /* @__PURE__ */ new Map();
|
|
31257
|
+
const updates = [];
|
|
31258
|
+
for (const row of rows) {
|
|
31259
|
+
if (!row.raw_json) continue;
|
|
31260
|
+
let parsed;
|
|
31261
|
+
try {
|
|
31262
|
+
parsed = JSON.parse(row.raw_json);
|
|
31263
|
+
} catch {
|
|
31264
|
+
continue;
|
|
31265
|
+
}
|
|
31266
|
+
if (!parsed.account) continue;
|
|
31267
|
+
const accountId = deriveAccountId(parsed.account);
|
|
31268
|
+
accounts.set(accountId, {
|
|
31269
|
+
id: accountId,
|
|
31270
|
+
name: parsed.account.name ?? "",
|
|
31271
|
+
type: parsed.account.type ?? null,
|
|
31272
|
+
providerName: parsed.account.providerName ?? null,
|
|
31273
|
+
display: parsed.account.accountTypeAndNumberDisplay ?? null
|
|
31274
|
+
});
|
|
31275
|
+
updates.push({ txId: row.id, accountId });
|
|
31276
|
+
}
|
|
31277
|
+
if (updates.length === 0) return { txsUpdated: 0, accountsCreated: 0 };
|
|
31278
|
+
db.exec("BEGIN");
|
|
31279
|
+
try {
|
|
31280
|
+
for (const acct of accounts.values()) upsertAccount(db, acct);
|
|
31281
|
+
const stmt = db.prepare("UPDATE transactions SET account_id = ? WHERE id = ?");
|
|
31282
|
+
for (const u of updates) stmt.run(u.accountId, u.txId);
|
|
31283
|
+
db.prepare("DELETE FROM accounts WHERE id = ''").run();
|
|
31284
|
+
db.exec("COMMIT");
|
|
31285
|
+
} catch (err) {
|
|
31286
|
+
db.exec("ROLLBACK");
|
|
31287
|
+
throw err;
|
|
31288
|
+
}
|
|
31289
|
+
return { txsUpdated: updates.length, accountsCreated: accounts.size };
|
|
31290
|
+
}
|
|
31239
31291
|
|
|
31240
31292
|
// src/tools/auth.ts
|
|
31241
31293
|
import { readFileSync as readFileSync2, writeFileSync, existsSync } from "fs";
|
|
@@ -31327,8 +31379,9 @@ async function handleSyncTransactions(args, ctx) {
|
|
|
31327
31379
|
try {
|
|
31328
31380
|
for (const tx of page.transactions) {
|
|
31329
31381
|
const exists = ctx.db.prepare("SELECT id FROM transactions WHERE id = ?").get(tx.id);
|
|
31382
|
+
const accountId = deriveAccountId(tx.account);
|
|
31330
31383
|
upsertAccount(ctx.db, {
|
|
31331
|
-
id:
|
|
31384
|
+
id: accountId,
|
|
31332
31385
|
name: tx.account.name,
|
|
31333
31386
|
type: tx.account.type,
|
|
31334
31387
|
providerName: tx.account.providerName,
|
|
@@ -31342,7 +31395,7 @@ async function handleSyncTransactions(args, ctx) {
|
|
|
31342
31395
|
description: tx.description,
|
|
31343
31396
|
status: tx.status,
|
|
31344
31397
|
amount: tx.amount.value,
|
|
31345
|
-
accountId
|
|
31398
|
+
accountId,
|
|
31346
31399
|
categoryId: tx.category?.id ?? null,
|
|
31347
31400
|
merchantId: tx.merchant?.id ?? null,
|
|
31348
31401
|
rawJson: JSON.stringify(tx)
|
|
@@ -31712,13 +31765,18 @@ async function main() {
|
|
|
31712
31765
|
if (refreshToken && isJwtExpired(refreshToken)) {
|
|
31713
31766
|
console.error("[creditkarma-mcp] Warning: refresh token in CK_COOKIES has expired. Run `npm run auth` (or call ck_set_session) to capture fresh credentials.");
|
|
31714
31767
|
}
|
|
31768
|
+
const db = initDb(dbPath);
|
|
31769
|
+
const repaired = backfillAccountIds(db);
|
|
31770
|
+
if (repaired.txsUpdated > 0) {
|
|
31771
|
+
console.error(`[creditkarma-mcp] Repaired ${repaired.txsUpdated} transactions across ${repaired.accountsCreated} accounts (CK returned empty account.id for legacy rows).`);
|
|
31772
|
+
}
|
|
31715
31773
|
const ctx = {
|
|
31716
31774
|
client: new CreditKarmaClient(token, refreshToken, cookies),
|
|
31717
|
-
db
|
|
31775
|
+
db,
|
|
31718
31776
|
mcpJsonPath
|
|
31719
31777
|
};
|
|
31720
31778
|
const server = new McpServer(
|
|
31721
|
-
{ name: "creditkarma-mcp", version: "2.0.
|
|
31779
|
+
{ name: "creditkarma-mcp", version: "2.0.8" }
|
|
31722
31780
|
);
|
|
31723
31781
|
registerAuthTools(server, ctx);
|
|
31724
31782
|
registerSyncTools(server, ctx);
|
package/dist/db.js
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { DatabaseSync } from 'node:sqlite';
|
|
2
2
|
import { mkdirSync } from 'fs';
|
|
3
3
|
import { dirname } from 'path';
|
|
4
|
+
import { deriveAccountId } from './accountId.js';
|
|
4
5
|
const CURRENT_VERSION = 1;
|
|
5
6
|
const MIGRATIONS = {
|
|
6
7
|
1: `
|
|
@@ -114,3 +115,59 @@ export function setSyncState(db, key, value) {
|
|
|
114
115
|
db.prepare('INSERT INTO sync_state (key, value) VALUES (?, ?) ON CONFLICT(key) DO UPDATE SET value = excluded.value')
|
|
115
116
|
.run(key, value);
|
|
116
117
|
}
|
|
118
|
+
/**
|
|
119
|
+
* Repair transactions whose `account_id` is `''` (or NULL) by re-deriving the
|
|
120
|
+
* id from each transaction's `raw_json.account` and rebuilding the accounts
|
|
121
|
+
* table. Idempotent — returns zero counts if there's nothing to fix.
|
|
122
|
+
*
|
|
123
|
+
* Needed because CK's `transactionsHub` historically returned empty
|
|
124
|
+
* `account.id` strings, collapsing every account into a single row.
|
|
125
|
+
*/
|
|
126
|
+
export function backfillAccountIds(db) {
|
|
127
|
+
const rows = db
|
|
128
|
+
.prepare("SELECT id, raw_json FROM transactions WHERE account_id IS NULL OR account_id = ''")
|
|
129
|
+
.all();
|
|
130
|
+
if (rows.length === 0)
|
|
131
|
+
return { txsUpdated: 0, accountsCreated: 0 };
|
|
132
|
+
const accounts = new Map();
|
|
133
|
+
const updates = [];
|
|
134
|
+
for (const row of rows) {
|
|
135
|
+
if (!row.raw_json)
|
|
136
|
+
continue;
|
|
137
|
+
let parsed;
|
|
138
|
+
try {
|
|
139
|
+
parsed = JSON.parse(row.raw_json);
|
|
140
|
+
}
|
|
141
|
+
catch {
|
|
142
|
+
continue;
|
|
143
|
+
}
|
|
144
|
+
if (!parsed.account)
|
|
145
|
+
continue;
|
|
146
|
+
const accountId = deriveAccountId(parsed.account);
|
|
147
|
+
accounts.set(accountId, {
|
|
148
|
+
id: accountId,
|
|
149
|
+
name: parsed.account.name ?? '',
|
|
150
|
+
type: parsed.account.type ?? null,
|
|
151
|
+
providerName: parsed.account.providerName ?? null,
|
|
152
|
+
display: parsed.account.accountTypeAndNumberDisplay ?? null,
|
|
153
|
+
});
|
|
154
|
+
updates.push({ txId: row.id, accountId });
|
|
155
|
+
}
|
|
156
|
+
if (updates.length === 0)
|
|
157
|
+
return { txsUpdated: 0, accountsCreated: 0 };
|
|
158
|
+
db.exec('BEGIN');
|
|
159
|
+
try {
|
|
160
|
+
for (const acct of accounts.values())
|
|
161
|
+
upsertAccount(db, acct);
|
|
162
|
+
const stmt = db.prepare('UPDATE transactions SET account_id = ? WHERE id = ?');
|
|
163
|
+
for (const u of updates)
|
|
164
|
+
stmt.run(u.accountId, u.txId);
|
|
165
|
+
db.prepare("DELETE FROM accounts WHERE id = ''").run();
|
|
166
|
+
db.exec('COMMIT');
|
|
167
|
+
}
|
|
168
|
+
catch (err) {
|
|
169
|
+
db.exec('ROLLBACK');
|
|
170
|
+
throw err;
|
|
171
|
+
}
|
|
172
|
+
return { txsUpdated: updates.length, accountsCreated: accounts.size };
|
|
173
|
+
}
|
package/dist/index.js
CHANGED
|
@@ -4,7 +4,7 @@ import { homedir } from 'os';
|
|
|
4
4
|
import { join, dirname } from 'path';
|
|
5
5
|
import { fileURLToPath } from 'url';
|
|
6
6
|
import { CreditKarmaClient, isJwtExpired, extractCookieValue } from './client.js';
|
|
7
|
-
import { initDb } from './db.js';
|
|
7
|
+
import { initDb, backfillAccountIds } from './db.js';
|
|
8
8
|
import { registerAuthTools } from './tools/auth.js';
|
|
9
9
|
import { registerSyncTools } from './tools/sync.js';
|
|
10
10
|
import { registerQueryTools } from './tools/query.js';
|
|
@@ -53,12 +53,17 @@ async function main() {
|
|
|
53
53
|
if (refreshToken && isJwtExpired(refreshToken)) {
|
|
54
54
|
console.error('[creditkarma-mcp] Warning: refresh token in CK_COOKIES has expired. Run `npm run auth` (or call ck_set_session) to capture fresh credentials.');
|
|
55
55
|
}
|
|
56
|
+
const db = initDb(dbPath);
|
|
57
|
+
const repaired = backfillAccountIds(db);
|
|
58
|
+
if (repaired.txsUpdated > 0) {
|
|
59
|
+
console.error(`[creditkarma-mcp] Repaired ${repaired.txsUpdated} transactions across ${repaired.accountsCreated} accounts (CK returned empty account.id for legacy rows).`);
|
|
60
|
+
}
|
|
56
61
|
const ctx = {
|
|
57
62
|
client: new CreditKarmaClient(token, refreshToken, cookies),
|
|
58
|
-
db
|
|
63
|
+
db,
|
|
59
64
|
mcpJsonPath
|
|
60
65
|
};
|
|
61
|
-
const server = new McpServer({ name: 'creditkarma-mcp', version: '2.0.
|
|
66
|
+
const server = new McpServer({ name: 'creditkarma-mcp', version: '2.0.8' });
|
|
62
67
|
registerAuthTools(server, ctx);
|
|
63
68
|
registerSyncTools(server, ctx);
|
|
64
69
|
registerQueryTools(server, ctx);
|
package/dist/tools/sync.js
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { z } from 'zod';
|
|
2
2
|
import { upsertAccount, upsertCategory, upsertMerchant, upsertTransaction, getSyncState, setSyncState } from '../db.js';
|
|
3
|
+
import { deriveAccountId } from '../accountId.js';
|
|
3
4
|
export async function handleSyncTransactions(args, ctx) {
|
|
4
5
|
// Auto-refresh if token expired and we have a refresh token
|
|
5
6
|
if (ctx.client.isTokenExpired() || !ctx.client.getToken()) {
|
|
@@ -50,8 +51,9 @@ export async function handleSyncTransactions(args, ctx) {
|
|
|
50
51
|
const exists = ctx.db
|
|
51
52
|
.prepare('SELECT id FROM transactions WHERE id = ?')
|
|
52
53
|
.get(tx.id);
|
|
54
|
+
const accountId = deriveAccountId(tx.account);
|
|
53
55
|
upsertAccount(ctx.db, {
|
|
54
|
-
id:
|
|
56
|
+
id: accountId, name: tx.account.name, type: tx.account.type,
|
|
55
57
|
providerName: tx.account.providerName, display: tx.account.accountTypeAndNumberDisplay
|
|
56
58
|
});
|
|
57
59
|
if (tx.category)
|
|
@@ -60,7 +62,7 @@ export async function handleSyncTransactions(args, ctx) {
|
|
|
60
62
|
upsertMerchant(ctx.db, { id: tx.merchant.id, name: tx.merchant.name });
|
|
61
63
|
upsertTransaction(ctx.db, {
|
|
62
64
|
id: tx.id, date: tx.date, description: tx.description, status: tx.status,
|
|
63
|
-
amount: tx.amount.value, accountId
|
|
65
|
+
amount: tx.amount.value, accountId,
|
|
64
66
|
categoryId: tx.category?.id ?? null,
|
|
65
67
|
merchantId: tx.merchant?.id ?? null,
|
|
66
68
|
rawJson: JSON.stringify(tx)
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "creditkarma-mcp",
|
|
3
|
-
"version": "2.0.
|
|
3
|
+
"version": "2.0.8",
|
|
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>",
|
|
@@ -38,6 +38,8 @@
|
|
|
38
38
|
"start": "node dist/index.js",
|
|
39
39
|
"dev": "node --env-file=.env dist/index.js",
|
|
40
40
|
"auth": "node scripts/setup-auth.mjs",
|
|
41
|
+
"inspect:accounts": "node scripts/inspect-accounts.mjs",
|
|
42
|
+
"probe:accounts": "node scripts/probe-account-ids.mjs",
|
|
41
43
|
"test": "vitest run",
|
|
42
44
|
"test:watch": "vitest",
|
|
43
45
|
"test:coverage": "vitest run --coverage"
|
|
@@ -51,7 +53,7 @@
|
|
|
51
53
|
"@types/node": "^25.5.2",
|
|
52
54
|
"@vitest/coverage-v8": "^4.1.2",
|
|
53
55
|
"esbuild": "^0.28.0",
|
|
54
|
-
"puppeteer-core": "^
|
|
56
|
+
"puppeteer-core": "^25.0.4",
|
|
55
57
|
"puppeteer-extra": "^3.3.6",
|
|
56
58
|
"puppeteer-extra-plugin-stealth": "^2.11.2",
|
|
57
59
|
"typescript": "^6.0.2",
|
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.0.
|
|
9
|
+
"version": "2.0.8",
|
|
10
10
|
"packages": [
|
|
11
11
|
{
|
|
12
12
|
"registryType": "npm",
|
|
13
13
|
"identifier": "creditkarma-mcp",
|
|
14
|
-
"version": "2.0.
|
|
14
|
+
"version": "2.0.8",
|
|
15
15
|
"transport": {
|
|
16
16
|
"type": "stdio"
|
|
17
17
|
},
|