@warmio/mcp 3.0.1 → 4.1.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 CHANGED
@@ -1,60 +1,262 @@
1
- # Warm MCP Server
1
+ # Warm MCP
2
2
 
3
- MCP server that gives Claude Code (or any MCP client) read-only access to your Warm financial data.
3
+ Read-only MCP server for Warm financial data.
4
4
 
5
- ## Quick Start
5
+ Warm supports two transport shapes from this repo:
6
+
7
+ - Local `stdio` via the npm package `@warmio/mcp`
8
+ - Self-hosted Streamable HTTP via `warm-mcp http`
9
+
10
+ Warm does not currently publish a Warm-hosted Streamable HTTP MCP endpoint from this repo.
11
+
12
+ ## Install
6
13
 
7
14
  ```bash
8
15
  npx @warmio/mcp
9
16
  ```
10
17
 
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.
18
+ The installer detects supported MCP clients, prompts for your Warm API key, and writes the local
19
+ `stdio` server config automatically.
13
20
 
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"
21
+ ## Requirements
18
22
 
19
- ## Options
23
+ - Warm Pro
24
+ - A Warm API key from [Settings -> API Keys](https://warm.io/settings)
25
+ - Node.js 18+
20
26
 
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) |
27
+ ## Manual `stdio` Config
26
28
 
27
- ## Requirements
29
+ Use this shape when configuring a local MCP client manually:
30
+
31
+ ```json
32
+ {
33
+ "mcpServers": {
34
+ "warm": {
35
+ "command": "npx",
36
+ "args": ["-y", "@warmio/mcp", "--server"],
37
+ "env": {
38
+ "WARM_API_KEY": "your_warm_api_key"
39
+ }
40
+ }
41
+ }
42
+ }
43
+ ```
44
+
45
+ ## Self-hosted Streamable HTTP
46
+
47
+ Run the HTTP server locally or behind your own reverse proxy:
48
+
49
+ ```bash
50
+ npx @warmio/mcp http --host 0.0.0.0 --port 3000 --path /mcp
51
+ ```
28
52
 
29
- - **Pro subscription** — API access requires Warm Pro
30
- - **API key** — Generate in [Settings → API Keys](https://warm.io/settings)
31
- - **Node.js 18+** — For running the MCP server
53
+ Environment overrides:
32
54
 
33
- ## How It Works
55
+ - `WARM_MCP_HTTP_HOST`
56
+ - `WARM_MCP_HTTP_PORT`
57
+ - `WARM_MCP_HTTP_PATH`
58
+ - `WARM_MCP_ALLOWED_HOSTS`
34
59
 
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
60
+ On Windows, prefer:
40
61
 
41
- The MCP server starts automatically when your client needs it — you never run it manually.
62
+ ```json
63
+ {
64
+ "mcpServers": {
65
+ "warm": {
66
+ "command": "cmd",
67
+ "args": ["/c", "npx", "-y", "@warmio/mcp", "--server"],
68
+ "env": {
69
+ "WARM_API_KEY": "your_warm_api_key"
70
+ }
71
+ }
72
+ }
73
+ }
74
+ ```
75
+
76
+ ## Core Tools
42
77
 
43
- ## Available Tools
78
+ Warm's published/documented MCP surface is the following four-tool core:
44
79
 
45
80
  | Tool | Description |
46
81
  |------|-------------|
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 |
82
+ | `get_accounts` | List connected accounts with current balances |
83
+ | `get_transactions` | Page through transactions with an opaque cursor |
84
+ | `get_financial_state` | Return the current typed financial state bundle |
85
+ | `verify_key` | Validate the configured API key |
86
+
87
+ ## Strict Contract
88
+
89
+ - Every tool takes a JSON object input and returns a JSON object output.
90
+ - Treat the contracts as closed and typed. Do not depend on undocumented fields.
91
+ - Calendar dates use `YYYY-MM-DD`. Incremental sync timestamps use ISO 8601 datetimes.
92
+ - Amounts are numbers, never formatted strings.
93
+ - Transaction amounts follow the Plaid sign convention:
94
+ positive = expense/debit, negative = income/credit.
95
+ - Pagination cursors are opaque strings. Do not parse them or mix them with changed filters.
96
+
97
+ ### `get_accounts`
98
+
99
+ Input:
100
+
101
+ ```json
102
+ {}
103
+ ```
104
+
105
+ Returns:
106
+
107
+ ```json
108
+ {
109
+ "accounts": [
110
+ {
111
+ "name": "Primary Checking",
112
+ "type": "depository",
113
+ "subtype": "checking",
114
+ "balance": 2450.12,
115
+ "institution": "Chase",
116
+ "mask": "1234"
117
+ }
118
+ ]
119
+ }
120
+ ```
121
+
122
+ ### `get_transactions`
123
+
124
+ Input:
125
+
126
+ ```json
127
+ {
128
+ "limit": 100,
129
+ "cursor": "opaque-cursor-from-a-prior-page",
130
+ "last_knowledge": "2026-03-11T00:00:00.000Z"
131
+ }
132
+ ```
133
+
134
+ Returns:
135
+
136
+ ```json
137
+ {
138
+ "generated_at": "2026-03-11T12:00:00.000Z",
139
+ "next_knowledge": "2026-03-11T12:00:00.000Z",
140
+ "txns": [
141
+ {
142
+ "id": "txn_123",
143
+ "date": "2026-01-15",
144
+ "amount": 12.34,
145
+ "merchant": "Coffee Shop",
146
+ "description": "COFFEE SHOP",
147
+ "category": "FOOD_AND_DRINK",
148
+ "detailed_category": "FOOD_AND_DRINK_COFFEE"
149
+ }
150
+ ],
151
+ "pagination": {
152
+ "limit": 100,
153
+ "next_cursor": "opaque-next-cursor",
154
+ "has_more": true
155
+ }
156
+ }
157
+ ```
158
+
159
+ Cursor model:
160
+
161
+ 1. Omit `cursor` on the first call.
162
+ 2. Keep `limit` fixed while following a cursor chain.
163
+ 3. If `pagination.next_cursor` is non-null, pass it unchanged to fetch the next page.
164
+ 4. Stop when `next_cursor` is `null`.
165
+ 5. Do not combine `cursor` with `last_knowledge`.
166
+
167
+ ### `get_financial_state`
168
+
169
+ Input:
170
+
171
+ ```json
172
+ {}
173
+ ```
174
+
175
+ Returns:
176
+
177
+ ```json
178
+ {
179
+ "generated_at": "2026-03-11T12:00:00.000Z",
180
+ "snapshots": [
181
+ {
182
+ "date": "2026-03-11",
183
+ "net_worth": 125430.55,
184
+ "total_assets": 168210.77,
185
+ "total_liabilities": 42780.22
186
+ }
187
+ ],
188
+ "recurring": [
189
+ {
190
+ "merchant": "Netflix",
191
+ "amount": 15.49,
192
+ "frequency": "MONTHLY",
193
+ "next_date": "2026-03-18",
194
+ "type": "subscription",
195
+ "active": true
196
+ }
197
+ ],
198
+ "budgets": [
199
+ {
200
+ "name": "Dining Out",
201
+ "amount": 400,
202
+ "spent": 182.55,
203
+ "remaining": 217.45,
204
+ "percent_used": 45.64,
205
+ "period": "monthly",
206
+ "status": "on_track"
207
+ }
208
+ ],
209
+ "goals": [
210
+ {
211
+ "name": "Emergency Fund",
212
+ "target": 10000,
213
+ "current": 4200,
214
+ "progress_percent": 42,
215
+ "target_date": null,
216
+ "status": "active",
217
+ "category": "safety",
218
+ "monthly_contribution_needed": 400
219
+ }
220
+ ],
221
+ "health": {
222
+ "score": 78,
223
+ "label": "Good",
224
+ "data_completeness": 94,
225
+ "pillars": {
226
+ "spend": 20,
227
+ "save": 23,
228
+ "borrow": 15,
229
+ "build": 20
230
+ },
231
+ "message": null
232
+ }
233
+ }
234
+ ```
235
+
236
+ If Warm does not have enough state data yet, nullable fields remain `null`.
237
+
238
+ ### `verify_key`
239
+
240
+ Input:
241
+
242
+ ```json
243
+ {}
244
+ ```
245
+
246
+ Returns:
247
+
248
+ ```json
249
+ {
250
+ "valid": true,
251
+ "status": "ok"
252
+ }
253
+ ```
52
254
 
53
255
  ## Security
54
256
 
55
- - **Read-only** Cannot modify, delete, or transfer data
56
- - **Scoped** Key only accesses your accounts
57
- - **Revocable** Delete key in Settings to revoke instantly
257
+ - Read-only: no write, delete, transfer, or mutation tools
258
+ - Scoped: the key only reads the owner's Warm data
259
+ - Revocable: delete the key in Settings to revoke access immediately
58
260
 
59
261
  ## Development
60
262
 
package/dist/http.d.ts ADDED
@@ -0,0 +1,9 @@
1
+ import type { Server as HttpServer } from 'http';
2
+ export interface WarmHttpServerOptions {
3
+ host?: string;
4
+ port?: number;
5
+ path?: string;
6
+ allowedHosts?: string[];
7
+ }
8
+ export declare function resolveHttpServerOptions(overrides?: WarmHttpServerOptions): Required<WarmHttpServerOptions>;
9
+ export declare function startHttpServer(options?: WarmHttpServerOptions): Promise<HttpServer>;
package/dist/http.js ADDED
@@ -0,0 +1,117 @@
1
+ import { createMcpExpressApp } from '@modelcontextprotocol/sdk/server/express.js';
2
+ import { StreamableHTTPServerTransport } from '@modelcontextprotocol/sdk/server/streamableHttp.js';
3
+ import { createWarmServer } from './server.js';
4
+ const DEFAULT_HTTP_HOST = '0.0.0.0';
5
+ const DEFAULT_HTTP_PORT = 3000;
6
+ const DEFAULT_HTTP_PATH = '/mcp';
7
+ function parsePort(value, fallback) {
8
+ const parsed = Number(value);
9
+ return Number.isInteger(parsed) && parsed > 0 ? parsed : fallback;
10
+ }
11
+ function normalizePath(value) {
12
+ if (!value) {
13
+ return DEFAULT_HTTP_PATH;
14
+ }
15
+ return value.startsWith('/') ? value : `/${value}`;
16
+ }
17
+ function parseAllowedHosts(value) {
18
+ if (!value) {
19
+ return [];
20
+ }
21
+ return value
22
+ .split(',')
23
+ .map((host) => host.trim())
24
+ .filter(Boolean);
25
+ }
26
+ function jsonRpcError(message) {
27
+ return {
28
+ jsonrpc: '2.0',
29
+ error: {
30
+ code: -32000,
31
+ message,
32
+ },
33
+ id: null,
34
+ };
35
+ }
36
+ function sendMethodNotAllowed(res) {
37
+ res.status(405).set('Allow', 'POST').json(jsonRpcError('Method not allowed.'));
38
+ }
39
+ export function resolveHttpServerOptions(overrides = {}) {
40
+ return {
41
+ host: overrides.host ||
42
+ process.env.WARM_MCP_HTTP_HOST ||
43
+ process.env.MCP_HOST ||
44
+ process.env.HOST ||
45
+ DEFAULT_HTTP_HOST,
46
+ port: overrides.port ??
47
+ parsePort(process.env.WARM_MCP_HTTP_PORT || process.env.MCP_PORT || process.env.PORT, DEFAULT_HTTP_PORT),
48
+ path: normalizePath(overrides.path || process.env.WARM_MCP_HTTP_PATH || process.env.MCP_PATH || DEFAULT_HTTP_PATH),
49
+ allowedHosts: overrides.allowedHosts ??
50
+ parseAllowedHosts(process.env.WARM_MCP_ALLOWED_HOSTS || process.env.MCP_ALLOWED_HOSTS),
51
+ };
52
+ }
53
+ export async function startHttpServer(options = {}) {
54
+ const resolved = resolveHttpServerOptions(options);
55
+ const app = createMcpExpressApp({
56
+ host: resolved.host,
57
+ ...(resolved.allowedHosts.length > 0 ? { allowedHosts: resolved.allowedHosts } : {}),
58
+ });
59
+ app.post(resolved.path, async (req, res) => {
60
+ const server = createWarmServer();
61
+ const transport = new StreamableHTTPServerTransport({
62
+ sessionIdGenerator: undefined,
63
+ });
64
+ let cleanedUp = false;
65
+ const cleanup = async () => {
66
+ if (cleanedUp) {
67
+ return;
68
+ }
69
+ cleanedUp = true;
70
+ await Promise.allSettled([transport.close(), server.close()]);
71
+ };
72
+ res.on('close', () => {
73
+ void cleanup();
74
+ });
75
+ try {
76
+ await server.connect(transport);
77
+ await transport.handleRequest(req, res, req.body);
78
+ }
79
+ catch (error) {
80
+ console.error('Error handling Warm MCP HTTP request:', error);
81
+ if (!res.headersSent) {
82
+ res.status(500).json(jsonRpcError('Internal server error'));
83
+ }
84
+ void cleanup();
85
+ }
86
+ });
87
+ app.get(resolved.path, (_req, res) => {
88
+ sendMethodNotAllowed(res);
89
+ });
90
+ app.delete(resolved.path, (_req, res) => {
91
+ sendMethodNotAllowed(res);
92
+ });
93
+ const listener = await new Promise((resolve, reject) => {
94
+ const httpServer = app.listen(resolved.port, resolved.host, () => {
95
+ resolve(httpServer);
96
+ });
97
+ httpServer.once('error', reject);
98
+ });
99
+ let shuttingDown = false;
100
+ const shutdown = (signal) => {
101
+ if (shuttingDown) {
102
+ return;
103
+ }
104
+ shuttingDown = true;
105
+ console.log(`Received ${signal}, shutting down Warm MCP HTTP server...`);
106
+ listener.close((error) => {
107
+ if (error) {
108
+ console.error('Failed to close Warm MCP HTTP server:', error);
109
+ process.exitCode = 1;
110
+ }
111
+ });
112
+ };
113
+ process.once('SIGINT', () => shutdown('SIGINT'));
114
+ process.once('SIGTERM', () => shutdown('SIGTERM'));
115
+ console.log(`Warm MCP Streamable HTTP server listening on http://${resolved.host}:${resolved.port}${resolved.path}`);
116
+ return listener;
117
+ }
package/dist/index.js CHANGED
@@ -1,10 +1,143 @@
1
1
  #!/usr/bin/env node
2
- const args = process.argv.slice(2);
3
- if (args.includes('--server')) {
4
- await import('./server.js');
2
+ import { startHttpServer } from './http.js';
3
+ import { install } from './install.js';
4
+ import { startStdioServer } from './server.js';
5
+ function printUsage() {
6
+ console.log('');
7
+ console.log(' Warm MCP');
8
+ console.log(' --------');
9
+ console.log('');
10
+ console.log(' warm-mcp [install] [--force] [--no-validate]');
11
+ console.log(' warm-mcp stdio');
12
+ console.log(' warm-mcp http [--host 0.0.0.0] [--port 3000] [--path /mcp]');
13
+ console.log(' [--allowed-hosts host1,host2]');
14
+ console.log('');
15
+ console.log(' Aliases:');
16
+ console.log(' --server, --stdio Start stdio mode');
17
+ console.log(' --http Start HTTP mode');
18
+ console.log('');
5
19
  }
6
- else {
7
- const { install } = await import('./install.js');
8
- await install();
20
+ function parsePort(value) {
21
+ const parsed = Number(value);
22
+ if (!Number.isInteger(parsed) || parsed <= 0) {
23
+ throw new Error(`Invalid port: ${value}`);
24
+ }
25
+ return parsed;
26
+ }
27
+ function parseList(value) {
28
+ return value
29
+ .split(',')
30
+ .map((item) => item.trim())
31
+ .filter(Boolean);
32
+ }
33
+ function readOption(args, index, flag) {
34
+ const arg = args[index];
35
+ if (arg.startsWith(`${flag}=`)) {
36
+ return {
37
+ nextIndex: index,
38
+ value: arg.slice(flag.length + 1),
39
+ };
40
+ }
41
+ const value = args[index + 1];
42
+ if (!value) {
43
+ throw new Error(`Missing value for ${flag}`);
44
+ }
45
+ return {
46
+ nextIndex: index + 1,
47
+ value,
48
+ };
49
+ }
50
+ function parseCliArgs(args) {
51
+ const options = {
52
+ command: 'install',
53
+ force: false,
54
+ validateApiKey: true,
55
+ };
56
+ for (let index = 0; index < args.length; index += 1) {
57
+ const arg = args[index];
58
+ if (arg === 'help' || arg === '--help' || arg === '-h') {
59
+ options.command = 'help';
60
+ continue;
61
+ }
62
+ if (arg === 'install' || arg === '--install') {
63
+ options.command = 'install';
64
+ continue;
65
+ }
66
+ if (arg === 'stdio' || arg === 'server' || arg === '--stdio' || arg === '--server') {
67
+ options.command = 'stdio';
68
+ continue;
69
+ }
70
+ if (arg === 'http' || arg === '--http') {
71
+ options.command = 'http';
72
+ continue;
73
+ }
74
+ if (arg === '--force') {
75
+ options.force = true;
76
+ continue;
77
+ }
78
+ if (arg === '--no-validate') {
79
+ options.validateApiKey = false;
80
+ continue;
81
+ }
82
+ if (arg === '--host' || arg.startsWith('--host=')) {
83
+ const { nextIndex, value } = readOption(args, index, '--host');
84
+ options.host = value;
85
+ index = nextIndex;
86
+ continue;
87
+ }
88
+ if (arg === '--port' || arg.startsWith('--port=')) {
89
+ const { nextIndex, value } = readOption(args, index, '--port');
90
+ options.port = parsePort(value);
91
+ index = nextIndex;
92
+ continue;
93
+ }
94
+ if (arg === '--path' || arg.startsWith('--path=')) {
95
+ const { nextIndex, value } = readOption(args, index, '--path');
96
+ options.path = value;
97
+ index = nextIndex;
98
+ continue;
99
+ }
100
+ if (arg === '--allowed-hosts' || arg.startsWith('--allowed-hosts=')) {
101
+ const { nextIndex, value } = readOption(args, index, '--allowed-hosts');
102
+ options.allowedHosts = parseList(value);
103
+ index = nextIndex;
104
+ continue;
105
+ }
106
+ throw new Error(`Unknown argument: ${arg}`);
107
+ }
108
+ return options;
109
+ }
110
+ async function main() {
111
+ const options = parseCliArgs(process.argv.slice(2));
112
+ switch (options.command) {
113
+ case 'help':
114
+ printUsage();
115
+ return;
116
+ case 'http':
117
+ await startHttpServer({
118
+ allowedHosts: options.allowedHosts,
119
+ host: options.host,
120
+ path: options.path,
121
+ port: options.port,
122
+ });
123
+ return;
124
+ case 'stdio':
125
+ await startStdioServer();
126
+ return;
127
+ case 'install':
128
+ await install({
129
+ force: options.force,
130
+ validateApiKey: options.validateApiKey,
131
+ });
132
+ return;
133
+ }
134
+ }
135
+ try {
136
+ await main();
137
+ }
138
+ catch (error) {
139
+ const message = error instanceof Error ? error.message : String(error);
140
+ console.error(message);
141
+ console.error('Run "warm-mcp --help" for usage.');
142
+ process.exit(1);
9
143
  }
10
- export {};
package/dist/install.d.ts CHANGED
@@ -1 +1,5 @@
1
- export declare function install(): Promise<void>;
1
+ export interface InstallOptions {
2
+ force?: boolean;
3
+ validateApiKey?: boolean;
4
+ }
5
+ export declare function install(options?: InstallOptions): Promise<void>;