@warmio/mcp 1.0.0 → 1.0.1
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 +29 -73
- package/dist/index.d.ts +0 -6
- package/dist/index.js +7 -227
- package/dist/install.d.ts +1 -0
- package/dist/install.js +140 -0
- package/dist/server.d.ts +7 -0
- package/dist/server.js +221 -0
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -2,97 +2,53 @@
|
|
|
2
2
|
|
|
3
3
|
MCP server that gives Claude Code (or any MCP client) read-only access to your Warm financial data.
|
|
4
4
|
|
|
5
|
-
## Quick
|
|
5
|
+
## Quick Start
|
|
6
6
|
|
|
7
7
|
```bash
|
|
8
8
|
npx @warmio/mcp
|
|
9
9
|
```
|
|
10
10
|
|
|
11
|
-
|
|
11
|
+
Detects installed MCP clients, prompts for your API key, and configures everything automatically.
|
|
12
|
+
Works on macOS, Linux, and Windows. Supports: Claude Code, Claude Desktop, Cursor, Windsurf, OpenCode, Codex CLI, Antigravity, Gemini CLI.
|
|
12
13
|
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
```bash
|
|
18
|
-
claude mcp add --scope user --env WARM_API_KEY=your-key warm -- npx -y @warmio/mcp
|
|
19
|
-
```
|
|
20
|
-
|
|
21
|
-
### Other Tools
|
|
22
|
-
|
|
23
|
-
Add to your tool's MCP config file:
|
|
24
|
-
|
|
25
|
-
| Tool | Config Path | Format |
|
|
26
|
-
| -------------- | ----------------------------------------------------------------- | ------ |
|
|
27
|
-
| Claude Code | `~/.claude.json` | JSON |
|
|
28
|
-
| Cursor | `~/.cursor/mcp.json` | JSON |
|
|
29
|
-
| Windsurf | `~/.codeium/windsurf/mcp_config.json` | JSON |
|
|
30
|
-
| Claude Desktop | `~/Library/Application Support/Claude/claude_desktop_config.json` | JSON |
|
|
31
|
-
| OpenCode | `~/.config/opencode/opencode.json` | JSON |
|
|
32
|
-
| Codex CLI | `~/.codex/config.toml` | TOML |
|
|
33
|
-
| Antigravity | `~/.gemini/antigravity/mcp_config.json` | JSON |
|
|
34
|
-
| Gemini CLI | `~/.gemini/settings.json` | JSON |
|
|
35
|
-
|
|
36
|
-
**JSON format** (most tools):
|
|
37
|
-
|
|
38
|
-
```json
|
|
39
|
-
{
|
|
40
|
-
"mcpServers": {
|
|
41
|
-
"warm": {
|
|
42
|
-
"command": "npx",
|
|
43
|
-
"args": ["-y", "@warmio/mcp"],
|
|
44
|
-
"env": { "WARM_API_KEY": "your-api-key" }
|
|
45
|
-
}
|
|
46
|
-
}
|
|
47
|
-
}
|
|
48
|
-
```
|
|
49
|
-
|
|
50
|
-
**TOML format** (Codex CLI):
|
|
51
|
-
|
|
52
|
-
```toml
|
|
53
|
-
[mcp_servers.warm]
|
|
54
|
-
command = "npx"
|
|
55
|
-
args = ["-y", "@warmio/mcp"]
|
|
14
|
+
After setup, open your MCP client and ask:
|
|
15
|
+
- "What's my net worth?"
|
|
16
|
+
- "How much did I spend on restaurants last month?"
|
|
17
|
+
- "Show me my subscriptions"
|
|
56
18
|
|
|
57
|
-
|
|
58
|
-
WARM_API_KEY = "your-api-key"
|
|
59
|
-
```
|
|
19
|
+
## Options
|
|
60
20
|
|
|
61
|
-
|
|
21
|
+
| Command | Description |
|
|
22
|
+
|---------|-------------|
|
|
23
|
+
| `npx @warmio/mcp` | Run the installer / configurator |
|
|
24
|
+
| `npx @warmio/mcp --force` | Re-run installer (updates API key in all configs) |
|
|
25
|
+
| `npx @warmio/mcp --server` | Start the MCP server (used internally by clients) |
|
|
62
26
|
|
|
63
27
|
## Requirements
|
|
64
28
|
|
|
65
29
|
- **Pro subscription** — API access requires Warm Pro
|
|
66
|
-
- **API key** — Generate in Settings → API Keys
|
|
30
|
+
- **API key** — Generate in [Settings → API Keys](https://warm.io/settings)
|
|
67
31
|
- **Node.js 18+** — For running the MCP server
|
|
68
32
|
|
|
69
|
-
##
|
|
33
|
+
## How It Works
|
|
70
34
|
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
| `get_snapshots` | Net worth history (daily or monthly) |
|
|
77
|
-
| `verify_key` | Check if API key is valid |
|
|
35
|
+
1. You run `npx @warmio/mcp` once
|
|
36
|
+
2. It detects which MCP clients you have installed
|
|
37
|
+
3. Prompts for your Warm API key
|
|
38
|
+
4. Writes the server config into each client's settings
|
|
39
|
+
5. Each client is configured to run `npx -y @warmio/mcp --server` on demand
|
|
78
40
|
|
|
79
|
-
|
|
41
|
+
The MCP server starts automatically when your client needs it — you never run it manually.
|
|
80
42
|
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
- "How much did I spend on restaurants last month?"
|
|
84
|
-
- "What's my net worth?"
|
|
85
|
-
- "Show my subscriptions"
|
|
86
|
-
- "What are my biggest expenses?"
|
|
87
|
-
|
|
88
|
-
Claude will automatically use the MCP tools to query your data.
|
|
89
|
-
|
|
90
|
-
## Configuration
|
|
43
|
+
## Available Tools
|
|
91
44
|
|
|
92
|
-
|
|
|
93
|
-
|
|
94
|
-
| `
|
|
95
|
-
| `
|
|
45
|
+
| Tool | Description |
|
|
46
|
+
|------|-------------|
|
|
47
|
+
| `get_accounts` | List all connected bank accounts with balances |
|
|
48
|
+
| `get_transactions` | Get transactions with date/limit filters |
|
|
49
|
+
| `get_recurring` | Show subscriptions and recurring payments |
|
|
50
|
+
| `get_snapshots` | Net worth history (daily or monthly) |
|
|
51
|
+
| `verify_key` | Check if API key is valid |
|
|
96
52
|
|
|
97
53
|
## Security
|
|
98
54
|
|
package/dist/index.d.ts
CHANGED
package/dist/index.js
CHANGED
|
@@ -1,230 +1,10 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
* Provides financial data from the Warm API as MCP tools.
|
|
6
|
-
* Reads API key from ~/.config/warm/api_key or WARM_API_KEY env var.
|
|
7
|
-
*/
|
|
8
|
-
import { Server } from '@modelcontextprotocol/sdk/server/index.js';
|
|
9
|
-
import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
|
|
10
|
-
import { CallToolRequestSchema, ListToolsRequestSchema } from '@modelcontextprotocol/sdk/types.js';
|
|
11
|
-
import * as fs from 'fs';
|
|
12
|
-
import * as path from 'path';
|
|
13
|
-
import * as os from 'os';
|
|
14
|
-
const API_URL = process.env.WARM_API_URL || 'https://warm.io';
|
|
15
|
-
function getApiKey() {
|
|
16
|
-
// Environment variable takes precedence
|
|
17
|
-
if (process.env.WARM_API_KEY) {
|
|
18
|
-
return process.env.WARM_API_KEY;
|
|
19
|
-
}
|
|
20
|
-
// Fall back to config file
|
|
21
|
-
const configPath = path.join(os.homedir(), '.config', 'warm', 'api_key');
|
|
22
|
-
try {
|
|
23
|
-
return fs.readFileSync(configPath, 'utf-8').trim();
|
|
24
|
-
}
|
|
25
|
-
catch {
|
|
26
|
-
return null;
|
|
27
|
-
}
|
|
2
|
+
const args = process.argv.slice(2);
|
|
3
|
+
if (args.includes('--server')) {
|
|
4
|
+
await import('./server.js');
|
|
28
5
|
}
|
|
29
|
-
|
|
30
|
-
const
|
|
31
|
-
|
|
32
|
-
throw new Error('WARM_API_KEY not set. Add your API key from https://warm.io/settings');
|
|
33
|
-
}
|
|
34
|
-
const url = new URL(endpoint, API_URL);
|
|
35
|
-
Object.entries(params).forEach(([key, value]) => {
|
|
36
|
-
if (value)
|
|
37
|
-
url.searchParams.append(key, value);
|
|
38
|
-
});
|
|
39
|
-
const response = await fetch(url.toString(), {
|
|
40
|
-
headers: {
|
|
41
|
-
Authorization: `Bearer ${apiKey}`,
|
|
42
|
-
Accept: 'application/json',
|
|
43
|
-
},
|
|
44
|
-
});
|
|
45
|
-
if (!response.ok) {
|
|
46
|
-
const errorMessages = {
|
|
47
|
-
401: 'Invalid or expired API key. Regenerate at https://warm.io/settings',
|
|
48
|
-
403: 'Pro subscription required. Upgrade at https://warm.io/settings',
|
|
49
|
-
429: 'Rate limit exceeded. Try again in a few minutes.',
|
|
50
|
-
};
|
|
51
|
-
throw new Error(errorMessages[response.status] || `HTTP ${response.status}`);
|
|
52
|
-
}
|
|
53
|
-
return response.json();
|
|
6
|
+
else {
|
|
7
|
+
const { install } = await import('./install.js');
|
|
8
|
+
await install();
|
|
54
9
|
}
|
|
55
|
-
|
|
56
|
-
server.setRequestHandler(ListToolsRequestSchema, async () => ({
|
|
57
|
-
tools: [
|
|
58
|
-
{
|
|
59
|
-
name: 'get_accounts',
|
|
60
|
-
description: 'Get all connected bank accounts with balances. Returns account names, types, balances, and institutions.',
|
|
61
|
-
inputSchema: {
|
|
62
|
-
type: 'object',
|
|
63
|
-
properties: {
|
|
64
|
-
since: {
|
|
65
|
-
type: 'string',
|
|
66
|
-
description: 'Filter accounts updated since this date (ISO format)',
|
|
67
|
-
},
|
|
68
|
-
},
|
|
69
|
-
},
|
|
70
|
-
},
|
|
71
|
-
{
|
|
72
|
-
name: 'get_transactions',
|
|
73
|
-
description: 'Get transactions from connected accounts. Supports pagination and date filtering.',
|
|
74
|
-
inputSchema: {
|
|
75
|
-
type: 'object',
|
|
76
|
-
properties: {
|
|
77
|
-
limit: {
|
|
78
|
-
type: 'number',
|
|
79
|
-
description: 'Maximum number of transactions to return (default: 100)',
|
|
80
|
-
},
|
|
81
|
-
since: {
|
|
82
|
-
type: 'string',
|
|
83
|
-
description: 'Get transactions since this date (ISO format, e.g., 2024-01-01)',
|
|
84
|
-
},
|
|
85
|
-
cursor: {
|
|
86
|
-
type: 'string',
|
|
87
|
-
description: 'Pagination cursor for fetching more results',
|
|
88
|
-
},
|
|
89
|
-
},
|
|
90
|
-
},
|
|
91
|
-
},
|
|
92
|
-
{
|
|
93
|
-
name: 'get_recurring',
|
|
94
|
-
description: 'Get recurring payments and subscriptions detected from transaction history.',
|
|
95
|
-
inputSchema: {
|
|
96
|
-
type: 'object',
|
|
97
|
-
properties: {
|
|
98
|
-
since: {
|
|
99
|
-
type: 'string',
|
|
100
|
-
description: 'Filter recurring items detected since this date',
|
|
101
|
-
},
|
|
102
|
-
},
|
|
103
|
-
},
|
|
104
|
-
},
|
|
105
|
-
{
|
|
106
|
-
name: 'get_snapshots',
|
|
107
|
-
description: 'Get net worth snapshots over time (daily or monthly aggregation).',
|
|
108
|
-
inputSchema: {
|
|
109
|
-
type: 'object',
|
|
110
|
-
properties: {
|
|
111
|
-
granularity: {
|
|
112
|
-
type: 'string',
|
|
113
|
-
enum: ['daily', 'monthly'],
|
|
114
|
-
description: 'Aggregation level (default: daily)',
|
|
115
|
-
},
|
|
116
|
-
limit: {
|
|
117
|
-
type: 'number',
|
|
118
|
-
description: 'Number of snapshots to return',
|
|
119
|
-
},
|
|
120
|
-
since: {
|
|
121
|
-
type: 'string',
|
|
122
|
-
description: 'Start date for snapshots (ISO format)',
|
|
123
|
-
},
|
|
124
|
-
},
|
|
125
|
-
},
|
|
126
|
-
},
|
|
127
|
-
{
|
|
128
|
-
name: 'verify_key',
|
|
129
|
-
description: 'Check if the Warm API key is valid and working.',
|
|
130
|
-
inputSchema: {
|
|
131
|
-
type: 'object',
|
|
132
|
-
properties: {},
|
|
133
|
-
},
|
|
134
|
-
},
|
|
135
|
-
],
|
|
136
|
-
}));
|
|
137
|
-
server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
|
138
|
-
const { name, arguments: args } = request.params;
|
|
139
|
-
try {
|
|
140
|
-
switch (name) {
|
|
141
|
-
case 'get_accounts': {
|
|
142
|
-
const params = {};
|
|
143
|
-
if (args?.since)
|
|
144
|
-
params.since = String(args.since);
|
|
145
|
-
const data = await apiRequest('/api/accounts', params);
|
|
146
|
-
return { content: [{ type: 'text', text: JSON.stringify(data, null, 2) }] };
|
|
147
|
-
}
|
|
148
|
-
case 'get_transactions': {
|
|
149
|
-
const params = {};
|
|
150
|
-
if (args?.limit)
|
|
151
|
-
params.limit = String(args.limit);
|
|
152
|
-
if (args?.since)
|
|
153
|
-
params.last_knowledge = String(args.since);
|
|
154
|
-
if (args?.cursor)
|
|
155
|
-
params.cursor = String(args.cursor);
|
|
156
|
-
const data = await apiRequest('/api/transactions', params);
|
|
157
|
-
return { content: [{ type: 'text', text: JSON.stringify(data, null, 2) }] };
|
|
158
|
-
}
|
|
159
|
-
case 'get_recurring': {
|
|
160
|
-
const params = {};
|
|
161
|
-
if (args?.since)
|
|
162
|
-
params.since = String(args.since);
|
|
163
|
-
const data = await apiRequest('/api/recurring', params);
|
|
164
|
-
return { content: [{ type: 'text', text: JSON.stringify(data, null, 2) }] };
|
|
165
|
-
}
|
|
166
|
-
case 'get_snapshots': {
|
|
167
|
-
// Fetch transactions which includes snapshots
|
|
168
|
-
const response = (await apiRequest('/api/transactions', {
|
|
169
|
-
limit: '1',
|
|
170
|
-
}));
|
|
171
|
-
const snapshots = response.snapshots || [];
|
|
172
|
-
const granularity = args?.granularity || 'daily';
|
|
173
|
-
const limit = args?.limit ? Number(args.limit) : granularity === 'daily' ? 100 : 0;
|
|
174
|
-
const since = args?.since;
|
|
175
|
-
let filtered = snapshots;
|
|
176
|
-
if (since) {
|
|
177
|
-
filtered = filtered.filter((s) => String(s.snapshot_date) >= since);
|
|
178
|
-
}
|
|
179
|
-
if (granularity === 'monthly') {
|
|
180
|
-
// Group by month, take last snapshot of each month
|
|
181
|
-
const byMonth = new Map();
|
|
182
|
-
filtered.forEach((s) => {
|
|
183
|
-
const month = String(s.snapshot_date).substring(0, 7);
|
|
184
|
-
if (!byMonth.has(month) ||
|
|
185
|
-
String(s.snapshot_date) > String(byMonth.get(month).snapshot_date)) {
|
|
186
|
-
byMonth.set(month, s);
|
|
187
|
-
}
|
|
188
|
-
});
|
|
189
|
-
filtered = Array.from(byMonth.values());
|
|
190
|
-
}
|
|
191
|
-
// Sort descending and limit
|
|
192
|
-
filtered.sort((a, b) => String(b.snapshot_date).localeCompare(String(a.snapshot_date)));
|
|
193
|
-
if (limit > 0) {
|
|
194
|
-
filtered = filtered.slice(0, limit);
|
|
195
|
-
}
|
|
196
|
-
const result = {
|
|
197
|
-
granularity,
|
|
198
|
-
snapshots: filtered.map((s) => ({
|
|
199
|
-
date: s.snapshot_date,
|
|
200
|
-
...(granularity === 'monthly' && {
|
|
201
|
-
month: String(s.snapshot_date).substring(0, 7),
|
|
202
|
-
}),
|
|
203
|
-
net_worth: s.net_worth,
|
|
204
|
-
total_assets: s.total_assets,
|
|
205
|
-
total_liabilities: s.total_liabilities,
|
|
206
|
-
})),
|
|
207
|
-
};
|
|
208
|
-
return { content: [{ type: 'text', text: JSON.stringify(result, null, 2) }] };
|
|
209
|
-
}
|
|
210
|
-
case 'verify_key': {
|
|
211
|
-
const data = await apiRequest('/api/verify');
|
|
212
|
-
return { content: [{ type: 'text', text: JSON.stringify(data, null, 2) }] };
|
|
213
|
-
}
|
|
214
|
-
default:
|
|
215
|
-
throw new Error(`Unknown tool: ${name}`);
|
|
216
|
-
}
|
|
217
|
-
}
|
|
218
|
-
catch (error) {
|
|
219
|
-
const message = error instanceof Error ? error.message : String(error);
|
|
220
|
-
return {
|
|
221
|
-
content: [{ type: 'text', text: JSON.stringify({ error: message }, null, 2) }],
|
|
222
|
-
isError: true,
|
|
223
|
-
};
|
|
224
|
-
}
|
|
225
|
-
});
|
|
226
|
-
async function main() {
|
|
227
|
-
const transport = new StdioServerTransport();
|
|
228
|
-
await server.connect(transport);
|
|
229
|
-
}
|
|
230
|
-
main().catch(console.error);
|
|
10
|
+
export {};
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export declare function install(): Promise<void>;
|
package/dist/install.js
ADDED
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
import { readFileSync, writeFileSync, existsSync, mkdirSync } from 'fs';
|
|
2
|
+
import { join, dirname } from 'path';
|
|
3
|
+
import { homedir, platform } from 'os';
|
|
4
|
+
import { createInterface } from 'readline';
|
|
5
|
+
const HOME = homedir();
|
|
6
|
+
function getClaudeDesktopPath() {
|
|
7
|
+
if (platform() === 'win32') {
|
|
8
|
+
return join(process.env.APPDATA || join(HOME, 'AppData', 'Roaming'), 'Claude', 'claude_desktop_config.json');
|
|
9
|
+
}
|
|
10
|
+
if (platform() === 'darwin') {
|
|
11
|
+
return join(HOME, 'Library', 'Application Support', 'Claude', 'claude_desktop_config.json');
|
|
12
|
+
}
|
|
13
|
+
return join(HOME, '.config', 'claude', 'claude_desktop_config.json');
|
|
14
|
+
}
|
|
15
|
+
const ALL_CLIENTS = [
|
|
16
|
+
{ name: 'Claude Code', configPath: join(HOME, '.claude.json'), format: 'json', alwaysInclude: true },
|
|
17
|
+
{ name: 'Claude Desktop', configPath: getClaudeDesktopPath(), format: 'json' },
|
|
18
|
+
{ name: 'Cursor', configPath: join(HOME, '.cursor', 'mcp.json'), format: 'json' },
|
|
19
|
+
{ name: 'Windsurf', configPath: join(HOME, '.codeium', 'windsurf', 'mcp_config.json'), format: 'json' },
|
|
20
|
+
{ name: 'OpenCode', configPath: join(HOME, '.config', 'opencode', 'opencode.json'), format: 'json' },
|
|
21
|
+
{ name: 'Codex CLI', configPath: join(HOME, '.codex', 'config.toml'), format: 'toml' },
|
|
22
|
+
{ name: 'Antigravity', configPath: join(HOME, '.gemini', 'antigravity', 'mcp_config.json'), format: 'json' },
|
|
23
|
+
{ name: 'Gemini CLI', configPath: join(HOME, '.gemini', 'settings.json'), format: 'json' },
|
|
24
|
+
];
|
|
25
|
+
const MCP_CONFIG = {
|
|
26
|
+
command: 'npx',
|
|
27
|
+
args: ['-y', '@warmio/mcp', '--server'],
|
|
28
|
+
};
|
|
29
|
+
function isDetected(client) {
|
|
30
|
+
if (client.alwaysInclude)
|
|
31
|
+
return true;
|
|
32
|
+
return existsSync(dirname(client.configPath));
|
|
33
|
+
}
|
|
34
|
+
function isConfigured(client) {
|
|
35
|
+
if (!existsSync(client.configPath))
|
|
36
|
+
return false;
|
|
37
|
+
try {
|
|
38
|
+
const content = readFileSync(client.configPath, 'utf-8');
|
|
39
|
+
if (client.format === 'toml')
|
|
40
|
+
return content.includes('[mcp_servers.warm]');
|
|
41
|
+
return !!JSON.parse(content)?.mcpServers?.warm;
|
|
42
|
+
}
|
|
43
|
+
catch {
|
|
44
|
+
return false;
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
function configureJson(client, apiKey) {
|
|
48
|
+
let config = {};
|
|
49
|
+
if (existsSync(client.configPath)) {
|
|
50
|
+
try {
|
|
51
|
+
config = JSON.parse(readFileSync(client.configPath, 'utf-8'));
|
|
52
|
+
}
|
|
53
|
+
catch { /* start fresh */ }
|
|
54
|
+
}
|
|
55
|
+
if (!config.mcpServers)
|
|
56
|
+
config.mcpServers = {};
|
|
57
|
+
config.mcpServers.warm = {
|
|
58
|
+
...MCP_CONFIG,
|
|
59
|
+
env: { WARM_API_KEY: apiKey },
|
|
60
|
+
};
|
|
61
|
+
mkdirSync(dirname(client.configPath), { recursive: true });
|
|
62
|
+
writeFileSync(client.configPath, JSON.stringify(config, null, 2) + '\n');
|
|
63
|
+
}
|
|
64
|
+
function configureToml(client, apiKey) {
|
|
65
|
+
let content = '';
|
|
66
|
+
if (existsSync(client.configPath)) {
|
|
67
|
+
content = readFileSync(client.configPath, 'utf-8');
|
|
68
|
+
if (!content.endsWith('\n'))
|
|
69
|
+
content += '\n';
|
|
70
|
+
}
|
|
71
|
+
content += `\n[mcp_servers.warm]\ncommand = "npx"\nargs = ["-y", "@warmio/mcp", "--server"]\n\n[mcp_servers.warm.env]\nWARM_API_KEY = "${apiKey}"\n`;
|
|
72
|
+
mkdirSync(dirname(client.configPath), { recursive: true });
|
|
73
|
+
writeFileSync(client.configPath, content);
|
|
74
|
+
}
|
|
75
|
+
function configure(client, apiKey) {
|
|
76
|
+
if (client.format === 'json')
|
|
77
|
+
configureJson(client, apiKey);
|
|
78
|
+
else
|
|
79
|
+
configureToml(client, apiKey);
|
|
80
|
+
}
|
|
81
|
+
function shortPath(p) {
|
|
82
|
+
return p.replace(HOME, '~');
|
|
83
|
+
}
|
|
84
|
+
function prompt(question) {
|
|
85
|
+
return new Promise((resolve) => {
|
|
86
|
+
const rl = createInterface({ input: process.stdin, output: process.stdout });
|
|
87
|
+
rl.question(question, (answer) => {
|
|
88
|
+
rl.close();
|
|
89
|
+
resolve(answer.trim());
|
|
90
|
+
});
|
|
91
|
+
});
|
|
92
|
+
}
|
|
93
|
+
export async function install() {
|
|
94
|
+
const force = process.argv.includes('--force');
|
|
95
|
+
console.log('');
|
|
96
|
+
console.log(' Warm MCP Server Installer');
|
|
97
|
+
console.log(' -------------------------');
|
|
98
|
+
console.log('');
|
|
99
|
+
const detected = ALL_CLIENTS.filter(isDetected);
|
|
100
|
+
const needsSetup = detected.filter((c) => !isConfigured(c) || force);
|
|
101
|
+
// Show detected clients and their status
|
|
102
|
+
console.log(' MCP clients found:');
|
|
103
|
+
detected.forEach((client) => {
|
|
104
|
+
const configured = isConfigured(client);
|
|
105
|
+
const status = configured && !force ? 'configured' : 'not configured';
|
|
106
|
+
console.log(` ${client.name.padEnd(18)} ${shortPath(client.configPath).padEnd(60)} ${status}`);
|
|
107
|
+
});
|
|
108
|
+
console.log('');
|
|
109
|
+
// Nothing to do
|
|
110
|
+
if (needsSetup.length === 0) {
|
|
111
|
+
console.log(' All clients already configured!');
|
|
112
|
+
console.log(' Run with --force to update the API key.');
|
|
113
|
+
console.log('');
|
|
114
|
+
return;
|
|
115
|
+
}
|
|
116
|
+
// Prompt for API key
|
|
117
|
+
const apiKey = await prompt(' Warm API key: ');
|
|
118
|
+
if (!apiKey) {
|
|
119
|
+
console.log('');
|
|
120
|
+
console.log(' No key provided. Get one at https://warm.io/settings');
|
|
121
|
+
console.log('');
|
|
122
|
+
return;
|
|
123
|
+
}
|
|
124
|
+
console.log('');
|
|
125
|
+
console.log(' Configuring...');
|
|
126
|
+
console.log('');
|
|
127
|
+
needsSetup.forEach((client) => {
|
|
128
|
+
try {
|
|
129
|
+
configure(client, apiKey);
|
|
130
|
+
console.log(` ${client.name.padEnd(18)} done`);
|
|
131
|
+
}
|
|
132
|
+
catch (err) {
|
|
133
|
+
console.log(` ${client.name.padEnd(18)} failed: ${err instanceof Error ? err.message : String(err)}`);
|
|
134
|
+
}
|
|
135
|
+
});
|
|
136
|
+
console.log('');
|
|
137
|
+
console.log(' All set! Restart your MCP clients and try:');
|
|
138
|
+
console.log(' "What\'s my net worth?"');
|
|
139
|
+
console.log('');
|
|
140
|
+
}
|
package/dist/server.d.ts
ADDED
package/dist/server.js
ADDED
|
@@ -0,0 +1,221 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Warm MCP Server
|
|
3
|
+
*
|
|
4
|
+
* Provides financial data from the Warm API as MCP tools.
|
|
5
|
+
* Reads API key from WARM_API_KEY env var or ~/.config/warm/api_key.
|
|
6
|
+
*/
|
|
7
|
+
import { Server } from '@modelcontextprotocol/sdk/server/index.js';
|
|
8
|
+
import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
|
|
9
|
+
import { CallToolRequestSchema, ListToolsRequestSchema } from '@modelcontextprotocol/sdk/types.js';
|
|
10
|
+
import * as fs from 'fs';
|
|
11
|
+
import * as path from 'path';
|
|
12
|
+
import * as os from 'os';
|
|
13
|
+
const API_URL = process.env.WARM_API_URL || 'https://warm.io';
|
|
14
|
+
function getApiKey() {
|
|
15
|
+
if (process.env.WARM_API_KEY) {
|
|
16
|
+
return process.env.WARM_API_KEY;
|
|
17
|
+
}
|
|
18
|
+
const configPath = path.join(os.homedir(), '.config', 'warm', 'api_key');
|
|
19
|
+
try {
|
|
20
|
+
return fs.readFileSync(configPath, 'utf-8').trim();
|
|
21
|
+
}
|
|
22
|
+
catch {
|
|
23
|
+
return null;
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
async function apiRequest(endpoint, params = {}) {
|
|
27
|
+
const apiKey = getApiKey();
|
|
28
|
+
if (!apiKey) {
|
|
29
|
+
throw new Error('WARM_API_KEY not set. Run "npx @warmio/mcp" to configure.');
|
|
30
|
+
}
|
|
31
|
+
const url = new URL(endpoint, API_URL);
|
|
32
|
+
Object.entries(params).forEach(([key, value]) => {
|
|
33
|
+
if (value)
|
|
34
|
+
url.searchParams.append(key, value);
|
|
35
|
+
});
|
|
36
|
+
const response = await fetch(url.toString(), {
|
|
37
|
+
headers: {
|
|
38
|
+
Authorization: `Bearer ${apiKey}`,
|
|
39
|
+
Accept: 'application/json',
|
|
40
|
+
},
|
|
41
|
+
});
|
|
42
|
+
if (!response.ok) {
|
|
43
|
+
const errorMessages = {
|
|
44
|
+
401: 'Invalid or expired API key. Regenerate at https://warm.io/settings',
|
|
45
|
+
403: 'Pro subscription required. Upgrade at https://warm.io/settings',
|
|
46
|
+
429: 'Rate limit exceeded. Try again in a few minutes.',
|
|
47
|
+
};
|
|
48
|
+
throw new Error(errorMessages[response.status] || `HTTP ${response.status}`);
|
|
49
|
+
}
|
|
50
|
+
return response.json();
|
|
51
|
+
}
|
|
52
|
+
const server = new Server({ name: 'warm', version: '1.0.1' }, { capabilities: { tools: {} } });
|
|
53
|
+
server.setRequestHandler(ListToolsRequestSchema, async () => ({
|
|
54
|
+
tools: [
|
|
55
|
+
{
|
|
56
|
+
name: 'get_accounts',
|
|
57
|
+
description: 'Get all connected bank accounts with balances. Returns account names, types, balances, and institutions.',
|
|
58
|
+
inputSchema: {
|
|
59
|
+
type: 'object',
|
|
60
|
+
properties: {
|
|
61
|
+
since: {
|
|
62
|
+
type: 'string',
|
|
63
|
+
description: 'Filter accounts updated since this date (ISO format)',
|
|
64
|
+
},
|
|
65
|
+
},
|
|
66
|
+
},
|
|
67
|
+
},
|
|
68
|
+
{
|
|
69
|
+
name: 'get_transactions',
|
|
70
|
+
description: 'Get transactions from connected accounts. Supports pagination and date filtering.',
|
|
71
|
+
inputSchema: {
|
|
72
|
+
type: 'object',
|
|
73
|
+
properties: {
|
|
74
|
+
limit: {
|
|
75
|
+
type: 'number',
|
|
76
|
+
description: 'Maximum number of transactions to return (default: 100)',
|
|
77
|
+
},
|
|
78
|
+
since: {
|
|
79
|
+
type: 'string',
|
|
80
|
+
description: 'Get transactions since this date (ISO format, e.g., 2024-01-01)',
|
|
81
|
+
},
|
|
82
|
+
cursor: {
|
|
83
|
+
type: 'string',
|
|
84
|
+
description: 'Pagination cursor for fetching more results',
|
|
85
|
+
},
|
|
86
|
+
},
|
|
87
|
+
},
|
|
88
|
+
},
|
|
89
|
+
{
|
|
90
|
+
name: 'get_recurring',
|
|
91
|
+
description: 'Get recurring payments and subscriptions detected from transaction history.',
|
|
92
|
+
inputSchema: {
|
|
93
|
+
type: 'object',
|
|
94
|
+
properties: {
|
|
95
|
+
since: {
|
|
96
|
+
type: 'string',
|
|
97
|
+
description: 'Filter recurring items detected since this date',
|
|
98
|
+
},
|
|
99
|
+
},
|
|
100
|
+
},
|
|
101
|
+
},
|
|
102
|
+
{
|
|
103
|
+
name: 'get_snapshots',
|
|
104
|
+
description: 'Get net worth snapshots over time (daily or monthly aggregation).',
|
|
105
|
+
inputSchema: {
|
|
106
|
+
type: 'object',
|
|
107
|
+
properties: {
|
|
108
|
+
granularity: {
|
|
109
|
+
type: 'string',
|
|
110
|
+
enum: ['daily', 'monthly'],
|
|
111
|
+
description: 'Aggregation level (default: daily)',
|
|
112
|
+
},
|
|
113
|
+
limit: {
|
|
114
|
+
type: 'number',
|
|
115
|
+
description: 'Number of snapshots to return',
|
|
116
|
+
},
|
|
117
|
+
since: {
|
|
118
|
+
type: 'string',
|
|
119
|
+
description: 'Start date for snapshots (ISO format)',
|
|
120
|
+
},
|
|
121
|
+
},
|
|
122
|
+
},
|
|
123
|
+
},
|
|
124
|
+
{
|
|
125
|
+
name: 'verify_key',
|
|
126
|
+
description: 'Check if the Warm API key is valid and working.',
|
|
127
|
+
inputSchema: {
|
|
128
|
+
type: 'object',
|
|
129
|
+
properties: {},
|
|
130
|
+
},
|
|
131
|
+
},
|
|
132
|
+
],
|
|
133
|
+
}));
|
|
134
|
+
server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
|
135
|
+
const { name, arguments: args } = request.params;
|
|
136
|
+
try {
|
|
137
|
+
switch (name) {
|
|
138
|
+
case 'get_accounts': {
|
|
139
|
+
const params = {};
|
|
140
|
+
if (args?.since)
|
|
141
|
+
params.since = String(args.since);
|
|
142
|
+
const data = await apiRequest('/api/accounts', params);
|
|
143
|
+
return { content: [{ type: 'text', text: JSON.stringify(data, null, 2) }] };
|
|
144
|
+
}
|
|
145
|
+
case 'get_transactions': {
|
|
146
|
+
const params = {};
|
|
147
|
+
if (args?.limit)
|
|
148
|
+
params.limit = String(args.limit);
|
|
149
|
+
if (args?.since)
|
|
150
|
+
params.last_knowledge = String(args.since);
|
|
151
|
+
if (args?.cursor)
|
|
152
|
+
params.cursor = String(args.cursor);
|
|
153
|
+
const data = await apiRequest('/api/transactions', params);
|
|
154
|
+
return { content: [{ type: 'text', text: JSON.stringify(data, null, 2) }] };
|
|
155
|
+
}
|
|
156
|
+
case 'get_recurring': {
|
|
157
|
+
const params = {};
|
|
158
|
+
if (args?.since)
|
|
159
|
+
params.since = String(args.since);
|
|
160
|
+
const data = await apiRequest('/api/recurring', params);
|
|
161
|
+
return { content: [{ type: 'text', text: JSON.stringify(data, null, 2) }] };
|
|
162
|
+
}
|
|
163
|
+
case 'get_snapshots': {
|
|
164
|
+
const response = (await apiRequest('/api/transactions', {
|
|
165
|
+
limit: '1',
|
|
166
|
+
}));
|
|
167
|
+
const snapshots = response.snapshots || [];
|
|
168
|
+
const granularity = args?.granularity || 'daily';
|
|
169
|
+
const limit = args?.limit ? Number(args.limit) : granularity === 'daily' ? 100 : 0;
|
|
170
|
+
const since = args?.since;
|
|
171
|
+
let filtered = snapshots;
|
|
172
|
+
if (since) {
|
|
173
|
+
filtered = filtered.filter((s) => String(s.snapshot_date) >= since);
|
|
174
|
+
}
|
|
175
|
+
if (granularity === 'monthly') {
|
|
176
|
+
const byMonth = new Map();
|
|
177
|
+
filtered.forEach((s) => {
|
|
178
|
+
const month = String(s.snapshot_date).substring(0, 7);
|
|
179
|
+
if (!byMonth.has(month) ||
|
|
180
|
+
String(s.snapshot_date) > String(byMonth.get(month).snapshot_date)) {
|
|
181
|
+
byMonth.set(month, s);
|
|
182
|
+
}
|
|
183
|
+
});
|
|
184
|
+
filtered = Array.from(byMonth.values());
|
|
185
|
+
}
|
|
186
|
+
filtered.sort((a, b) => String(b.snapshot_date).localeCompare(String(a.snapshot_date)));
|
|
187
|
+
if (limit > 0) {
|
|
188
|
+
filtered = filtered.slice(0, limit);
|
|
189
|
+
}
|
|
190
|
+
const result = {
|
|
191
|
+
granularity,
|
|
192
|
+
snapshots: filtered.map((s) => ({
|
|
193
|
+
date: s.snapshot_date,
|
|
194
|
+
...(granularity === 'monthly' && {
|
|
195
|
+
month: String(s.snapshot_date).substring(0, 7),
|
|
196
|
+
}),
|
|
197
|
+
net_worth: s.net_worth,
|
|
198
|
+
total_assets: s.total_assets,
|
|
199
|
+
total_liabilities: s.total_liabilities,
|
|
200
|
+
})),
|
|
201
|
+
};
|
|
202
|
+
return { content: [{ type: 'text', text: JSON.stringify(result, null, 2) }] };
|
|
203
|
+
}
|
|
204
|
+
case 'verify_key': {
|
|
205
|
+
const data = await apiRequest('/api/verify');
|
|
206
|
+
return { content: [{ type: 'text', text: JSON.stringify(data, null, 2) }] };
|
|
207
|
+
}
|
|
208
|
+
default:
|
|
209
|
+
throw new Error(`Unknown tool: ${name}`);
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
catch (error) {
|
|
213
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
214
|
+
return {
|
|
215
|
+
content: [{ type: 'text', text: JSON.stringify({ error: message }, null, 2) }],
|
|
216
|
+
isError: true,
|
|
217
|
+
};
|
|
218
|
+
}
|
|
219
|
+
});
|
|
220
|
+
const transport = new StdioServerTransport();
|
|
221
|
+
await server.connect(transport);
|