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 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": "your-ckat-value-here"
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 CKAT value to the console
89
- npm run auth -- .env # writes CK_COOKIES=<ckat> to .env
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 `CKAT` cookie (the URL-encoded bundle of access + refresh JWTs). Either prints it (for pasting into Claude Desktop / MCPB) or writes it to the env file you pass. Requires Google Chrome installed locally; the script installs `puppeteer-core` on first run (~1 MB).
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 cookie from your normal Chrome (Option C below), then paste it at the prompt. Input is **not echoed** — paste, press Enter. Accepts the raw CKAT value, `CKAT=<value>`, or a full Cookie header.
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 → **Application** → **Cookies** `https://www.creditkarma.com`
107
- 3. Find the `CKAT` cookie and copy its value
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` (or your CKAT cookie) into `CK_COOKIES` in your `.env` or Claude config
114
- - Or call `ck_set_session` from within Claude with the cookie value — it accepts any of:
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
- | Format | Example |
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 the new CKAT cookie from DevTools) and either update `CK_COOKIES` or call `ck_set_session`
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 cookies (auto-extracts tokens from CKAT) |
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` | CKAT value, `CKAT=<value>`, or full Cookie header | *(required)* |
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 new CKAT cookie) and update `CK_COOKIES` or call `ck_set_session`.
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 cookies expire after ~8 hours.
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
- - The server never logs credentials
182
- - Only SELECT queries are permitted via `ck_query_sql` — no writes to Credit Karma
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 # run the test suite
188
- npm run build # compile TypeScript → dist/
189
- npm run test:watch # watch mode
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 with auto-refresh
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, etc.
204
- sql.ts ck_query_sql
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": "your-ckat-value-here"
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": "your-ckat-value-here"
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 CKAT value to the console
64
- npm run auth -- .env # writes CK_COOKIES=<ckat> to .env
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 `CKAT` cookie (the URL-encoded bundle of access + refresh JWTs). Use the printed value with Claude Desktop / MCPB, or the `.env` form when running from source.
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 → **Application** → **Cookies** → `creditkarma.com`
72
- 3. Copy the `CKAT` cookie value
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 cookie value to store credentials and enable auto-refresh.
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 new CKAT cookie) and call `ck_set_session`
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 — accepts CKAT value, `CKAT=<value>`, or full Cookie header |
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 `CKAT` cookie manually from creditkarma.com DevTools)
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
- function normalizeComponentEncoding(component, esc2) {
3300
- const func = esc2 !== true ? escape : unescape;
3301
- if (component.scheme !== void 0) {
3302
- component.scheme = func(component.scheme);
3303
- }
3304
- if (component.userinfo !== void 0) {
3305
- component.userinfo = func(component.userinfo);
3306
- }
3307
- if (component.host !== void 0) {
3308
- component.host = func(component.host);
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
- if (component.path !== void 0) {
3311
- component.path = func(component.path);
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
- if (component.query !== void 0) {
3314
- component.query = func(component.query);
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
- if (component.fragment !== void 0) {
3317
- component.fragment = func(component.fragment);
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 component;
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 = component.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
- normalizeComponentEncoding,
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, normalizeComponentEncoding, isIPv4, nonSimpleDomain } = require_utils();
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
- serialize(parse3(uri, options), options);
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
- if (typeof uriA === "string") {
3645
- uriA = unescape(uriA);
3646
- uriA = serialize(normalizeComponentEncoding(parse3(uriA, options), true), { ...options, skipEscape: true });
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 = escape(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 = unescape(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 parse3(uri, opts) {
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 = escape(unescape(parsed.path));
3855
+ parsed.path = normalizePathEncoding(parsed.path);
3795
3856
  }
3796
3857
  if (parsed.fragment) {
3797
- parsed.fragment = encodeURI(decodeURIComponent(parsed.fragment));
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 ck_login first.");
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) throw new Error(`Token refresh failed: HTTP ${res.status}`);
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 extractGlidFromJwt(token) {
31071
+ function decodeJwtPayload(token) {
30979
31072
  try {
30980
- const payload = JSON.parse(Buffer.from(token.split(".")[1], "base64url").toString());
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 = extractCookieValue2(args.cookies, "CKAT") ?? args.cookies.trim();
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: 'Store a Credit Karma session to enable automatic token refresh. Accepts any of: (1) the raw CKAT cookie value, (2) the full Cookie header string from any creditkarma.com request, or (3) just "CKAT=<value>". Capture via `npm run auth` from the creditkarma-mcp repo, or find CKAT in Chrome DevTools \u2192 Application \u2192 Cookies \u2192 creditkarma.com.',
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('One of: raw CKAT value, full Cookie header string, or "CKAT=<value>"')
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 your CKAT cookie.");
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
- const text = typeof result === "string" ? result : JSON.stringify(result, null, 2);
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(`%${filters.account.replace(/[%_\\]/g, "\\$&")}%`);
31453
+ params.push(likePattern(filters.account));
31351
31454
  }
31352
31455
  if (filters.category) {
31353
31456
  conditions.push("c.name LIKE ? ESCAPE '\\'");
31354
- params.push(`%${filters.category.replace(/[%_\\]/g, "\\$&")}%`);
31457
+ params.push(likePattern(filters.category));
31355
31458
  }
31356
31459
  if (filters.merchant) {
31357
31460
  conditions.push("m.name LIKE ? ESCAPE '\\'");
31358
- params.push(`%${filters.merchant.replace(/[%_\\]/g, "\\$&")}%`);
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(`%${args.account.replace(/[%_\\]/g, "\\$&")}%`);
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(`%${args.category.replace(/[%_\\]/g, "\\$&")}%`);
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 = extractCookieValue3(cookies, "CKAT") ?? cookies.trim();
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.5" }
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 ck_login first.');
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
- throw new Error(`Token refresh failed: HTTP ${res.status}`);
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
- function extractGlidFromJwt(token) {
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
- const payload = JSON.parse(Buffer.from(token.split('.')[1], 'base64url').toString());
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
- function extractCookieValue(cookieString, name) {
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
- // Bootstrap tokens from CK_COOKIES: accepts raw CKAT, CKAT=<value>, or full cookie string
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.5' });
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);
@@ -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
- // Accept three formats:
6
- // 1. Raw CKAT value: eyJ...%3BeyJ... or eyJ...;eyJ...
7
- // 2. Full Cookie header: CKTRKID=...; CKAT=eyJ...%3BeyJ...; ...
8
- // 3. Key=value pair: CKAT=eyJ...%3BeyJ...
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. Accepts any of: (1) the raw CKAT cookie value, (2) the full Cookie header string from any creditkarma.com request, or (3) just "CKAT=<value>". Capture via `npm run auth` from the creditkarma-mcp repo, or find CKAT in Chrome DevTools \u2192 Application \u2192 Cookies \u2192 creditkarma.com.',
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('One of: raw CKAT value, full Cookie header string, or "CKAT=<value>"'),
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);
@@ -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('a.name LIKE ? ESCAPE \'\\\'');
44
- params.push(`%${filters.account.replace(/[%_\\]/g, '\\$&')}%`);
52
+ conditions.push("a.name LIKE ? ESCAPE '\\'");
53
+ params.push(likePattern(filters.account));
45
54
  }
46
55
  if (filters.category) {
47
- conditions.push('c.name LIKE ? ESCAPE \'\\\'');
48
- params.push(`%${filters.category.replace(/[%_\\]/g, '\\$&')}%`);
56
+ conditions.push("c.name LIKE ? ESCAPE '\\'");
57
+ params.push(likePattern(filters.category));
49
58
  }
50
59
  if (filters.merchant) {
51
- conditions.push('m.name LIKE ? ESCAPE \'\\\'');
52
- params.push(`%${filters.merchant.replace(/[%_\\]/g, '\\$&')}%`);
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('a.name LIKE ? ESCAPE \'\\\'');
87
- params.push(`%${args.account.replace(/[%_\\]/g, '\\$&')}%`);
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('c.name LIKE ? ESCAPE \'\\\'');
116
- params.push(`%${args.category.replace(/[%_\\]/g, '\\$&')}%`);
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;
@@ -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 your CKAT cookie.');
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
- const text = typeof result === 'string' ? result : JSON.stringify(result, null, 2);
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.5",
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.5",
9
+ "version": "2.0.7",
10
10
  "packages": [
11
11
  {
12
12
  "registryType": "npm",
13
13
  "identifier": "creditkarma-mcp",
14
- "version": "2.0.5",
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": "Your Credit Karma Cookie header (run `npm run auth` to capture via browser login, or copy CKAT from DevTools)",
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