creditkarma-mcp 1.0.0
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 +195 -0
- package/dist/client.js +173 -0
- package/dist/db.js +116 -0
- package/dist/index.js +73 -0
- package/dist/tools/auth.js +69 -0
- package/dist/tools/query.js +234 -0
- package/dist/tools/sql.js +24 -0
- package/dist/tools/sync.js +126 -0
- package/dist/transaction.graphql +3825 -0
- package/package.json +39 -0
- package/src/transaction.graphql +3825 -0
package/README.md
ADDED
|
@@ -0,0 +1,195 @@
|
|
|
1
|
+
# Credit Karma MCP
|
|
2
|
+
|
|
3
|
+
A [Model Context Protocol](https://modelcontextprotocol.io) server that connects Claude to [Credit Karma](https://www.creditkarma.com), giving you natural-language access to your transactions, spending patterns, and account summaries.
|
|
4
|
+
|
|
5
|
+
> [!WARNING]
|
|
6
|
+
> **AI-developed project.** This codebase was entirely built and is actively maintained by [Claude Sonnet 4.6](https://www.anthropic.com/claude). No human has audited the implementation. Review all code and tool permissions before use.
|
|
7
|
+
|
|
8
|
+
## What you can do
|
|
9
|
+
|
|
10
|
+
Ask Claude things like:
|
|
11
|
+
|
|
12
|
+
- *"Sync my latest transactions"*
|
|
13
|
+
- *"What did I spend on food last month?"*
|
|
14
|
+
- *"Show me my top merchants this year"*
|
|
15
|
+
- *"How much did I spend in March compared to February?"*
|
|
16
|
+
- *"Which accounts have the most activity?"*
|
|
17
|
+
- *"Run a SQL query against my transactions"*
|
|
18
|
+
|
|
19
|
+
## Requirements
|
|
20
|
+
|
|
21
|
+
- [Claude Desktop](https://claude.ai/download) or [Claude Code](https://claude.ai/code)
|
|
22
|
+
- [Node.js](https://nodejs.org) 18 or later
|
|
23
|
+
- A Credit Karma account
|
|
24
|
+
|
|
25
|
+
## Installation
|
|
26
|
+
|
|
27
|
+
### 1. Clone and build
|
|
28
|
+
|
|
29
|
+
```bash
|
|
30
|
+
git clone https://github.com/chrischall/creditkarma-mcp.git
|
|
31
|
+
cd creditkarma-mcp
|
|
32
|
+
npm install
|
|
33
|
+
npm run build
|
|
34
|
+
```
|
|
35
|
+
|
|
36
|
+
### 2. Configure
|
|
37
|
+
|
|
38
|
+
```bash
|
|
39
|
+
cp .env.example .env
|
|
40
|
+
# See "Authentication" below to get your CK_COOKIES value
|
|
41
|
+
```
|
|
42
|
+
|
|
43
|
+
### 3. Add to Claude
|
|
44
|
+
|
|
45
|
+
**Claude Code** — add to `.mcp.json` in your project:
|
|
46
|
+
|
|
47
|
+
```json
|
|
48
|
+
{
|
|
49
|
+
"mcpServers": {
|
|
50
|
+
"creditkarma": {
|
|
51
|
+
"command": "node",
|
|
52
|
+
"args": ["/absolute/path/to/creditkarma-mcp/dist/index.js"]
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
```
|
|
57
|
+
|
|
58
|
+
**Claude Desktop** — edit `~/Library/Application Support/Claude/claude_desktop_config.json` (Mac) or `%APPDATA%\Claude\claude_desktop_config.json` (Windows):
|
|
59
|
+
|
|
60
|
+
```json
|
|
61
|
+
{
|
|
62
|
+
"mcpServers": {
|
|
63
|
+
"creditkarma": {
|
|
64
|
+
"command": "node",
|
|
65
|
+
"args": ["/absolute/path/to/creditkarma-mcp/dist/index.js"],
|
|
66
|
+
"env": {
|
|
67
|
+
"CK_COOKIES": "your-ckat-value-here"
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
```
|
|
73
|
+
|
|
74
|
+
### 4. Restart Claude
|
|
75
|
+
|
|
76
|
+
Fully quit and relaunch. Then ask: *"Sync my Credit Karma transactions"*.
|
|
77
|
+
|
|
78
|
+
## Authentication
|
|
79
|
+
|
|
80
|
+
Credit Karma uses short-lived JWTs. This server handles automatic token refresh — you only need to set up credentials once (or when your session expires).
|
|
81
|
+
|
|
82
|
+
### Getting your credentials
|
|
83
|
+
|
|
84
|
+
1. Log in to [creditkarma.com](https://www.creditkarma.com) in Chrome
|
|
85
|
+
2. Open DevTools → **Application** → **Cookies** → `https://www.creditkarma.com`
|
|
86
|
+
3. Find the `CKAT` cookie and copy its value
|
|
87
|
+
|
|
88
|
+
### Setting credentials
|
|
89
|
+
|
|
90
|
+
Call `ck_set_session` with your cookie value — it accepts any of:
|
|
91
|
+
|
|
92
|
+
| Format | Example |
|
|
93
|
+
|--------|---------|
|
|
94
|
+
| Raw CKAT value | `eyJraWQ...%3BeyJraWQ...` |
|
|
95
|
+
| `CKAT=<value>` | `CKAT=eyJraWQ...%3BeyJraWQ...` |
|
|
96
|
+
| Full Cookie header | *(paste the entire Cookie header from DevTools → Network)* |
|
|
97
|
+
|
|
98
|
+
Or set `CK_COOKIES` directly in your `.env` file (any of the three formats above).
|
|
99
|
+
|
|
100
|
+
The server automatically extracts both the access token and refresh token from the CKAT cookie, and refreshes the access token as needed.
|
|
101
|
+
|
|
102
|
+
### Session expiry
|
|
103
|
+
|
|
104
|
+
- **Access token**: ~15 minutes (auto-refreshed transparently)
|
|
105
|
+
- **Refresh token**: ~8 hours
|
|
106
|
+
- When the refresh token expires, log in to creditkarma.com again, grab the new CKAT cookie, and call `ck_set_session`
|
|
107
|
+
|
|
108
|
+
## Available tools
|
|
109
|
+
|
|
110
|
+
| Tool | What it does |
|
|
111
|
+
|------|-------------|
|
|
112
|
+
| `ck_set_session` | Store credentials from your browser cookies (auto-extracts tokens from CKAT) |
|
|
113
|
+
| `ck_sync_transactions` | Sync transactions into the local SQLite database |
|
|
114
|
+
| `ck_list_transactions` | List transactions with filters (date, account, category, merchant, amount) |
|
|
115
|
+
| `ck_get_recent_transactions` | Fetch the N most recent transactions |
|
|
116
|
+
| `ck_get_spending_by_category` | Spending totals grouped by category |
|
|
117
|
+
| `ck_get_spending_by_merchant` | Spending totals grouped by merchant |
|
|
118
|
+
| `ck_get_account_summary` | Transaction counts and totals by account |
|
|
119
|
+
| `ck_query_sql` | Run a read-only SQL query against the local database |
|
|
120
|
+
|
|
121
|
+
## How it works
|
|
122
|
+
|
|
123
|
+
Transactions are synced from Credit Karma's GraphQL API into a local SQLite database (default: `~/.creditkarma-mcp/transactions.db`). All query tools run against this local database — fast, offline-capable, and queryable with SQL.
|
|
124
|
+
|
|
125
|
+
**Sync strategy**: incremental by default (fetches since last sync date with a 30-day overlap for updates). Use `force_full: true` to re-fetch everything.
|
|
126
|
+
|
|
127
|
+
**Auto-refresh**: if the access token has expired, the server automatically refreshes it before syncing. If the refresh token has also expired, it throws an error asking you to re-authenticate.
|
|
128
|
+
|
|
129
|
+
## Database schema
|
|
130
|
+
|
|
131
|
+
```sql
|
|
132
|
+
transactions (id, date, description, status, amount, account_id, category_id, merchant_id, raw_json)
|
|
133
|
+
accounts (id, name, type, provider_name, display)
|
|
134
|
+
categories (id, name, type)
|
|
135
|
+
merchants (id, name)
|
|
136
|
+
sync_state (key, value)
|
|
137
|
+
```
|
|
138
|
+
|
|
139
|
+
## Configuration
|
|
140
|
+
|
|
141
|
+
| Env var | Description | Default |
|
|
142
|
+
|---------|-------------|---------|
|
|
143
|
+
| `CK_COOKIES` | CKAT value, `CKAT=<value>`, or full Cookie header | *(required)* |
|
|
144
|
+
| `CK_DB_PATH` | Path to SQLite database file | `~/.creditkarma-mcp/transactions.db` |
|
|
145
|
+
|
|
146
|
+
## Troubleshooting
|
|
147
|
+
|
|
148
|
+
**"TOKEN_EXPIRED"** — your refresh token has expired. Log in to creditkarma.com, grab the new CKAT cookie, and call `ck_set_session`.
|
|
149
|
+
|
|
150
|
+
**Sync returns 0 transactions** — check that your `CK_COOKIES` value is fresh. CKAT cookies expire after ~8 hours.
|
|
151
|
+
|
|
152
|
+
**Tools not appearing** — fully quit and relaunch Claude Desktop. In Claude Code, run `/mcp` to check server status.
|
|
153
|
+
|
|
154
|
+
**"No such file or directory: dist/transaction.graphql"** — run `npm run build` (not just `tsc`).
|
|
155
|
+
|
|
156
|
+
## Security
|
|
157
|
+
|
|
158
|
+
- Credentials are stored only in your local `.env` file (gitignored) or Claude config
|
|
159
|
+
- The server never logs credentials
|
|
160
|
+
- Only SELECT queries are permitted via `ck_query_sql` — no writes to Credit Karma
|
|
161
|
+
|
|
162
|
+
## Development
|
|
163
|
+
|
|
164
|
+
```bash
|
|
165
|
+
npm test # run the test suite
|
|
166
|
+
npm run build # compile TypeScript → dist/
|
|
167
|
+
npm run test:watch # watch mode
|
|
168
|
+
```
|
|
169
|
+
|
|
170
|
+
### Project structure
|
|
171
|
+
|
|
172
|
+
```
|
|
173
|
+
src/
|
|
174
|
+
client.ts Credit Karma GraphQL client with auto-refresh
|
|
175
|
+
index.ts MCP server entry point
|
|
176
|
+
db.ts SQLite schema and upsert helpers
|
|
177
|
+
transaction.graphql GraphQL query for transactions
|
|
178
|
+
tools/
|
|
179
|
+
auth.ts ck_set_session
|
|
180
|
+
sync.ts ck_sync_transactions
|
|
181
|
+
query.ts ck_list_transactions, ck_get_recent_transactions, etc.
|
|
182
|
+
sql.ts ck_query_sql
|
|
183
|
+
tests/
|
|
184
|
+
client.test.ts
|
|
185
|
+
db.test.ts
|
|
186
|
+
tools/
|
|
187
|
+
auth.test.ts
|
|
188
|
+
sync.test.ts
|
|
189
|
+
query.test.ts
|
|
190
|
+
sql.test.ts
|
|
191
|
+
```
|
|
192
|
+
|
|
193
|
+
## License
|
|
194
|
+
|
|
195
|
+
MIT
|
package/dist/client.js
ADDED
|
@@ -0,0 +1,173 @@
|
|
|
1
|
+
import { readFileSync } from 'fs';
|
|
2
|
+
import { fileURLToPath } from 'url';
|
|
3
|
+
import { join, dirname } from 'path';
|
|
4
|
+
const TOKEN_TTL_MS = 10 * 60 * 1000; // 10 minutes
|
|
5
|
+
export const GRAPHQL_ENDPOINT = 'https://api.creditkarma.com/graphql';
|
|
6
|
+
export const CK_REFRESH_ENDPOINT = 'https://www.creditkarma.com/member/oauth2/refresh';
|
|
7
|
+
export class CreditKarmaClient {
|
|
8
|
+
token = null;
|
|
9
|
+
tokenSetAt = null;
|
|
10
|
+
refreshToken = null;
|
|
11
|
+
cookies = null;
|
|
12
|
+
constructor(token, refreshToken, cookies) {
|
|
13
|
+
if (token)
|
|
14
|
+
this.setToken(token);
|
|
15
|
+
if (refreshToken)
|
|
16
|
+
this.refreshToken = refreshToken;
|
|
17
|
+
if (cookies)
|
|
18
|
+
this.cookies = cookies;
|
|
19
|
+
}
|
|
20
|
+
setToken(token) {
|
|
21
|
+
this.token = token;
|
|
22
|
+
this.tokenSetAt = Date.now();
|
|
23
|
+
}
|
|
24
|
+
getToken() {
|
|
25
|
+
return this.token;
|
|
26
|
+
}
|
|
27
|
+
getRefreshToken() {
|
|
28
|
+
return this.refreshToken;
|
|
29
|
+
}
|
|
30
|
+
setRefreshToken(token) {
|
|
31
|
+
this.refreshToken = token;
|
|
32
|
+
}
|
|
33
|
+
getCookies() {
|
|
34
|
+
return this.cookies;
|
|
35
|
+
}
|
|
36
|
+
setCookies(cookies) {
|
|
37
|
+
this.cookies = cookies;
|
|
38
|
+
}
|
|
39
|
+
isTokenExpired() {
|
|
40
|
+
if (!this.token || this.tokenSetAt === null)
|
|
41
|
+
return true;
|
|
42
|
+
return Date.now() - this.tokenSetAt > TOKEN_TTL_MS;
|
|
43
|
+
}
|
|
44
|
+
/** Fetch a single page of transactions. Throws TOKEN_EXPIRED on 401. */
|
|
45
|
+
async fetchPage(afterCursor) {
|
|
46
|
+
if (!this.token)
|
|
47
|
+
throw new Error('TOKEN_EXPIRED');
|
|
48
|
+
const response = await this.post(GRAPHQL_ENDPOINT, {
|
|
49
|
+
query: TRANSACTION_QUERY,
|
|
50
|
+
variables: buildVariables(afterCursor)
|
|
51
|
+
});
|
|
52
|
+
if (response.status === 401)
|
|
53
|
+
throw new Error('TOKEN_EXPIRED');
|
|
54
|
+
if (response.status === 429) {
|
|
55
|
+
await sleep(2000);
|
|
56
|
+
const retry = await this.post(GRAPHQL_ENDPOINT, {
|
|
57
|
+
query: TRANSACTION_QUERY,
|
|
58
|
+
variables: buildVariables(afterCursor)
|
|
59
|
+
});
|
|
60
|
+
if (retry.status === 401)
|
|
61
|
+
throw new Error('TOKEN_EXPIRED');
|
|
62
|
+
if (!retry.ok)
|
|
63
|
+
throw new Error(`HTTP ${retry.status}`);
|
|
64
|
+
return parseTransactionPage(await retry.json());
|
|
65
|
+
}
|
|
66
|
+
if (!response.ok)
|
|
67
|
+
throw new Error(`HTTP ${response.status}`);
|
|
68
|
+
return parseTransactionPage(await response.json());
|
|
69
|
+
}
|
|
70
|
+
/**
|
|
71
|
+
* Refresh the access token using CK's native refresh endpoint.
|
|
72
|
+
* Requires a refresh token and session cookies (captured after login).
|
|
73
|
+
*/
|
|
74
|
+
async refreshAccessToken() {
|
|
75
|
+
if (!this.refreshToken)
|
|
76
|
+
throw new Error('NO_REFRESH_TOKEN: Call ck_login first.');
|
|
77
|
+
const headers = {
|
|
78
|
+
'content-type': 'application/json',
|
|
79
|
+
'Origin': 'https://www.creditkarma.com',
|
|
80
|
+
'Referer': 'https://www.creditkarma.com/',
|
|
81
|
+
'User-Agent': 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Safari/537.36',
|
|
82
|
+
'ck-client-name': 'web',
|
|
83
|
+
'ck-client-version': '1.0.0',
|
|
84
|
+
'ck-device-type': 'Desktop',
|
|
85
|
+
};
|
|
86
|
+
if (this.token) {
|
|
87
|
+
headers['authorization'] = `Bearer ${this.token}`;
|
|
88
|
+
// Extract glid from JWT for ck-trace-id
|
|
89
|
+
const glid = extractGlidFromJwt(this.token);
|
|
90
|
+
if (glid)
|
|
91
|
+
headers['ck-trace-id'] = glid;
|
|
92
|
+
// Extract CKTRKID cookie for ck-cookie-id
|
|
93
|
+
const cookieId = extractCookieValue(this.cookies ?? '', 'CKTRKID');
|
|
94
|
+
if (cookieId)
|
|
95
|
+
headers['ck-cookie-id'] = cookieId;
|
|
96
|
+
}
|
|
97
|
+
if (this.cookies)
|
|
98
|
+
headers['Cookie'] = this.cookies;
|
|
99
|
+
const res = await fetch(CK_REFRESH_ENDPOINT, {
|
|
100
|
+
method: 'POST',
|
|
101
|
+
headers,
|
|
102
|
+
body: JSON.stringify({ refreshToken: this.refreshToken })
|
|
103
|
+
});
|
|
104
|
+
if (!res.ok)
|
|
105
|
+
throw new Error(`Token refresh failed: HTTP ${res.status}`);
|
|
106
|
+
const json = await res.json();
|
|
107
|
+
if (json.error || !json.accessToken)
|
|
108
|
+
throw new Error(`Token refresh error: ${json.error ?? 'no accessToken in response'}`);
|
|
109
|
+
this.setToken(json.accessToken);
|
|
110
|
+
if (json.refreshToken)
|
|
111
|
+
this.refreshToken = json.refreshToken;
|
|
112
|
+
return json.accessToken;
|
|
113
|
+
}
|
|
114
|
+
post(url, body) {
|
|
115
|
+
return fetch(url, {
|
|
116
|
+
method: 'POST',
|
|
117
|
+
headers: {
|
|
118
|
+
'Authorization': `Bearer ${this.token}`,
|
|
119
|
+
'Content-Type': 'application/json',
|
|
120
|
+
'Origin': 'https://www.creditkarma.com',
|
|
121
|
+
'Referer': 'https://www.creditkarma.com/',
|
|
122
|
+
'User-Agent': 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Safari/537.36'
|
|
123
|
+
},
|
|
124
|
+
body: JSON.stringify(body)
|
|
125
|
+
});
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
// ---------------------------------------------------------------------------
|
|
129
|
+
// Helpers
|
|
130
|
+
// ---------------------------------------------------------------------------
|
|
131
|
+
function extractGlidFromJwt(token) {
|
|
132
|
+
try {
|
|
133
|
+
const payload = JSON.parse(Buffer.from(token.split('.')[1], 'base64url').toString());
|
|
134
|
+
return payload.glid ?? null;
|
|
135
|
+
}
|
|
136
|
+
catch {
|
|
137
|
+
return null;
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
function extractCookieValue(cookieString, name) {
|
|
141
|
+
const match = cookieString.match(new RegExp(`(?:^|;\\s*)${name}=([^;]+)`));
|
|
142
|
+
return match ? match[1] : null;
|
|
143
|
+
}
|
|
144
|
+
// ---------------------------------------------------------------------------
|
|
145
|
+
// GraphQL query
|
|
146
|
+
// ---------------------------------------------------------------------------
|
|
147
|
+
const _dir = dirname(fileURLToPath(import.meta.url));
|
|
148
|
+
export const TRANSACTION_QUERY = readFileSync(join(_dir, 'transaction.graphql'), 'utf8');
|
|
149
|
+
function buildVariables(afterCursor) {
|
|
150
|
+
return {
|
|
151
|
+
input: {
|
|
152
|
+
paginationInput: { afterCursor: afterCursor ?? null },
|
|
153
|
+
categoryInput: { categoryId: null, primeCategoryType: null },
|
|
154
|
+
datePeriodInput: { datePeriod: null },
|
|
155
|
+
accountInput: {}
|
|
156
|
+
}
|
|
157
|
+
};
|
|
158
|
+
}
|
|
159
|
+
function parseTransactionPage(json) {
|
|
160
|
+
const top = json;
|
|
161
|
+
// CK returns errorCode for some auth failures, errors array for others
|
|
162
|
+
if (top['errorCode'] || top['errors'])
|
|
163
|
+
throw new Error(`TOKEN_EXPIRED`);
|
|
164
|
+
const data = (top['data'] ?? {});
|
|
165
|
+
const prime = data['prime'];
|
|
166
|
+
if (!prime)
|
|
167
|
+
throw new Error(`TOKEN_EXPIRED`);
|
|
168
|
+
const hub = prime['transactionsHub'];
|
|
169
|
+
return (hub['transactionPage']);
|
|
170
|
+
}
|
|
171
|
+
function sleep(ms) {
|
|
172
|
+
return new Promise(resolve => setTimeout(resolve, ms));
|
|
173
|
+
}
|
package/dist/db.js
ADDED
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
import BetterSqlite3 from 'better-sqlite3';
|
|
2
|
+
import { mkdirSync } from 'fs';
|
|
3
|
+
import { dirname } from 'path';
|
|
4
|
+
const CURRENT_VERSION = 1;
|
|
5
|
+
const MIGRATIONS = {
|
|
6
|
+
1: `
|
|
7
|
+
CREATE TABLE IF NOT EXISTS schema_version (version INTEGER PRIMARY KEY);
|
|
8
|
+
|
|
9
|
+
CREATE TABLE IF NOT EXISTS accounts (
|
|
10
|
+
id TEXT PRIMARY KEY,
|
|
11
|
+
name TEXT NOT NULL,
|
|
12
|
+
type TEXT,
|
|
13
|
+
provider_name TEXT,
|
|
14
|
+
display TEXT
|
|
15
|
+
);
|
|
16
|
+
|
|
17
|
+
CREATE TABLE IF NOT EXISTS categories (
|
|
18
|
+
id TEXT PRIMARY KEY,
|
|
19
|
+
name TEXT NOT NULL,
|
|
20
|
+
type TEXT
|
|
21
|
+
);
|
|
22
|
+
|
|
23
|
+
CREATE TABLE IF NOT EXISTS merchants (
|
|
24
|
+
id TEXT PRIMARY KEY,
|
|
25
|
+
name TEXT NOT NULL
|
|
26
|
+
);
|
|
27
|
+
|
|
28
|
+
CREATE TABLE IF NOT EXISTS transactions (
|
|
29
|
+
id TEXT PRIMARY KEY,
|
|
30
|
+
date TEXT NOT NULL,
|
|
31
|
+
description TEXT NOT NULL,
|
|
32
|
+
status TEXT,
|
|
33
|
+
amount REAL NOT NULL,
|
|
34
|
+
account_id TEXT REFERENCES accounts(id),
|
|
35
|
+
category_id TEXT REFERENCES categories(id),
|
|
36
|
+
merchant_id TEXT REFERENCES merchants(id),
|
|
37
|
+
raw_json TEXT,
|
|
38
|
+
synced_at TEXT DEFAULT CURRENT_TIMESTAMP,
|
|
39
|
+
updated_at TEXT DEFAULT CURRENT_TIMESTAMP
|
|
40
|
+
);
|
|
41
|
+
|
|
42
|
+
CREATE TABLE IF NOT EXISTS sync_state (
|
|
43
|
+
key TEXT PRIMARY KEY,
|
|
44
|
+
value TEXT
|
|
45
|
+
);
|
|
46
|
+
|
|
47
|
+
INSERT OR IGNORE INTO schema_version VALUES (1);
|
|
48
|
+
`
|
|
49
|
+
};
|
|
50
|
+
export function initDb(dbPath) {
|
|
51
|
+
if (dbPath !== ':memory:') {
|
|
52
|
+
mkdirSync(dirname(dbPath), { recursive: true });
|
|
53
|
+
}
|
|
54
|
+
const db = new BetterSqlite3(dbPath);
|
|
55
|
+
db.pragma('journal_mode = WAL');
|
|
56
|
+
db.pragma('foreign_keys = ON');
|
|
57
|
+
const tableExists = db
|
|
58
|
+
.prepare("SELECT name FROM sqlite_master WHERE type='table' AND name='schema_version'")
|
|
59
|
+
.get();
|
|
60
|
+
const currentVersion = tableExists
|
|
61
|
+
? (db.prepare('SELECT MAX(version) as v FROM schema_version').get().v ?? 0)
|
|
62
|
+
: 0;
|
|
63
|
+
for (let v = currentVersion + 1; v <= CURRENT_VERSION; v++) {
|
|
64
|
+
db.exec(MIGRATIONS[v]);
|
|
65
|
+
}
|
|
66
|
+
return db;
|
|
67
|
+
}
|
|
68
|
+
export function upsertAccount(db, row) {
|
|
69
|
+
db.prepare(`
|
|
70
|
+
INSERT INTO accounts (id, name, type, provider_name, display)
|
|
71
|
+
VALUES (?, ?, ?, ?, ?)
|
|
72
|
+
ON CONFLICT(id) DO UPDATE SET
|
|
73
|
+
name = excluded.name,
|
|
74
|
+
type = excluded.type,
|
|
75
|
+
provider_name = excluded.provider_name,
|
|
76
|
+
display = excluded.display
|
|
77
|
+
`).run(row.id, row.name, row.type ?? null, row.providerName ?? null, row.display ?? null);
|
|
78
|
+
}
|
|
79
|
+
export function upsertCategory(db, row) {
|
|
80
|
+
db.prepare(`
|
|
81
|
+
INSERT INTO categories (id, name, type)
|
|
82
|
+
VALUES (?, ?, ?)
|
|
83
|
+
ON CONFLICT(id) DO UPDATE SET name = excluded.name, type = excluded.type
|
|
84
|
+
`).run(row.id, row.name, row.type ?? null);
|
|
85
|
+
}
|
|
86
|
+
export function upsertMerchant(db, row) {
|
|
87
|
+
db.prepare(`
|
|
88
|
+
INSERT INTO merchants (id, name)
|
|
89
|
+
VALUES (?, ?)
|
|
90
|
+
ON CONFLICT(id) DO UPDATE SET name = excluded.name
|
|
91
|
+
`).run(row.id, row.name);
|
|
92
|
+
}
|
|
93
|
+
export function upsertTransaction(db, row) {
|
|
94
|
+
db.prepare(`
|
|
95
|
+
INSERT INTO transactions (id, date, description, status, amount, account_id, category_id, merchant_id, raw_json)
|
|
96
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
97
|
+
ON CONFLICT(id) DO UPDATE SET
|
|
98
|
+
date = excluded.date,
|
|
99
|
+
description = excluded.description,
|
|
100
|
+
status = excluded.status,
|
|
101
|
+
amount = excluded.amount,
|
|
102
|
+
account_id = excluded.account_id,
|
|
103
|
+
category_id = excluded.category_id,
|
|
104
|
+
merchant_id = excluded.merchant_id,
|
|
105
|
+
raw_json = excluded.raw_json,
|
|
106
|
+
updated_at = CURRENT_TIMESTAMP
|
|
107
|
+
`).run(row.id, row.date, row.description, row.status, row.amount, row.accountId, row.categoryId, row.merchantId, row.rawJson);
|
|
108
|
+
}
|
|
109
|
+
export function getSyncState(db, key) {
|
|
110
|
+
const row = db.prepare('SELECT value FROM sync_state WHERE key = ?').get(key);
|
|
111
|
+
return row?.value ?? null;
|
|
112
|
+
}
|
|
113
|
+
export function setSyncState(db, key, value) {
|
|
114
|
+
db.prepare('INSERT INTO sync_state (key, value) VALUES (?, ?) ON CONFLICT(key) DO UPDATE SET value = excluded.value')
|
|
115
|
+
.run(key, value);
|
|
116
|
+
}
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
import { config as loadDotenv } from 'dotenv';
|
|
2
|
+
import { Server } from '@modelcontextprotocol/sdk/server/index.js';
|
|
3
|
+
import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
|
|
4
|
+
import { CallToolRequestSchema, ListToolsRequestSchema } from '@modelcontextprotocol/sdk/types.js';
|
|
5
|
+
import { homedir } from 'os';
|
|
6
|
+
import { join } from 'path';
|
|
7
|
+
// Load .env from the project directory; don't override vars already set by .mcp.json
|
|
8
|
+
loadDotenv({ path: join(process.cwd(), '.env'), override: false });
|
|
9
|
+
import { CreditKarmaClient } from './client.js';
|
|
10
|
+
import { initDb } from './db.js';
|
|
11
|
+
import { authToolDefinitions, handleSetSession } from './tools/auth.js';
|
|
12
|
+
import { syncToolDefinitions, handleSyncTransactions } from './tools/sync.js';
|
|
13
|
+
import { queryToolDefinitions, handleListTransactions, handleGetRecentTransactions, handleGetSpendingByCategory, handleGetSpendingByMerchant, handleGetAccountSummary } from './tools/query.js';
|
|
14
|
+
import { sqlToolDefinitions, handleQuerySql } from './tools/sql.js';
|
|
15
|
+
const allTools = [
|
|
16
|
+
...authToolDefinitions,
|
|
17
|
+
...syncToolDefinitions,
|
|
18
|
+
...queryToolDefinitions,
|
|
19
|
+
...sqlToolDefinitions
|
|
20
|
+
];
|
|
21
|
+
function extractCookieValue(cookieString, name) {
|
|
22
|
+
const match = cookieString.match(new RegExp(`(?:^|;\\s*)${name}=([^;]+)`));
|
|
23
|
+
return match ? match[1] : undefined;
|
|
24
|
+
}
|
|
25
|
+
async function main() {
|
|
26
|
+
const dbPath = process.env.CK_DB_PATH || join(homedir(), '.creditkarma-mcp', 'transactions.db');
|
|
27
|
+
const mcpJsonPath = join(process.cwd(), '.mcp.json');
|
|
28
|
+
const cookies = process.env.CK_COOKIES || undefined;
|
|
29
|
+
// Bootstrap tokens from CK_COOKIES: accepts raw CKAT, CKAT=<value>, or full cookie string
|
|
30
|
+
let token;
|
|
31
|
+
let refreshToken;
|
|
32
|
+
if (cookies) {
|
|
33
|
+
const ckat = extractCookieValue(cookies, 'CKAT') ?? cookies.trim();
|
|
34
|
+
const parts = ckat.replace('%3B', ';').split(';');
|
|
35
|
+
token = parts[0]?.trim() || undefined;
|
|
36
|
+
refreshToken = parts[1]?.trim() || undefined;
|
|
37
|
+
}
|
|
38
|
+
const ctx = {
|
|
39
|
+
client: new CreditKarmaClient(token, refreshToken, cookies),
|
|
40
|
+
db: initDb(dbPath),
|
|
41
|
+
mcpJsonPath
|
|
42
|
+
};
|
|
43
|
+
const server = new Server({ name: 'creditkarma-mcp', version: '1.0.0' }, { capabilities: { tools: {} } });
|
|
44
|
+
server.setRequestHandler(ListToolsRequestSchema, async () => ({ tools: allTools }));
|
|
45
|
+
server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
|
46
|
+
const { name, arguments: args = {} } = request.params;
|
|
47
|
+
const result = await dispatch(name, args, ctx);
|
|
48
|
+
return {
|
|
49
|
+
content: [{ type: 'text', text: typeof result === 'string' ? result : JSON.stringify(result, null, 2) }]
|
|
50
|
+
};
|
|
51
|
+
});
|
|
52
|
+
const transport = new StdioServerTransport();
|
|
53
|
+
await server.connect(transport);
|
|
54
|
+
}
|
|
55
|
+
async function dispatch(name, args, ctx) {
|
|
56
|
+
switch (name) {
|
|
57
|
+
// Auth
|
|
58
|
+
case 'ck_set_session': return handleSetSession(args, ctx);
|
|
59
|
+
// Sync
|
|
60
|
+
case 'ck_sync_transactions': return handleSyncTransactions(args, ctx);
|
|
61
|
+
// Query
|
|
62
|
+
case 'ck_list_transactions': return handleListTransactions(args, ctx);
|
|
63
|
+
case 'ck_get_recent_transactions': return handleGetRecentTransactions(args, ctx);
|
|
64
|
+
case 'ck_get_spending_by_category': return handleGetSpendingByCategory(args, ctx);
|
|
65
|
+
case 'ck_get_spending_by_merchant': return handleGetSpendingByMerchant(args, ctx);
|
|
66
|
+
case 'ck_get_account_summary': return handleGetAccountSummary(args, ctx);
|
|
67
|
+
// SQL
|
|
68
|
+
case 'ck_query_sql': return handleQuerySql(args, ctx);
|
|
69
|
+
default:
|
|
70
|
+
throw new Error(`Unknown tool: ${name}`);
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
main().catch(console.error);
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
import { readFileSync, writeFileSync, existsSync } from 'fs';
|
|
2
|
+
import { join, dirname } from 'path';
|
|
3
|
+
export async function handleSetSession(args, ctx) {
|
|
4
|
+
// Accept three formats:
|
|
5
|
+
// 1. Raw CKAT value: eyJ...%3BeyJ... or eyJ...;eyJ...
|
|
6
|
+
// 2. Full Cookie header: CKTRKID=...; CKAT=eyJ...%3BeyJ...; ...
|
|
7
|
+
// 3. Key=value pair: CKAT=eyJ...%3BeyJ...
|
|
8
|
+
const ckat = extractCookieValue(args.cookies, 'CKAT') ?? args.cookies.trim();
|
|
9
|
+
const parts = ckat.replace('%3B', ';').split(';');
|
|
10
|
+
const accessToken = parts[0]?.trim();
|
|
11
|
+
const refreshToken = parts[1]?.trim() ?? null;
|
|
12
|
+
if (!accessToken)
|
|
13
|
+
return 'Session not saved: could not extract a token from the provided value.';
|
|
14
|
+
ctx.client.setToken(accessToken);
|
|
15
|
+
if (refreshToken)
|
|
16
|
+
ctx.client.setRefreshToken(refreshToken);
|
|
17
|
+
ctx.client.setCookies(args.cookies);
|
|
18
|
+
const warning = persistSession(args.cookies, ctx.mcpJsonPath);
|
|
19
|
+
return warning
|
|
20
|
+
? `Session saved. Warning: ${warning}`
|
|
21
|
+
: 'Session saved. Access token, refresh token, and cookies stored.';
|
|
22
|
+
}
|
|
23
|
+
function extractCookieValue(cookieString, name) {
|
|
24
|
+
const match = cookieString.match(new RegExp(`(?:^|;\\s*)${name}=([^;]+)`));
|
|
25
|
+
return match ? match[1] : null;
|
|
26
|
+
}
|
|
27
|
+
/** Persist session to .env. Returns a warning string or null on success. */
|
|
28
|
+
export function persistSession(cookies, mcpJsonPath) {
|
|
29
|
+
if (!cookies)
|
|
30
|
+
return null;
|
|
31
|
+
const envPath = join(dirname(mcpJsonPath), '.env');
|
|
32
|
+
let existing = '';
|
|
33
|
+
if (existsSync(envPath)) {
|
|
34
|
+
try {
|
|
35
|
+
existing = readFileSync(envPath, 'utf8');
|
|
36
|
+
}
|
|
37
|
+
catch {
|
|
38
|
+
return '.env could not be read — session applied in memory only';
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
// Replace or append CK_COOKIES line
|
|
42
|
+
const line = `CK_COOKIES=${cookies}`;
|
|
43
|
+
const updated = existing.match(/^CK_COOKIES=/m)
|
|
44
|
+
? existing.replace(/^CK_COOKIES=.*/m, line)
|
|
45
|
+
: existing + (existing.endsWith('\n') || existing === '' ? '' : '\n') + line + '\n';
|
|
46
|
+
try {
|
|
47
|
+
writeFileSync(envPath, updated);
|
|
48
|
+
}
|
|
49
|
+
catch {
|
|
50
|
+
return '.env could not be written — session applied in memory only';
|
|
51
|
+
}
|
|
52
|
+
return null;
|
|
53
|
+
}
|
|
54
|
+
// Keep old name as alias for tests
|
|
55
|
+
export const persistTokens = persistSession;
|
|
56
|
+
export const authToolDefinitions = [
|
|
57
|
+
{
|
|
58
|
+
name: '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>". Find CKAT in Chrome DevTools → Application → Cookies → creditkarma.com, or copy the Cookie request header from the Network tab.',
|
|
60
|
+
annotations: { readOnlyHint: false },
|
|
61
|
+
inputSchema: {
|
|
62
|
+
type: 'object',
|
|
63
|
+
properties: {
|
|
64
|
+
cookies: { type: 'string', description: 'One of: raw CKAT value, full Cookie header string, or "CKAT=<value>"' },
|
|
65
|
+
},
|
|
66
|
+
required: ['cookies']
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
];
|