creditkarma-mcp 2.0.5 → 2.0.7
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 +43 -34
- package/SKILL.md +11 -13
- package/dist/bundle.js +171 -69
- package/dist/client.js +32 -7
- package/dist/index.js +7 -7
- package/dist/tools/auth.js +13 -11
- package/dist/tools/query.js +19 -10
- package/dist/tools/sync.js +2 -3
- package/package.json +1 -1
- package/server.json +3 -3
package/README.md
CHANGED
|
@@ -65,7 +65,7 @@ cp .env.example .env
|
|
|
65
65
|
"command": "node",
|
|
66
66
|
"args": ["/absolute/path/to/creditkarma-mcp/dist/index.js"],
|
|
67
67
|
"env": {
|
|
68
|
-
"CK_COOKIES": "
|
|
68
|
+
"CK_COOKIES": "CKTRKID=...; CKAT=eyJ...%3BeyJ...; ..."
|
|
69
69
|
}
|
|
70
70
|
}
|
|
71
71
|
}
|
|
@@ -85,11 +85,11 @@ Credit Karma uses short-lived JWTs. This server handles automatic token refresh
|
|
|
85
85
|
#### Option A — scripted (recommended)
|
|
86
86
|
|
|
87
87
|
```bash
|
|
88
|
-
npm run auth # prints the
|
|
89
|
-
npm run auth -- .env # writes CK_COOKIES=<
|
|
88
|
+
npm run auth # prints the Cookie header to the console
|
|
89
|
+
npm run auth -- .env # writes CK_COOKIES=<header> to .env
|
|
90
90
|
```
|
|
91
91
|
|
|
92
|
-
Launches Chrome with a dedicated profile at `~/.creditkarma-mcp/chrome-profile`, waits for you to sign in at creditkarma.com, then captures the
|
|
92
|
+
Launches Chrome with a dedicated profile at `~/.creditkarma-mcp/chrome-profile`, waits for you to sign in at creditkarma.com, then captures the full session Cookie header (CKAT carries the access + refresh JWTs; CKTRKID and friends are needed by the refresh endpoint). Either prints it (for pasting into Claude Desktop / MCPB) or writes it to the env file you pass at mode 0600 (owner-only). Requires Google Chrome installed locally; on first run the script installs `puppeteer-core`, `puppeteer-extra`, and `puppeteer-extra-plugin-stealth` (a few MB, not added to `package.json`).
|
|
93
93
|
|
|
94
94
|
#### Option B — manual paste (secure prompt)
|
|
95
95
|
|
|
@@ -98,40 +98,34 @@ npm run auth -- --manual # prompts for the cookie, prints CK_COOKIES
|
|
|
98
98
|
npm run auth -- --manual .env # prompts for the cookie, writes to .env
|
|
99
99
|
```
|
|
100
100
|
|
|
101
|
-
Use this if the scripted flow hits Intuit/Akamai bot detection (sign-in returns "A technical issue has unexpectedly occurred"). Grab the
|
|
101
|
+
Use this if the scripted flow hits Intuit/Akamai bot detection (sign-in returns "A technical issue has unexpectedly occurred"). Grab the Cookie header from your normal Chrome (Option C below), then paste it at the prompt. Input is **not echoed** — paste, press Enter.
|
|
102
102
|
|
|
103
103
|
#### Option C — manual (DevTools)
|
|
104
104
|
|
|
105
105
|
1. Log in to [creditkarma.com](https://www.creditkarma.com) in Chrome
|
|
106
|
-
2. Open DevTools → **
|
|
107
|
-
3.
|
|
106
|
+
2. Open DevTools → **Network** → click any request to creditkarma.com → **Request Headers**
|
|
107
|
+
3. Right-click the `cookie` header → **Copy value**
|
|
108
108
|
|
|
109
109
|
### Setting credentials
|
|
110
110
|
|
|
111
111
|
Either of these works:
|
|
112
112
|
|
|
113
|
-
- Paste the value from `npm run auth`
|
|
114
|
-
- Or call `ck_set_session` from within Claude with the
|
|
113
|
+
- Paste the value from `npm run auth` into `CK_COOKIES` in your `.env` or Claude config
|
|
114
|
+
- Or call `ck_set_session` from within Claude with the Cookie header value
|
|
115
115
|
|
|
116
|
-
|
|
117
|
-
|--------|---------|
|
|
118
|
-
| Raw CKAT value | `eyJraWQ...%3BeyJraWQ...` |
|
|
119
|
-
| `CKAT=<value>` | `CKAT=eyJraWQ...%3BeyJraWQ...` |
|
|
120
|
-
| Full Cookie header | *(what `npm run auth` prints)* |
|
|
121
|
-
|
|
122
|
-
The server automatically extracts both the access token and refresh token from the CKAT cookie, and refreshes the access token as needed.
|
|
116
|
+
The server extracts the access and refresh JWTs from the `CKAT` cookie inside the header and refreshes the access token automatically as needed.
|
|
123
117
|
|
|
124
118
|
### Session expiry
|
|
125
119
|
|
|
126
120
|
- **Access token**: ~15 minutes (auto-refreshed transparently)
|
|
127
121
|
- **Refresh token**: ~8 hours
|
|
128
|
-
- When the refresh token expires, re-run `npm run auth` (or grab
|
|
122
|
+
- When the refresh token expires, re-run `npm run auth` (or grab a fresh Cookie header from DevTools) and either update `CK_COOKIES` or call `ck_set_session`
|
|
129
123
|
|
|
130
124
|
## Available tools
|
|
131
125
|
|
|
132
126
|
| Tool | What it does |
|
|
133
127
|
|------|-------------|
|
|
134
|
-
| `ck_set_session` | Store credentials from your browser
|
|
128
|
+
| `ck_set_session` | Store credentials from your browser Cookie header (auto-extracts JWTs from the CKAT cookie) |
|
|
135
129
|
| `ck_sync_transactions` | Sync transactions into the local SQLite database |
|
|
136
130
|
| `ck_list_transactions` | List transactions with filters (date, account, category, merchant, amount) |
|
|
137
131
|
| `ck_get_recent_transactions` | Fetch the N most recent transactions |
|
|
@@ -162,14 +156,14 @@ sync_state (key, value)
|
|
|
162
156
|
|
|
163
157
|
| Env var | Description | Default |
|
|
164
158
|
|---------|-------------|---------|
|
|
165
|
-
| `CK_COOKIES` |
|
|
159
|
+
| `CK_COOKIES` | Full Cookie header from a signed-in creditkarma.com request | *(required)* |
|
|
166
160
|
| `CK_DB_PATH` | Path to SQLite database file | `~/.creditkarma-mcp/transactions.db` |
|
|
167
161
|
|
|
168
162
|
## Troubleshooting
|
|
169
163
|
|
|
170
|
-
**"TOKEN_EXPIRED"** — your refresh token has expired. Re-run `npm run auth` (or grab a
|
|
164
|
+
**"TOKEN_EXPIRED"** — your refresh token has expired. Re-run `npm run auth` (or grab a fresh Cookie header) and update `CK_COOKIES` or call `ck_set_session`.
|
|
171
165
|
|
|
172
|
-
**Sync returns 0 transactions** — check that your `CK_COOKIES` value is fresh. CKAT
|
|
166
|
+
**Sync returns 0 transactions** — check that your `CK_COOKIES` value is fresh. The refresh token inside the CKAT cookie expires after ~8 hours.
|
|
173
167
|
|
|
174
168
|
**Tools not appearing** — fully quit and relaunch Claude Desktop. In Claude Code, run `/mcp` to check server status.
|
|
175
169
|
|
|
@@ -178,33 +172,48 @@ sync_state (key, value)
|
|
|
178
172
|
## Security
|
|
179
173
|
|
|
180
174
|
- Credentials are stored only in your local `.env` file (gitignored) or Claude config
|
|
181
|
-
-
|
|
182
|
-
-
|
|
175
|
+
- `.env` is written at mode 0600 (owner read/write only) by both `npm run auth` and `ck_set_session`
|
|
176
|
+
- `ck_set_session` refuses to save a refresh token whose JWT `exp` is already in the past — prevents stale credentials from polluting `.env`
|
|
177
|
+
- The server never logs credentials; warnings go to stderr only (stdout is reserved for the MCP JSON-RPC stream)
|
|
178
|
+
- Only `SELECT` queries are permitted via `ck_query_sql` — no writes to Credit Karma; the underlying `node:sqlite` `prepare()` also rejects multi-statement input
|
|
183
179
|
|
|
184
180
|
## Development
|
|
185
181
|
|
|
186
182
|
```bash
|
|
187
|
-
npm test
|
|
188
|
-
npm run build
|
|
189
|
-
npm run test:watch
|
|
183
|
+
npm test # run the test suite (vitest)
|
|
184
|
+
npm run build # compile TypeScript → dist/, copy transaction.graphql, bundle for MCPB
|
|
185
|
+
npm run test:watch # watch mode
|
|
186
|
+
npm run test:coverage # coverage report (CI enforces 100% on src/**)
|
|
190
187
|
```
|
|
191
188
|
|
|
189
|
+
Versions are bumped automatically by the **Tag & Bump** GitHub Action (`.github/workflows/tag-and-bump.yml`). Do not bump manually.
|
|
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
|
+
|
|
192
195
|
### Project structure
|
|
193
196
|
|
|
194
197
|
```
|
|
195
198
|
src/
|
|
196
|
-
client.ts Credit Karma GraphQL client
|
|
197
|
-
index.ts MCP server entry point
|
|
198
|
-
db.ts SQLite schema and upsert helpers
|
|
199
|
-
transaction.graphql GraphQL query for transactions
|
|
199
|
+
client.ts Credit Karma GraphQL client (auto-refresh, JWT helpers, cookie parser)
|
|
200
|
+
index.ts MCP server entry point; bootstraps tokens from CK_COOKIES
|
|
201
|
+
db.ts SQLite schema, migrations, and upsert helpers
|
|
202
|
+
transaction.graphql GraphQL query for transactions (copied to dist/ at build time)
|
|
200
203
|
tools/
|
|
201
|
-
auth.ts ck_set_session
|
|
202
|
-
sync.ts ck_sync_transactions
|
|
203
|
-
query.ts ck_list_transactions, ck_get_recent_transactions,
|
|
204
|
-
|
|
204
|
+
auth.ts ck_set_session — refuses stale refresh tokens, writes .env at 0600
|
|
205
|
+
sync.ts ck_sync_transactions — incremental sync with resume-on-failure
|
|
206
|
+
query.ts ck_list_transactions, ck_get_recent_transactions,
|
|
207
|
+
ck_get_spending_by_category, ck_get_spending_by_merchant,
|
|
208
|
+
ck_get_account_summary
|
|
209
|
+
sql.ts ck_query_sql — SELECT-only escape hatch
|
|
210
|
+
scripts/
|
|
211
|
+
setup-auth.mjs npm run auth — Puppeteer flow + manual paste fallback
|
|
205
212
|
tests/
|
|
213
|
+
helpers.ts Shared test helpers (fakeServer, makeJwt)
|
|
206
214
|
client.test.ts
|
|
207
215
|
db.test.ts
|
|
216
|
+
setup-auth.test.ts
|
|
208
217
|
tools/
|
|
209
218
|
auth.test.ts
|
|
210
219
|
sync.test.ts
|
package/SKILL.md
CHANGED
|
@@ -23,7 +23,7 @@ Add to `.mcp.json` in your project or `~/.claude/mcp.json`:
|
|
|
23
23
|
"command": "npx",
|
|
24
24
|
"args": ["-y", "creditkarma-mcp"],
|
|
25
25
|
"env": {
|
|
26
|
-
"CK_COOKIES": "
|
|
26
|
+
"CK_COOKIES": "CKTRKID=...; CKAT=eyJ...%3BeyJ...; ..."
|
|
27
27
|
}
|
|
28
28
|
}
|
|
29
29
|
}
|
|
@@ -47,7 +47,7 @@ Then add to `.mcp.json`:
|
|
|
47
47
|
"command": "node",
|
|
48
48
|
"args": ["/path/to/creditkarma-mcp/dist/index.js"],
|
|
49
49
|
"env": {
|
|
50
|
-
"CK_COOKIES": "
|
|
50
|
+
"CK_COOKIES": "CKTRKID=...; CKAT=eyJ...%3BeyJ...; ..."
|
|
51
51
|
}
|
|
52
52
|
}
|
|
53
53
|
}
|
|
@@ -60,33 +60,31 @@ Or use a `.env` file in the project directory with `CK_COOKIES=<value>`.
|
|
|
60
60
|
|
|
61
61
|
**Scripted (recommended — source install):**
|
|
62
62
|
```bash
|
|
63
|
-
npm run auth # prints the
|
|
64
|
-
npm run auth -- .env # writes CK_COOKIES=<
|
|
63
|
+
npm run auth # prints the Cookie header to the console
|
|
64
|
+
npm run auth -- .env # writes CK_COOKIES=<header> to .env
|
|
65
65
|
```
|
|
66
66
|
|
|
67
|
-
Launches Chrome with a dedicated profile, waits for sign-in at creditkarma.com, then captures the
|
|
67
|
+
Launches Chrome with a dedicated profile, waits for sign-in at creditkarma.com, then captures the full session Cookie header (CKAT carries the access + refresh JWTs; CKTRKID and friends are needed by the refresh endpoint). Use the printed value with Claude Desktop / MCPB, or the `.env` form when running from source.
|
|
68
68
|
|
|
69
69
|
**Manual (DevTools):**
|
|
70
70
|
1. Log in to [creditkarma.com](https://www.creditkarma.com) in Chrome
|
|
71
|
-
2. DevTools → **
|
|
72
|
-
3.
|
|
73
|
-
|
|
74
|
-
Accepts: raw CKAT value, `CKAT=<value>`, or the full Cookie header string from any CK network request.
|
|
71
|
+
2. DevTools → **Network** → any creditkarma.com request → **Request Headers**
|
|
72
|
+
3. Right-click the `cookie` header → **Copy value**
|
|
75
73
|
|
|
76
74
|
## Authentication
|
|
77
75
|
|
|
78
|
-
Call `ck_set_session` with your
|
|
76
|
+
Call `ck_set_session` with your Cookie header to store credentials and enable auto-refresh.
|
|
79
77
|
|
|
80
78
|
- Access token: ~15 min TTL, auto-refreshed transparently
|
|
81
79
|
- Refresh token: ~8 hours TTL
|
|
82
|
-
- When expired: re-run `npm run auth` (or grab a
|
|
80
|
+
- When expired: re-run `npm run auth` (or grab a fresh Cookie header) and call `ck_set_session`
|
|
83
81
|
|
|
84
82
|
## Tools
|
|
85
83
|
|
|
86
84
|
### Auth
|
|
87
85
|
| Tool | Description |
|
|
88
86
|
|------|-------------|
|
|
89
|
-
| `ck_set_session(cookies)` | Store credentials —
|
|
87
|
+
| `ck_set_session(cookies)` | Store credentials — paste the full Cookie header from a signed-in creditkarma.com request |
|
|
90
88
|
|
|
91
89
|
### Sync
|
|
92
90
|
| Tool | Description |
|
|
@@ -106,7 +104,7 @@ Call `ck_set_session` with your cookie value to store credentials and enable aut
|
|
|
106
104
|
## Workflows
|
|
107
105
|
|
|
108
106
|
**First-time setup:**
|
|
109
|
-
1. Run `npm run auth` (or grab the
|
|
107
|
+
1. Run `npm run auth` (or grab the Cookie header manually from a creditkarma.com request in DevTools)
|
|
110
108
|
2. Paste into `CK_COOKIES` env var, or call `ck_set_session(cookies)` from within Claude
|
|
111
109
|
3. `ck_sync_transactions` → initial full sync
|
|
112
110
|
|
package/dist/bundle.js
CHANGED
|
@@ -3104,6 +3104,9 @@ var require_utils = __commonJS({
|
|
|
3104
3104
|
"use strict";
|
|
3105
3105
|
var isUUID = RegExp.prototype.test.bind(/^[\da-f]{8}-[\da-f]{4}-[\da-f]{4}-[\da-f]{4}-[\da-f]{12}$/iu);
|
|
3106
3106
|
var isIPv4 = RegExp.prototype.test.bind(/^(?:(?:25[0-5]|2[0-4]\d|1\d{2}|[1-9]\d|\d)\.){3}(?:25[0-5]|2[0-4]\d|1\d{2}|[1-9]\d|\d)$/u);
|
|
3107
|
+
var isHexPair = RegExp.prototype.test.bind(/^[\da-f]{2}$/iu);
|
|
3108
|
+
var isUnreserved = RegExp.prototype.test.bind(/^[\da-z\-._~]$/iu);
|
|
3109
|
+
var isPathCharacter = RegExp.prototype.test.bind(/^[\da-z\-._~!$&'()*+,;=:@/]$/iu);
|
|
3107
3110
|
function stringArrayToHexStripped(input) {
|
|
3108
3111
|
let acc = "";
|
|
3109
3112
|
let code = 0;
|
|
@@ -3296,27 +3299,77 @@ var require_utils = __commonJS({
|
|
|
3296
3299
|
}
|
|
3297
3300
|
return output.join("");
|
|
3298
3301
|
}
|
|
3299
|
-
|
|
3300
|
-
|
|
3301
|
-
|
|
3302
|
-
|
|
3303
|
-
|
|
3304
|
-
|
|
3305
|
-
|
|
3306
|
-
|
|
3307
|
-
|
|
3308
|
-
|
|
3302
|
+
var HOST_DELIMS = { "@": "%40", "/": "%2F", "?": "%3F", "#": "%23", ":": "%3A" };
|
|
3303
|
+
var HOST_DELIM_RE = /[@/?#:]/g;
|
|
3304
|
+
var HOST_DELIM_NO_COLON_RE = /[@/?#]/g;
|
|
3305
|
+
function reescapeHostDelimiters(host, isIP) {
|
|
3306
|
+
const re = isIP ? HOST_DELIM_NO_COLON_RE : HOST_DELIM_RE;
|
|
3307
|
+
re.lastIndex = 0;
|
|
3308
|
+
return host.replace(re, (ch) => HOST_DELIMS[ch]);
|
|
3309
|
+
}
|
|
3310
|
+
function normalizePercentEncoding(input, decodeUnreserved = false) {
|
|
3311
|
+
if (input.indexOf("%") === -1) {
|
|
3312
|
+
return input;
|
|
3309
3313
|
}
|
|
3310
|
-
|
|
3311
|
-
|
|
3314
|
+
let output = "";
|
|
3315
|
+
for (let i = 0; i < input.length; i++) {
|
|
3316
|
+
if (input[i] === "%" && i + 2 < input.length) {
|
|
3317
|
+
const hex3 = input.slice(i + 1, i + 3);
|
|
3318
|
+
if (isHexPair(hex3)) {
|
|
3319
|
+
const normalizedHex = hex3.toUpperCase();
|
|
3320
|
+
const decoded = String.fromCharCode(parseInt(normalizedHex, 16));
|
|
3321
|
+
if (decodeUnreserved && isUnreserved(decoded)) {
|
|
3322
|
+
output += decoded;
|
|
3323
|
+
} else {
|
|
3324
|
+
output += "%" + normalizedHex;
|
|
3325
|
+
}
|
|
3326
|
+
i += 2;
|
|
3327
|
+
continue;
|
|
3328
|
+
}
|
|
3329
|
+
}
|
|
3330
|
+
output += input[i];
|
|
3312
3331
|
}
|
|
3313
|
-
|
|
3314
|
-
|
|
3332
|
+
return output;
|
|
3333
|
+
}
|
|
3334
|
+
function normalizePathEncoding(input) {
|
|
3335
|
+
let output = "";
|
|
3336
|
+
for (let i = 0; i < input.length; i++) {
|
|
3337
|
+
if (input[i] === "%" && i + 2 < input.length) {
|
|
3338
|
+
const hex3 = input.slice(i + 1, i + 3);
|
|
3339
|
+
if (isHexPair(hex3)) {
|
|
3340
|
+
const normalizedHex = hex3.toUpperCase();
|
|
3341
|
+
const decoded = String.fromCharCode(parseInt(normalizedHex, 16));
|
|
3342
|
+
if (decoded !== "." && isUnreserved(decoded)) {
|
|
3343
|
+
output += decoded;
|
|
3344
|
+
} else {
|
|
3345
|
+
output += "%" + normalizedHex;
|
|
3346
|
+
}
|
|
3347
|
+
i += 2;
|
|
3348
|
+
continue;
|
|
3349
|
+
}
|
|
3350
|
+
}
|
|
3351
|
+
if (isPathCharacter(input[i])) {
|
|
3352
|
+
output += input[i];
|
|
3353
|
+
} else {
|
|
3354
|
+
output += escape(input[i]);
|
|
3355
|
+
}
|
|
3315
3356
|
}
|
|
3316
|
-
|
|
3317
|
-
|
|
3357
|
+
return output;
|
|
3358
|
+
}
|
|
3359
|
+
function escapePreservingEscapes(input) {
|
|
3360
|
+
let output = "";
|
|
3361
|
+
for (let i = 0; i < input.length; i++) {
|
|
3362
|
+
if (input[i] === "%" && i + 2 < input.length) {
|
|
3363
|
+
const hex3 = input.slice(i + 1, i + 3);
|
|
3364
|
+
if (isHexPair(hex3)) {
|
|
3365
|
+
output += "%" + hex3.toUpperCase();
|
|
3366
|
+
i += 2;
|
|
3367
|
+
continue;
|
|
3368
|
+
}
|
|
3369
|
+
}
|
|
3370
|
+
output += escape(input[i]);
|
|
3318
3371
|
}
|
|
3319
|
-
return
|
|
3372
|
+
return output;
|
|
3320
3373
|
}
|
|
3321
3374
|
function recomposeAuthority(component) {
|
|
3322
3375
|
const uriTokens = [];
|
|
@@ -3331,7 +3384,7 @@ var require_utils = __commonJS({
|
|
|
3331
3384
|
if (ipV6res.isIPV6 === true) {
|
|
3332
3385
|
host = `[${ipV6res.escapedHost}]`;
|
|
3333
3386
|
} else {
|
|
3334
|
-
host =
|
|
3387
|
+
host = reescapeHostDelimiters(host, false);
|
|
3335
3388
|
}
|
|
3336
3389
|
}
|
|
3337
3390
|
uriTokens.push(host);
|
|
@@ -3345,7 +3398,10 @@ var require_utils = __commonJS({
|
|
|
3345
3398
|
module.exports = {
|
|
3346
3399
|
nonSimpleDomain,
|
|
3347
3400
|
recomposeAuthority,
|
|
3348
|
-
|
|
3401
|
+
reescapeHostDelimiters,
|
|
3402
|
+
normalizePercentEncoding,
|
|
3403
|
+
normalizePathEncoding,
|
|
3404
|
+
escapePreservingEscapes,
|
|
3349
3405
|
removeDotSegments,
|
|
3350
3406
|
isIPv4,
|
|
3351
3407
|
isUUID,
|
|
@@ -3569,12 +3625,12 @@ var require_schemes = __commonJS({
|
|
|
3569
3625
|
var require_fast_uri = __commonJS({
|
|
3570
3626
|
"node_modules/fast-uri/index.js"(exports, module) {
|
|
3571
3627
|
"use strict";
|
|
3572
|
-
var { normalizeIPv6, removeDotSegments, recomposeAuthority,
|
|
3628
|
+
var { normalizeIPv6, removeDotSegments, recomposeAuthority, normalizePercentEncoding, normalizePathEncoding, escapePreservingEscapes, reescapeHostDelimiters, isIPv4, nonSimpleDomain } = require_utils();
|
|
3573
3629
|
var { SCHEMES, getSchemeHandler } = require_schemes();
|
|
3574
3630
|
function normalize(uri, options) {
|
|
3575
3631
|
if (typeof uri === "string") {
|
|
3576
3632
|
uri = /** @type {T} */
|
|
3577
|
-
|
|
3633
|
+
normalizeString(uri, options);
|
|
3578
3634
|
} else if (typeof uri === "object") {
|
|
3579
3635
|
uri = /** @type {T} */
|
|
3580
3636
|
parse3(serialize(uri, options), options);
|
|
@@ -3641,19 +3697,9 @@ var require_fast_uri = __commonJS({
|
|
|
3641
3697
|
return target;
|
|
3642
3698
|
}
|
|
3643
3699
|
function equal(uriA, uriB, options) {
|
|
3644
|
-
|
|
3645
|
-
|
|
3646
|
-
|
|
3647
|
-
} else if (typeof uriA === "object") {
|
|
3648
|
-
uriA = serialize(normalizeComponentEncoding(uriA, true), { ...options, skipEscape: true });
|
|
3649
|
-
}
|
|
3650
|
-
if (typeof uriB === "string") {
|
|
3651
|
-
uriB = unescape(uriB);
|
|
3652
|
-
uriB = serialize(normalizeComponentEncoding(parse3(uriB, options), true), { ...options, skipEscape: true });
|
|
3653
|
-
} else if (typeof uriB === "object") {
|
|
3654
|
-
uriB = serialize(normalizeComponentEncoding(uriB, true), { ...options, skipEscape: true });
|
|
3655
|
-
}
|
|
3656
|
-
return uriA.toLowerCase() === uriB.toLowerCase();
|
|
3700
|
+
const normalizedA = normalizeComparableURI(uriA, options);
|
|
3701
|
+
const normalizedB = normalizeComparableURI(uriB, options);
|
|
3702
|
+
return normalizedA !== void 0 && normalizedB !== void 0 && normalizedA.toLowerCase() === normalizedB.toLowerCase();
|
|
3657
3703
|
}
|
|
3658
3704
|
function serialize(cmpts, opts) {
|
|
3659
3705
|
const component = {
|
|
@@ -3678,12 +3724,12 @@ var require_fast_uri = __commonJS({
|
|
|
3678
3724
|
if (schemeHandler && schemeHandler.serialize) schemeHandler.serialize(component, options);
|
|
3679
3725
|
if (component.path !== void 0) {
|
|
3680
3726
|
if (!options.skipEscape) {
|
|
3681
|
-
component.path =
|
|
3727
|
+
component.path = escapePreservingEscapes(component.path);
|
|
3682
3728
|
if (component.scheme !== void 0) {
|
|
3683
3729
|
component.path = component.path.split("%3A").join(":");
|
|
3684
3730
|
}
|
|
3685
3731
|
} else {
|
|
3686
|
-
component.path =
|
|
3732
|
+
component.path = normalizePercentEncoding(component.path);
|
|
3687
3733
|
}
|
|
3688
3734
|
}
|
|
3689
3735
|
if (options.reference !== "suffix" && component.scheme) {
|
|
@@ -3718,7 +3764,16 @@ var require_fast_uri = __commonJS({
|
|
|
3718
3764
|
return uriTokens.join("");
|
|
3719
3765
|
}
|
|
3720
3766
|
var URI_PARSE = /^(?:([^#/:?]+):)?(?:\/\/((?:([^#/?@]*)@)?(\[[^#/?\]]+\]|[^#/:?]*)(?::(\d*))?))?([^#?]*)(?:\?([^#]*))?(?:#((?:.|[\n\r])*))?/u;
|
|
3721
|
-
function
|
|
3767
|
+
function getParseError(parsed, matches) {
|
|
3768
|
+
if (matches[2] !== void 0 && parsed.path && parsed.path[0] !== "/") {
|
|
3769
|
+
return 'URI path must start with "/" when authority is present.';
|
|
3770
|
+
}
|
|
3771
|
+
if (typeof parsed.port === "number" && (parsed.port < 0 || parsed.port > 65535)) {
|
|
3772
|
+
return "URI port is malformed.";
|
|
3773
|
+
}
|
|
3774
|
+
return void 0;
|
|
3775
|
+
}
|
|
3776
|
+
function parseWithStatus(uri, opts) {
|
|
3722
3777
|
const options = Object.assign({}, opts);
|
|
3723
3778
|
const parsed = {
|
|
3724
3779
|
scheme: void 0,
|
|
@@ -3729,6 +3784,7 @@ var require_fast_uri = __commonJS({
|
|
|
3729
3784
|
query: void 0,
|
|
3730
3785
|
fragment: void 0
|
|
3731
3786
|
};
|
|
3787
|
+
let malformedAuthorityOrPort = false;
|
|
3732
3788
|
let isIP = false;
|
|
3733
3789
|
if (options.reference === "suffix") {
|
|
3734
3790
|
if (options.scheme) {
|
|
@@ -3749,6 +3805,11 @@ var require_fast_uri = __commonJS({
|
|
|
3749
3805
|
if (isNaN(parsed.port)) {
|
|
3750
3806
|
parsed.port = matches[5];
|
|
3751
3807
|
}
|
|
3808
|
+
const parseError = getParseError(parsed, matches);
|
|
3809
|
+
if (parseError !== void 0) {
|
|
3810
|
+
parsed.error = parsed.error || parseError;
|
|
3811
|
+
malformedAuthorityOrPort = true;
|
|
3812
|
+
}
|
|
3752
3813
|
if (parsed.host) {
|
|
3753
3814
|
const ipv4result = isIPv4(parsed.host);
|
|
3754
3815
|
if (ipv4result === false) {
|
|
@@ -3787,14 +3848,18 @@ var require_fast_uri = __commonJS({
|
|
|
3787
3848
|
parsed.scheme = unescape(parsed.scheme);
|
|
3788
3849
|
}
|
|
3789
3850
|
if (parsed.host !== void 0) {
|
|
3790
|
-
parsed.host = unescape(parsed.host);
|
|
3851
|
+
parsed.host = reescapeHostDelimiters(unescape(parsed.host), isIP);
|
|
3791
3852
|
}
|
|
3792
3853
|
}
|
|
3793
3854
|
if (parsed.path) {
|
|
3794
|
-
parsed.path =
|
|
3855
|
+
parsed.path = normalizePathEncoding(parsed.path);
|
|
3795
3856
|
}
|
|
3796
3857
|
if (parsed.fragment) {
|
|
3797
|
-
|
|
3858
|
+
try {
|
|
3859
|
+
parsed.fragment = encodeURI(decodeURIComponent(parsed.fragment));
|
|
3860
|
+
} catch {
|
|
3861
|
+
parsed.error = parsed.error || "URI malformed";
|
|
3862
|
+
}
|
|
3798
3863
|
}
|
|
3799
3864
|
}
|
|
3800
3865
|
if (schemeHandler && schemeHandler.parse) {
|
|
@@ -3803,7 +3868,29 @@ var require_fast_uri = __commonJS({
|
|
|
3803
3868
|
} else {
|
|
3804
3869
|
parsed.error = parsed.error || "URI can not be parsed.";
|
|
3805
3870
|
}
|
|
3806
|
-
return parsed;
|
|
3871
|
+
return { parsed, malformedAuthorityOrPort };
|
|
3872
|
+
}
|
|
3873
|
+
function parse3(uri, opts) {
|
|
3874
|
+
return parseWithStatus(uri, opts).parsed;
|
|
3875
|
+
}
|
|
3876
|
+
function normalizeString(uri, opts) {
|
|
3877
|
+
return normalizeStringWithStatus(uri, opts).normalized;
|
|
3878
|
+
}
|
|
3879
|
+
function normalizeStringWithStatus(uri, opts) {
|
|
3880
|
+
const { parsed, malformedAuthorityOrPort } = parseWithStatus(uri, opts);
|
|
3881
|
+
return {
|
|
3882
|
+
normalized: malformedAuthorityOrPort ? uri : serialize(parsed, opts),
|
|
3883
|
+
malformedAuthorityOrPort
|
|
3884
|
+
};
|
|
3885
|
+
}
|
|
3886
|
+
function normalizeComparableURI(uri, opts) {
|
|
3887
|
+
if (typeof uri === "string") {
|
|
3888
|
+
const { normalized, malformedAuthorityOrPort } = normalizeStringWithStatus(uri, opts);
|
|
3889
|
+
return malformedAuthorityOrPort ? void 0 : normalized;
|
|
3890
|
+
}
|
|
3891
|
+
if (typeof uri === "object") {
|
|
3892
|
+
return serialize(uri, opts);
|
|
3893
|
+
}
|
|
3807
3894
|
}
|
|
3808
3895
|
var fastUri = {
|
|
3809
3896
|
SCHEMES,
|
|
@@ -3943,7 +4030,7 @@ var require_core = __commonJS({
|
|
|
3943
4030
|
constructor(opts = {}) {
|
|
3944
4031
|
this.schemas = {};
|
|
3945
4032
|
this.refs = {};
|
|
3946
|
-
this.formats =
|
|
4033
|
+
this.formats = /* @__PURE__ */ Object.create(null);
|
|
3947
4034
|
this._compilations = /* @__PURE__ */ new Set();
|
|
3948
4035
|
this._loading = {};
|
|
3949
4036
|
this._cache = /* @__PURE__ */ new Map();
|
|
@@ -30931,7 +31018,7 @@ var CreditKarmaClient = class {
|
|
|
30931
31018
|
* Requires a refresh token and session cookies (captured after login).
|
|
30932
31019
|
*/
|
|
30933
31020
|
async refreshAccessToken() {
|
|
30934
|
-
if (!this.refreshToken) throw new Error("NO_REFRESH_TOKEN: Call
|
|
31021
|
+
if (!this.refreshToken) throw new Error("NO_REFRESH_TOKEN: Call ck_set_session first.");
|
|
30935
31022
|
const headers = {
|
|
30936
31023
|
"content-type": "application/json",
|
|
30937
31024
|
"Origin": "https://www.creditkarma.com",
|
|
@@ -30954,7 +31041,13 @@ var CreditKarmaClient = class {
|
|
|
30954
31041
|
headers,
|
|
30955
31042
|
body: JSON.stringify({ refreshToken: this.refreshToken })
|
|
30956
31043
|
});
|
|
30957
|
-
if (!res.ok)
|
|
31044
|
+
if (!res.ok) {
|
|
31045
|
+
const body = await res.text().catch(() => "");
|
|
31046
|
+
const contentType = res.headers.get("content-type") ?? "";
|
|
31047
|
+
const looksHtml = !contentType.includes("json") && /^\s*<(!doctype|html)/i.test(body);
|
|
31048
|
+
const detail = looksHtml ? "(non-JSON error page \u2014 refresh token likely expired or session invalid; re-run `npm run auth`)" : body.length > 200 ? body.slice(0, 200) + "\u2026" : body || "(empty body)";
|
|
31049
|
+
throw new Error(`Token refresh failed: HTTP ${res.status} \u2014 ${detail}`);
|
|
31050
|
+
}
|
|
30958
31051
|
const json2 = await res.json();
|
|
30959
31052
|
if (json2.error || !json2.accessToken) throw new Error(`Token refresh error: ${json2.error ?? "no accessToken in response"}`);
|
|
30960
31053
|
this.setToken(json2.accessToken);
|
|
@@ -30975,14 +31068,23 @@ var CreditKarmaClient = class {
|
|
|
30975
31068
|
});
|
|
30976
31069
|
}
|
|
30977
31070
|
};
|
|
30978
|
-
function
|
|
31071
|
+
function decodeJwtPayload(token) {
|
|
30979
31072
|
try {
|
|
30980
|
-
|
|
30981
|
-
return payload.glid ?? null;
|
|
31073
|
+
return JSON.parse(Buffer.from(token.split(".")[1], "base64url").toString());
|
|
30982
31074
|
} catch {
|
|
30983
31075
|
return null;
|
|
30984
31076
|
}
|
|
30985
31077
|
}
|
|
31078
|
+
function isJwtExpired(token) {
|
|
31079
|
+
const p = decodeJwtPayload(token);
|
|
31080
|
+
if (!p || typeof p.exp !== "number") return false;
|
|
31081
|
+
return p.exp * 1e3 < Date.now();
|
|
31082
|
+
}
|
|
31083
|
+
function extractGlidFromJwt(token) {
|
|
31084
|
+
const p = decodeJwtPayload(token);
|
|
31085
|
+
const glid = p?.glid;
|
|
31086
|
+
return typeof glid === "string" ? glid : null;
|
|
31087
|
+
}
|
|
30986
31088
|
function extractCookieValue(cookieString, name) {
|
|
30987
31089
|
const match = cookieString.match(new RegExp(`(?:^|;\\s*)${name}=([^;]+)`));
|
|
30988
31090
|
return match ? match[1] : null;
|
|
@@ -31139,21 +31241,20 @@ function setSyncState(db, key, value) {
|
|
|
31139
31241
|
import { readFileSync as readFileSync2, writeFileSync, existsSync } from "fs";
|
|
31140
31242
|
import { join as join2, dirname as dirname3 } from "path";
|
|
31141
31243
|
async function handleSetSession(args, ctx) {
|
|
31142
|
-
const ckat =
|
|
31244
|
+
const ckat = extractCookieValue(args.cookies, "CKAT") ?? args.cookies.trim();
|
|
31143
31245
|
const parts = ckat.replace("%3B", ";").split(";");
|
|
31144
31246
|
const accessToken = parts[0]?.trim();
|
|
31145
31247
|
const refreshToken = parts[1]?.trim() ?? null;
|
|
31146
31248
|
if (!accessToken) return "Session not saved: could not extract a token from the provided value.";
|
|
31249
|
+
if (refreshToken && isJwtExpired(refreshToken)) {
|
|
31250
|
+
return "Session not saved: refresh token has already expired. Run `npm run auth` to capture fresh credentials via browser login.";
|
|
31251
|
+
}
|
|
31147
31252
|
ctx.client.setToken(accessToken);
|
|
31148
31253
|
if (refreshToken) ctx.client.setRefreshToken(refreshToken);
|
|
31149
31254
|
ctx.client.setCookies(args.cookies);
|
|
31150
31255
|
const warning = persistSession(args.cookies, ctx.mcpJsonPath);
|
|
31151
31256
|
return warning ? `Session saved. Warning: ${warning}` : "Session saved. Access token, refresh token, and cookies stored.";
|
|
31152
31257
|
}
|
|
31153
|
-
function extractCookieValue2(cookieString, name) {
|
|
31154
|
-
const match = cookieString.match(new RegExp(`(?:^|;\\s*)${name}=([^;]+)`));
|
|
31155
|
-
return match ? match[1] : null;
|
|
31156
|
-
}
|
|
31157
31258
|
function persistSession(cookies, mcpJsonPath) {
|
|
31158
31259
|
if (!cookies) return null;
|
|
31159
31260
|
const envPath = join2(dirname3(mcpJsonPath), ".env");
|
|
@@ -31168,7 +31269,7 @@ function persistSession(cookies, mcpJsonPath) {
|
|
|
31168
31269
|
const line = `CK_COOKIES=${cookies}`;
|
|
31169
31270
|
const updated = existing.match(/^CK_COOKIES=/m) ? existing.replace(/^CK_COOKIES=.*/m, line) : existing + (existing.endsWith("\n") || existing === "" ? "" : "\n") + line + "\n";
|
|
31170
31271
|
try {
|
|
31171
|
-
writeFileSync(envPath, updated);
|
|
31272
|
+
writeFileSync(envPath, updated, { mode: 384 });
|
|
31172
31273
|
} catch {
|
|
31173
31274
|
return ".env could not be written \u2014 session applied in memory only";
|
|
31174
31275
|
}
|
|
@@ -31178,10 +31279,10 @@ function registerAuthTools(server, ctx) {
|
|
|
31178
31279
|
server.registerTool(
|
|
31179
31280
|
"ck_set_session",
|
|
31180
31281
|
{
|
|
31181
|
-
description:
|
|
31282
|
+
description: "Store a Credit Karma session to enable automatic token refresh. Pass the full Cookie header from a signed-in creditkarma.com request (Chrome DevTools \u2192 Network \u2192 any creditkarma.com request \u2192 Request Headers \u2192 right-click the `cookie` header \u2192 Copy value). Capture via `npm run auth` from the creditkarma-mcp repo.",
|
|
31182
31283
|
annotations: { readOnlyHint: false },
|
|
31183
31284
|
inputSchema: {
|
|
31184
|
-
cookies: external_exports.string().describe(
|
|
31285
|
+
cookies: external_exports.string().describe("Full Cookie header from a signed-in creditkarma.com request (contains CKAT, CKTRKID, etc.)")
|
|
31185
31286
|
}
|
|
31186
31287
|
},
|
|
31187
31288
|
async (args) => {
|
|
@@ -31271,7 +31372,7 @@ async function handleSyncTransactions(args, ctx) {
|
|
|
31271
31372
|
}
|
|
31272
31373
|
async function refreshOrThrow(ctx) {
|
|
31273
31374
|
if (!ctx.client.getRefreshToken()) {
|
|
31274
|
-
throw new Error("TOKEN_EXPIRED: No valid token. Run `npm run auth` to capture a fresh Cookie header via browser login, or call ck_set_session with
|
|
31375
|
+
throw new Error("TOKEN_EXPIRED: No valid token. Run `npm run auth` to capture a fresh Cookie header via browser login, or call ck_set_session with the Cookie header from a signed-in creditkarma.com request.");
|
|
31275
31376
|
}
|
|
31276
31377
|
await ctx.client.refreshAccessToken();
|
|
31277
31378
|
}
|
|
@@ -31298,13 +31399,15 @@ function registerSyncTools(server, ctx) {
|
|
|
31298
31399
|
},
|
|
31299
31400
|
async (args) => {
|
|
31300
31401
|
const result = await handleSyncTransactions(args, ctx);
|
|
31301
|
-
|
|
31302
|
-
return { content: [{ type: "text", text }] };
|
|
31402
|
+
return { content: [{ type: "text", text: JSON.stringify(result, null, 2) }] };
|
|
31303
31403
|
}
|
|
31304
31404
|
);
|
|
31305
31405
|
}
|
|
31306
31406
|
|
|
31307
31407
|
// src/tools/query.ts
|
|
31408
|
+
function likePattern(value) {
|
|
31409
|
+
return `%${value.replace(/[%_\\]/g, "\\$&")}%`;
|
|
31410
|
+
}
|
|
31308
31411
|
async function handleListTransactions(args, ctx) {
|
|
31309
31412
|
return queryTransactions(ctx.db, args);
|
|
31310
31413
|
}
|
|
@@ -31347,15 +31450,15 @@ function buildWhere(filters) {
|
|
|
31347
31450
|
}
|
|
31348
31451
|
if (filters.account) {
|
|
31349
31452
|
conditions.push("a.name LIKE ? ESCAPE '\\'");
|
|
31350
|
-
params.push(
|
|
31453
|
+
params.push(likePattern(filters.account));
|
|
31351
31454
|
}
|
|
31352
31455
|
if (filters.category) {
|
|
31353
31456
|
conditions.push("c.name LIKE ? ESCAPE '\\'");
|
|
31354
|
-
params.push(
|
|
31457
|
+
params.push(likePattern(filters.category));
|
|
31355
31458
|
}
|
|
31356
31459
|
if (filters.merchant) {
|
|
31357
31460
|
conditions.push("m.name LIKE ? ESCAPE '\\'");
|
|
31358
|
-
params.push(
|
|
31461
|
+
params.push(likePattern(filters.merchant));
|
|
31359
31462
|
}
|
|
31360
31463
|
if (filters.status) {
|
|
31361
31464
|
conditions.push("t.status = ?");
|
|
@@ -31390,7 +31493,7 @@ async function handleGetSpendingByCategory(args, ctx) {
|
|
|
31390
31493
|
}
|
|
31391
31494
|
if (args.account) {
|
|
31392
31495
|
conditions.push("a.name LIKE ? ESCAPE '\\'");
|
|
31393
|
-
params.push(
|
|
31496
|
+
params.push(likePattern(args.account));
|
|
31394
31497
|
}
|
|
31395
31498
|
const where = `WHERE ${conditions.join(" AND ")}`;
|
|
31396
31499
|
const rows = ctx.db.prepare(`
|
|
@@ -31419,7 +31522,7 @@ async function handleGetSpendingByMerchant(args, ctx) {
|
|
|
31419
31522
|
}
|
|
31420
31523
|
if (args.category) {
|
|
31421
31524
|
conditions.push("c.name LIKE ? ESCAPE '\\'");
|
|
31422
|
-
params.push(
|
|
31525
|
+
params.push(likePattern(args.category));
|
|
31423
31526
|
}
|
|
31424
31527
|
const where = `WHERE ${conditions.join(" AND ")}`;
|
|
31425
31528
|
const limit = args.limit ?? 25;
|
|
@@ -31594,10 +31697,6 @@ try {
|
|
|
31594
31697
|
config2({ path: join3(__dirname, "..", ".env"), override: false, quiet: true });
|
|
31595
31698
|
} catch {
|
|
31596
31699
|
}
|
|
31597
|
-
function extractCookieValue3(cookieString, name) {
|
|
31598
|
-
const match = cookieString.match(new RegExp(`(?:^|;\\s*)${name}=([^;]+)`));
|
|
31599
|
-
return match ? match[1] : void 0;
|
|
31600
|
-
}
|
|
31601
31700
|
async function main() {
|
|
31602
31701
|
const dbPath = readVar("CK_DB_PATH") || join3(homedir(), ".creditkarma-mcp", "transactions.db");
|
|
31603
31702
|
const mcpJsonPath = join3(__dirname, "..", ".mcp.json");
|
|
@@ -31605,18 +31704,21 @@ async function main() {
|
|
|
31605
31704
|
let token;
|
|
31606
31705
|
let refreshToken;
|
|
31607
31706
|
if (cookies) {
|
|
31608
|
-
const ckat =
|
|
31707
|
+
const ckat = extractCookieValue(cookies, "CKAT") ?? cookies.trim();
|
|
31609
31708
|
const parts = ckat.replace("%3B", ";").split(";");
|
|
31610
31709
|
token = parts[0]?.trim() || void 0;
|
|
31611
31710
|
refreshToken = parts[1]?.trim() || void 0;
|
|
31612
31711
|
}
|
|
31712
|
+
if (refreshToken && isJwtExpired(refreshToken)) {
|
|
31713
|
+
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
|
+
}
|
|
31613
31715
|
const ctx = {
|
|
31614
31716
|
client: new CreditKarmaClient(token, refreshToken, cookies),
|
|
31615
31717
|
db: initDb(dbPath),
|
|
31616
31718
|
mcpJsonPath
|
|
31617
31719
|
};
|
|
31618
31720
|
const server = new McpServer(
|
|
31619
|
-
{ name: "creditkarma-mcp", version: "2.0.
|
|
31721
|
+
{ name: "creditkarma-mcp", version: "2.0.7" }
|
|
31620
31722
|
);
|
|
31621
31723
|
registerAuthTools(server, ctx);
|
|
31622
31724
|
registerSyncTools(server, ctx);
|
package/dist/client.js
CHANGED
|
@@ -73,7 +73,7 @@ export class CreditKarmaClient {
|
|
|
73
73
|
*/
|
|
74
74
|
async refreshAccessToken() {
|
|
75
75
|
if (!this.refreshToken)
|
|
76
|
-
throw new Error('NO_REFRESH_TOKEN: Call
|
|
76
|
+
throw new Error('NO_REFRESH_TOKEN: Call ck_set_session first.');
|
|
77
77
|
const headers = {
|
|
78
78
|
'content-type': 'application/json',
|
|
79
79
|
'Origin': 'https://www.creditkarma.com',
|
|
@@ -101,8 +101,15 @@ export class CreditKarmaClient {
|
|
|
101
101
|
headers,
|
|
102
102
|
body: JSON.stringify({ refreshToken: this.refreshToken })
|
|
103
103
|
});
|
|
104
|
-
if (!res.ok)
|
|
105
|
-
|
|
104
|
+
if (!res.ok) {
|
|
105
|
+
const body = await res.text().catch(() => '');
|
|
106
|
+
const contentType = res.headers.get('content-type') ?? '';
|
|
107
|
+
const looksHtml = !contentType.includes('json') && /^\s*<(!doctype|html)/i.test(body);
|
|
108
|
+
const detail = looksHtml
|
|
109
|
+
? '(non-JSON error page — refresh token likely expired or session invalid; re-run `npm run auth`)'
|
|
110
|
+
: (body.length > 200 ? body.slice(0, 200) + '…' : body || '(empty body)');
|
|
111
|
+
throw new Error(`Token refresh failed: HTTP ${res.status} — ${detail}`);
|
|
112
|
+
}
|
|
106
113
|
const json = await res.json();
|
|
107
114
|
if (json.error || !json.accessToken)
|
|
108
115
|
throw new Error(`Token refresh error: ${json.error ?? 'no accessToken in response'}`);
|
|
@@ -128,16 +135,34 @@ export class CreditKarmaClient {
|
|
|
128
135
|
// ---------------------------------------------------------------------------
|
|
129
136
|
// Helpers
|
|
130
137
|
// ---------------------------------------------------------------------------
|
|
131
|
-
|
|
138
|
+
/** Decode the unverified JWT payload claims. Returns null if the token is not
|
|
139
|
+
* a well-formed JWT. We never use this for authorization — only for reading
|
|
140
|
+
* claims (exp, glid) on tokens we already trust. */
|
|
141
|
+
export function decodeJwtPayload(token) {
|
|
132
142
|
try {
|
|
133
|
-
|
|
134
|
-
return payload.glid ?? null;
|
|
143
|
+
return JSON.parse(Buffer.from(token.split('.')[1], 'base64url').toString());
|
|
135
144
|
}
|
|
136
145
|
catch {
|
|
137
146
|
return null;
|
|
138
147
|
}
|
|
139
148
|
}
|
|
140
|
-
|
|
149
|
+
/** True only if we can decode the JWT and its `exp` claim is in the past.
|
|
150
|
+
* Returns false for un-decodable strings (let the API decide) or tokens
|
|
151
|
+
* without an `exp` claim. */
|
|
152
|
+
export function isJwtExpired(token) {
|
|
153
|
+
const p = decodeJwtPayload(token);
|
|
154
|
+
if (!p || typeof p.exp !== 'number')
|
|
155
|
+
return false;
|
|
156
|
+
return p.exp * 1000 < Date.now();
|
|
157
|
+
}
|
|
158
|
+
function extractGlidFromJwt(token) {
|
|
159
|
+
const p = decodeJwtPayload(token);
|
|
160
|
+
const glid = p?.glid;
|
|
161
|
+
return typeof glid === 'string' ? glid : null;
|
|
162
|
+
}
|
|
163
|
+
/** Parse a single cookie value out of a Cookie header string. Exported so the
|
|
164
|
+
* auth tool and bootstrap don't each maintain their own copy. */
|
|
165
|
+
export function extractCookieValue(cookieString, name) {
|
|
141
166
|
const match = cookieString.match(new RegExp(`(?:^|;\\s*)${name}=([^;]+)`));
|
|
142
167
|
return match ? match[1] : null;
|
|
143
168
|
}
|
package/dist/index.js
CHANGED
|
@@ -3,7 +3,7 @@ import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js'
|
|
|
3
3
|
import { homedir } from 'os';
|
|
4
4
|
import { join, dirname } from 'path';
|
|
5
5
|
import { fileURLToPath } from 'url';
|
|
6
|
-
import { CreditKarmaClient } from './client.js';
|
|
6
|
+
import { CreditKarmaClient, isJwtExpired, extractCookieValue } from './client.js';
|
|
7
7
|
import { initDb } from './db.js';
|
|
8
8
|
import { registerAuthTools } from './tools/auth.js';
|
|
9
9
|
import { registerSyncTools } from './tools/sync.js';
|
|
@@ -36,15 +36,12 @@ try {
|
|
|
36
36
|
catch {
|
|
37
37
|
// not available — rely on process.env (mcpb sets credentials via mcp_config.env)
|
|
38
38
|
}
|
|
39
|
-
function extractCookieValue(cookieString, name) {
|
|
40
|
-
const match = cookieString.match(new RegExp(`(?:^|;\\s*)${name}=([^;]+)`));
|
|
41
|
-
return match ? match[1] : undefined;
|
|
42
|
-
}
|
|
43
39
|
async function main() {
|
|
44
40
|
const dbPath = readVar('CK_DB_PATH') || join(homedir(), '.creditkarma-mcp', 'transactions.db');
|
|
45
41
|
const mcpJsonPath = join(__dirname, '..', '.mcp.json');
|
|
46
42
|
const cookies = readVar('CK_COOKIES') || undefined;
|
|
47
|
-
//
|
|
43
|
+
// Canonical CK_COOKIES is a full Cookie header. Parser stays lenient and
|
|
44
|
+
// also accepts a bare CKAT value or `CKAT=<value>` from legacy configs.
|
|
48
45
|
let token;
|
|
49
46
|
let refreshToken;
|
|
50
47
|
if (cookies) {
|
|
@@ -53,12 +50,15 @@ async function main() {
|
|
|
53
50
|
token = parts[0]?.trim() || undefined;
|
|
54
51
|
refreshToken = parts[1]?.trim() || undefined;
|
|
55
52
|
}
|
|
53
|
+
if (refreshToken && isJwtExpired(refreshToken)) {
|
|
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
|
+
}
|
|
56
56
|
const ctx = {
|
|
57
57
|
client: new CreditKarmaClient(token, refreshToken, cookies),
|
|
58
58
|
db: initDb(dbPath),
|
|
59
59
|
mcpJsonPath
|
|
60
60
|
};
|
|
61
|
-
const server = new McpServer({ name: 'creditkarma-mcp', version: '2.0.
|
|
61
|
+
const server = new McpServer({ name: 'creditkarma-mcp', version: '2.0.7' });
|
|
62
62
|
registerAuthTools(server, ctx);
|
|
63
63
|
registerSyncTools(server, ctx);
|
|
64
64
|
registerQueryTools(server, ctx);
|
package/dist/tools/auth.js
CHANGED
|
@@ -1,17 +1,23 @@
|
|
|
1
1
|
import { z } from 'zod';
|
|
2
2
|
import { readFileSync, writeFileSync, existsSync } from 'fs';
|
|
3
3
|
import { join, dirname } from 'path';
|
|
4
|
+
import { isJwtExpired, extractCookieValue } from '../client.js';
|
|
4
5
|
export async function handleSetSession(args, ctx) {
|
|
5
|
-
//
|
|
6
|
-
//
|
|
7
|
-
//
|
|
8
|
-
//
|
|
6
|
+
// Canonical input is the full Cookie header from a signed-in creditkarma.com
|
|
7
|
+
// request (`CKTRKID=...; CKAT=eyJ...%3BeyJ...; ...`). The parser remains
|
|
8
|
+
// lenient and also accepts a bare CKAT value or `CKAT=<value>` for callers
|
|
9
|
+
// that lifted just the cookie value from DevTools.
|
|
9
10
|
const ckat = extractCookieValue(args.cookies, 'CKAT') ?? args.cookies.trim();
|
|
10
11
|
const parts = ckat.replace('%3B', ';').split(';');
|
|
11
12
|
const accessToken = parts[0]?.trim();
|
|
12
13
|
const refreshToken = parts[1]?.trim() ?? null;
|
|
13
14
|
if (!accessToken)
|
|
14
15
|
return 'Session not saved: could not extract a token from the provided value.';
|
|
16
|
+
// Refuse if the refresh JWT is already expired — saving stale credentials
|
|
17
|
+
// pollutes .env and produces confusing HTTP 400s from the refresh endpoint.
|
|
18
|
+
if (refreshToken && isJwtExpired(refreshToken)) {
|
|
19
|
+
return 'Session not saved: refresh token has already expired. Run `npm run auth` to capture fresh credentials via browser login.';
|
|
20
|
+
}
|
|
15
21
|
ctx.client.setToken(accessToken);
|
|
16
22
|
if (refreshToken)
|
|
17
23
|
ctx.client.setRefreshToken(refreshToken);
|
|
@@ -21,10 +27,6 @@ export async function handleSetSession(args, ctx) {
|
|
|
21
27
|
? `Session saved. Warning: ${warning}`
|
|
22
28
|
: 'Session saved. Access token, refresh token, and cookies stored.';
|
|
23
29
|
}
|
|
24
|
-
function extractCookieValue(cookieString, name) {
|
|
25
|
-
const match = cookieString.match(new RegExp(`(?:^|;\\s*)${name}=([^;]+)`));
|
|
26
|
-
return match ? match[1] : null;
|
|
27
|
-
}
|
|
28
30
|
/** Persist session to .env. Returns a warning string or null on success. */
|
|
29
31
|
export function persistSession(cookies, mcpJsonPath) {
|
|
30
32
|
if (!cookies)
|
|
@@ -45,7 +47,7 @@ export function persistSession(cookies, mcpJsonPath) {
|
|
|
45
47
|
? existing.replace(/^CK_COOKIES=.*/m, line)
|
|
46
48
|
: existing + (existing.endsWith('\n') || existing === '' ? '' : '\n') + line + '\n';
|
|
47
49
|
try {
|
|
48
|
-
writeFileSync(envPath, updated);
|
|
50
|
+
writeFileSync(envPath, updated, { mode: 0o600 });
|
|
49
51
|
}
|
|
50
52
|
catch {
|
|
51
53
|
return '.env could not be written — session applied in memory only';
|
|
@@ -56,10 +58,10 @@ export function persistSession(cookies, mcpJsonPath) {
|
|
|
56
58
|
export const persistTokens = persistSession;
|
|
57
59
|
export function registerAuthTools(server, ctx) {
|
|
58
60
|
server.registerTool('ck_set_session', {
|
|
59
|
-
description: 'Store a Credit Karma session to enable automatic token refresh.
|
|
61
|
+
description: 'Store a Credit Karma session to enable automatic token refresh. Pass the full Cookie header from a signed-in creditkarma.com request (Chrome DevTools \u2192 Network \u2192 any creditkarma.com request \u2192 Request Headers \u2192 right-click the `cookie` header \u2192 Copy value). Capture via `npm run auth` from the creditkarma-mcp repo.',
|
|
60
62
|
annotations: { readOnlyHint: false },
|
|
61
63
|
inputSchema: {
|
|
62
|
-
cookies: z.string().describe('
|
|
64
|
+
cookies: z.string().describe('Full Cookie header from a signed-in creditkarma.com request (contains CKAT, CKTRKID, etc.)'),
|
|
63
65
|
},
|
|
64
66
|
}, async (args) => {
|
|
65
67
|
const result = await handleSetSession(args, ctx);
|
package/dist/tools/query.js
CHANGED
|
@@ -1,4 +1,13 @@
|
|
|
1
1
|
import { z } from 'zod';
|
|
2
|
+
// ---------------------------------------------------------------------------
|
|
3
|
+
// Helpers
|
|
4
|
+
// ---------------------------------------------------------------------------
|
|
5
|
+
/** Wrap a user-supplied string as a SQL LIKE pattern with `%`s on both sides
|
|
6
|
+
* and the LIKE metacharacters (`%`, `_`, `\`) escaped. Pair with `ESCAPE '\'`
|
|
7
|
+
* in the SQL fragment. */
|
|
8
|
+
function likePattern(value) {
|
|
9
|
+
return `%${value.replace(/[%_\\]/g, '\\$&')}%`;
|
|
10
|
+
}
|
|
2
11
|
export async function handleListTransactions(args, ctx) {
|
|
3
12
|
return queryTransactions(ctx.db, args);
|
|
4
13
|
}
|
|
@@ -40,16 +49,16 @@ function buildWhere(filters) {
|
|
|
40
49
|
params.push(filters.end_date);
|
|
41
50
|
}
|
|
42
51
|
if (filters.account) {
|
|
43
|
-
conditions.push(
|
|
44
|
-
params.push(
|
|
52
|
+
conditions.push("a.name LIKE ? ESCAPE '\\'");
|
|
53
|
+
params.push(likePattern(filters.account));
|
|
45
54
|
}
|
|
46
55
|
if (filters.category) {
|
|
47
|
-
conditions.push(
|
|
48
|
-
params.push(
|
|
56
|
+
conditions.push("c.name LIKE ? ESCAPE '\\'");
|
|
57
|
+
params.push(likePattern(filters.category));
|
|
49
58
|
}
|
|
50
59
|
if (filters.merchant) {
|
|
51
|
-
conditions.push(
|
|
52
|
-
params.push(
|
|
60
|
+
conditions.push("m.name LIKE ? ESCAPE '\\'");
|
|
61
|
+
params.push(likePattern(filters.merchant));
|
|
53
62
|
}
|
|
54
63
|
if (filters.status) {
|
|
55
64
|
conditions.push('t.status = ?');
|
|
@@ -83,8 +92,8 @@ export async function handleGetSpendingByCategory(args, ctx) {
|
|
|
83
92
|
params.push(args.end_date);
|
|
84
93
|
}
|
|
85
94
|
if (args.account) {
|
|
86
|
-
conditions.push(
|
|
87
|
-
params.push(
|
|
95
|
+
conditions.push("a.name LIKE ? ESCAPE '\\'");
|
|
96
|
+
params.push(likePattern(args.account));
|
|
88
97
|
}
|
|
89
98
|
const where = `WHERE ${conditions.join(' AND ')}`;
|
|
90
99
|
const rows = ctx.db.prepare(`
|
|
@@ -112,8 +121,8 @@ export async function handleGetSpendingByMerchant(args, ctx) {
|
|
|
112
121
|
params.push(args.end_date);
|
|
113
122
|
}
|
|
114
123
|
if (args.category) {
|
|
115
|
-
conditions.push(
|
|
116
|
-
params.push(
|
|
124
|
+
conditions.push("c.name LIKE ? ESCAPE '\\'");
|
|
125
|
+
params.push(likePattern(args.category));
|
|
117
126
|
}
|
|
118
127
|
const where = `WHERE ${conditions.join(' AND ')}`;
|
|
119
128
|
const limit = args.limit ?? 25;
|
package/dist/tools/sync.js
CHANGED
|
@@ -96,7 +96,7 @@ export async function handleSyncTransactions(args, ctx) {
|
|
|
96
96
|
}
|
|
97
97
|
async function refreshOrThrow(ctx) {
|
|
98
98
|
if (!ctx.client.getRefreshToken()) {
|
|
99
|
-
throw new Error('TOKEN_EXPIRED: No valid token. Run `npm run auth` to capture a fresh Cookie header via browser login, or call ck_set_session with
|
|
99
|
+
throw new Error('TOKEN_EXPIRED: No valid token. Run `npm run auth` to capture a fresh Cookie header via browser login, or call ck_set_session with the Cookie header from a signed-in creditkarma.com request.');
|
|
100
100
|
}
|
|
101
101
|
await ctx.client.refreshAccessToken();
|
|
102
102
|
}
|
|
@@ -124,7 +124,6 @@ export function registerSyncTools(server, ctx) {
|
|
|
124
124
|
},
|
|
125
125
|
}, async (args) => {
|
|
126
126
|
const result = await handleSyncTransactions(args, ctx);
|
|
127
|
-
|
|
128
|
-
return { content: [{ type: 'text', text }] };
|
|
127
|
+
return { content: [{ type: 'text', text: JSON.stringify(result, null, 2) }] };
|
|
129
128
|
});
|
|
130
129
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "creditkarma-mcp",
|
|
3
|
-
"version": "2.0.
|
|
3
|
+
"version": "2.0.7",
|
|
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>",
|
package/server.json
CHANGED
|
@@ -6,19 +6,19 @@
|
|
|
6
6
|
"url": "https://github.com/chrischall/creditkarma-mcp",
|
|
7
7
|
"source": "github"
|
|
8
8
|
},
|
|
9
|
-
"version": "2.0.
|
|
9
|
+
"version": "2.0.7",
|
|
10
10
|
"packages": [
|
|
11
11
|
{
|
|
12
12
|
"registryType": "npm",
|
|
13
13
|
"identifier": "creditkarma-mcp",
|
|
14
|
-
"version": "2.0.
|
|
14
|
+
"version": "2.0.7",
|
|
15
15
|
"transport": {
|
|
16
16
|
"type": "stdio"
|
|
17
17
|
},
|
|
18
18
|
"environmentVariables": [
|
|
19
19
|
{
|
|
20
20
|
"name": "CK_COOKIES",
|
|
21
|
-
"description": "
|
|
21
|
+
"description": "The Cookie header from a signed-in creditkarma.com request. Run `npm run auth` to capture via browser login, or copy from DevTools → Network → any creditkarma.com request → Request Headers.",
|
|
22
22
|
"isRequired": false,
|
|
23
23
|
"format": "string",
|
|
24
24
|
"isSecret": true
|