@warmio/mcp 5.0.1 → 7.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 CHANGED
@@ -1,6 +1,6 @@
1
1
  # Warmio
2
2
 
3
- Warmio is the one-command CLI and read-only MCP server for Warm financial data.
3
+ Warmio is the one-command CLI with separate compact MCP modes for financial context and supported automations.
4
4
 
5
5
  ## Install
6
6
 
@@ -8,26 +8,32 @@ Warmio is the one-command CLI and read-only MCP server for Warm financial data.
8
8
  npx -y @warmio/mcp@latest
9
9
  ```
10
10
 
11
- The installer detects supported MCP clients, prompts for your Warm API key, validates it, and
12
- writes the local `stdio` MCP config automatically. The key is stored once in your local Warm
13
- profile instead of being duplicated into every MCP client config.
11
+ The installer detects supported MCP clients, prompts for a mode-specific Warm API key, validates
12
+ its audience, and writes the local `stdio` MCP config automatically. Context and automation keys
13
+ are stored separately and are never shared between modes.
14
14
 
15
15
  ## Requirements
16
16
 
17
17
  - Warm Pro
18
- - A Warm API key from [Settings -> API Keys](https://warm.io/settings)
18
+ - A context key or an automation key from [Settings -> API Keys](https://warm.io/settings)
19
19
  - Node.js 18+
20
20
 
21
21
  ## Manual MCP Config
22
22
 
23
- Use this when a client asks you to paste an MCP JSON config:
23
+ Context is the default read-only mode. Install it with its own key:
24
+
25
+ ```bash
26
+ npx -y @warmio/mcp@latest install --mode context
27
+ ```
28
+
29
+ Use this when a client asks you to paste a context MCP JSON config:
24
30
 
25
31
  ```json
26
32
  {
27
33
  "mcpServers": {
28
34
  "warm": {
29
35
  "command": "npx",
30
- "args": ["-y", "@warmio/mcp@latest", "mcp"]
36
+ "args": ["-y", "@warmio/mcp@latest", "mcp", "--mode", "context"]
31
37
  }
32
38
  }
33
39
  }
@@ -40,18 +46,48 @@ On Windows, prefer:
40
46
  "mcpServers": {
41
47
  "warm": {
42
48
  "command": "cmd",
43
- "args": ["/c", "npx", "-y", "@warmio/mcp@latest", "mcp"]
49
+ "args": ["/c", "npx", "-y", "@warmio/mcp@latest", "mcp", "--mode", "context"]
44
50
  }
45
51
  }
46
52
  }
47
53
  ```
48
54
 
49
- Optional auth overrides:
55
+ Context credential overrides:
56
+
57
+ - `WARM_CONTEXT_API_KEY`
58
+ - `WARM_CONTEXT_API_KEY_FILE`
59
+
60
+ Automation is intentionally opt-in and must use a distinct automation key:
61
+
62
+ ```bash
63
+ npx -y @warmio/mcp@latest install --mode automation
64
+ ```
65
+
66
+ ```json
67
+ {
68
+ "mcpServers": {
69
+ "warm-automation": {
70
+ "command": "npx",
71
+ "args": ["-y", "@warmio/mcp@latest", "mcp", "--mode", "automation"]
72
+ }
73
+ }
74
+ }
75
+ ```
50
76
 
51
- - `WARM_API_KEY`
52
- - `WARM_API_KEY_FILE`
77
+ Automation credential overrides are `WARM_AUTOMATION_API_KEY` and
78
+ `WARM_AUTOMATION_API_KEY_FILE`. The default key files are `${WARM_CONFIG_DIR}/context_api_key`
79
+ and `${WARM_CONFIG_DIR}/automation_api_key`; `WARM_API_KEY` and `WARM_API_KEY_FILE` are not read.
80
+ The API verifies the key audience before every MCP tool call, so a context key cannot operate the
81
+ automation server and an automation key cannot operate the context server.
53
82
 
54
- ## Universal copy/paste setup prompt
83
+ Installer validation fails closed for API responses `401` and `403`. A `429` rate limit or a
84
+ temporary network failure remains a soft validation failure so setup can finish; the MCP server
85
+ will still validate the key audience before serving tools.
86
+
87
+ Both modes send API requests to `https://app.warm.io` by default. Set `WARM_API_URL` only when an
88
+ explicit API base URL override is required.
89
+
90
+ ## Universal Copy/Paste Setup Prompt
55
91
 
56
92
  ```text
57
93
  I want to connect Warm to this AI app using MCP.
@@ -62,7 +98,7 @@ Use this MCP server config:
62
98
  "mcpServers": {
63
99
  "warm": {
64
100
  "command": "npx",
65
- "args": ["-y", "@warmio/mcp@latest", "mcp"]
101
+ "args": ["-y", "@warmio/mcp@latest", "mcp", "--mode", "context"]
66
102
  }
67
103
  }
68
104
  }
@@ -73,18 +109,29 @@ Do not ask me to paste my Warm API key into this chat. Help me store it locally
73
109
  flow, then test the connection by calling Warm's verify_key tool.
74
110
 
75
111
  After setup, I should be able to ask:
76
- - What’s my net worth?
112
+ - What is my net worth?
77
113
  - Summarize my recent transactions.
78
114
  - What changed recently?
79
115
  - What recurring charges do I have?
80
116
  ```
81
117
 
118
+ ## CLI
119
+
120
+ ```bash
121
+ npx -y @warmio/mcp@latest context get
122
+ npx -y @warmio/mcp@latest context meta
123
+ npx -y @warmio/mcp@latest transactions get --month 2026-07
124
+ npx -y @warmio/mcp@latest transactions get --latest
125
+ ```
126
+
127
+ All CLI read commands print JSON.
128
+
82
129
  ## Optional Streamable HTTP
83
130
 
84
131
  If your MCP client supports Streamable HTTP, you can run:
85
132
 
86
133
  ```bash
87
- npx -y @warmio/mcp@latest http --host 127.0.0.1 --port 3000 --path /mcp
134
+ npx -y @warmio/mcp@latest http --mode context --host 127.0.0.1 --port 3000 --path /mcp
88
135
  ```
89
136
 
90
137
  Relevant optional environment variables:
@@ -93,29 +140,52 @@ Relevant optional environment variables:
93
140
  - `WARM_MCP_HTTP_PORT`
94
141
  - `WARM_MCP_HTTP_PATH`
95
142
  - `WARM_MCP_ALLOWED_HOSTS`
143
+ - `WARM_MCP_AUTH_TOKEN`
96
144
 
97
- ## Core Tools
145
+ `WARM_MCP_AUTH_TOKEN` is a separate bearer token for the MCP HTTP transport, never a Warm API
146
+ key. Automation HTTP always requires it, including on loopback. Context HTTP requires it whenever
147
+ the server binds anything other than the exact SDK-protected loopback hosts `127.0.0.1`,
148
+ `localhost`, or `::1`. Send it on every request as
149
+ `Authorization: Bearer <WARM_MCP_AUTH_TOKEN>`.
98
150
 
99
- Warm's published/documented MCP surface is the following four-tool core:
151
+ ## MCP Tools
100
152
 
101
- | Tool | Description |
102
- | --------------------- | ----------------------------------------------- |
103
- | `get_accounts` | List connected accounts with current balances |
104
- | `get_transactions` | Page through transactions with an opaque cursor |
105
- | `get_financial_state` | Return the current typed financial state bundle |
106
- | `verify_key` | Validate the configured API key |
153
+ Context mode remains exactly three read-only tools:
107
154
 
108
- ## Strict Contract
155
+ | Tool | Description |
156
+ | ----------------------- | ------------------------------------------------ |
157
+ | `get_financial_context` | Return compact `FinancialContext` JSON |
158
+ | `get_transactions` | Return one month page or the fixed latest window |
159
+ | `verify_key` | Validate the configured API key |
109
160
 
110
- - Every tool takes a JSON object input and returns a JSON object output.
111
- - Treat the contracts as closed and typed. Do not depend on undocumented fields.
112
- - Calendar dates use `YYYY-MM-DD`. Incremental sync timestamps use ISO 8601 datetimes.
113
- - Amounts are numbers, never formatted strings.
114
- - Transaction amounts follow the Plaid sign convention:
115
- positive = expense/debit, negative = income/credit.
116
- - Pagination cursors are opaque strings. Do not parse them or mix them with changed filters.
161
+ Automation mode is also exactly three tools:
162
+
163
+ | Tool | Description |
164
+ | -------------------- | ------------------------------------------------------------------ |
165
+ | `search_operations` | Find supported operations without loading the full catalog |
166
+ | `describe_operation` | Read an operation schema and request approval for a write input |
167
+ | `invoke_operation` | Invoke the operation; every write requires an `approval_id` |
117
168
 
118
- ### `get_accounts`
169
+ ## Parameter Contract
170
+
171
+ - Every tool takes a JSON object input and returns a JSON object output.
172
+ - `get_financial_context` takes `{}` and returns compact context JSON without transaction items.
173
+ - `get_transactions` takes either `{ "month": "YYYY-MM" }`, `{ "latest": true }`, or `{}`.
174
+ - A bare `get_transactions` call with no arguments defaults to `{ "latest": true }`.
175
+ - `month` must use `YYYY-MM` format, for example `2026-07`.
176
+ - `latest` is a fixed 10-day window anchored to the current artifact; the caller cannot configure the window.
177
+ - `month` and `latest` are mutually exclusive.
178
+ - A malformed month returns an error.
179
+ - A well-formed month outside the covered range returns an error.
180
+ - A well-formed month inside the covered range with no transactions returns `count: 0` and `items: []`.
181
+ - Transaction amounts follow the Plaid sign convention: positive = expense/debit, negative = income/credit.
182
+ - `describe_operation` returns `approval: { id, status, approval_url, expires_at }` for every write operation.
183
+ - `invoke_operation` accepts that approval as `approval_id`; confirmation tokens are not supported.
184
+ - Tool and Warm API failures return structured MCP results with `isError: true`; malformed MCP input remains a protocol error.
185
+
186
+ ## Shapes
187
+
188
+ ### `get_financial_context`
119
189
 
120
190
  Input:
121
191
 
@@ -123,168 +193,89 @@ Input:
123
193
  {}
124
194
  ```
125
195
 
126
- Returns:
196
+ Returns compact `FinancialContext`:
127
197
 
128
198
  ```json
129
199
  {
130
- "accounts": [
131
- {
132
- "name": "Primary Checking",
133
- "type": "depository",
134
- "subtype": "checking",
135
- "balance": 2450.12,
136
- "institution": "Chase",
137
- "mask": "1234"
138
- }
139
- ]
200
+ "version": "v1",
201
+ "updated_at": "2026-07-06T11:58:41.000Z",
202
+ "currency": "USD",
203
+ "status": {
204
+ "position": {
205
+ "date": "2026-07-06",
206
+ "net_worth": 84500,
207
+ "cash": 18200,
208
+ "debt": 6400,
209
+ "investments": 72700,
210
+ "other_assets": 0,
211
+ "total_assets": 90900
212
+ },
213
+ "accounts": []
214
+ },
215
+ "transactions": {
216
+ "total": 2,
217
+ "months": [{ "month": "2026-07", "count": 2 }]
218
+ },
219
+ "recurring": [],
220
+ "budgets": [],
221
+ "goals": [],
222
+ "snapshots": [],
223
+ "liabilities": [],
224
+ "holdings": [],
225
+ "health": null
140
226
  }
141
227
  ```
142
228
 
143
229
  ### `get_transactions`
144
230
 
145
- Input:
231
+ Month input:
146
232
 
147
233
  ```json
148
- {
149
- "limit": 100,
150
- "cursor": "opaque-cursor-from-a-prior-page",
151
- "last_knowledge": "2026-03-11T00:00:00.000Z",
152
- "search": "coffee"
153
- }
234
+ { "month": "2026-07" }
154
235
  ```
155
236
 
156
- Returns:
237
+ Month response:
157
238
 
158
239
  ```json
159
240
  {
160
- "generated_at": "2026-03-11T12:00:00.000Z",
161
- "next_knowledge": "2026-03-11T12:00:00.000Z",
162
- "txns": [
241
+ "month": "2026-07",
242
+ "start_date": "2026-07-01",
243
+ "end_date": "2026-07-31",
244
+ "count": 1,
245
+ "items": [
163
246
  {
164
- "id": "txn_123",
165
- "date": "2026-01-15",
166
- "amount": 12.34,
167
- "merchant": "Coffee Shop",
168
- "description": "COFFEE SHOP",
247
+ "id": "txn_1",
248
+ "account_id": "acct_1",
249
+ "date": "2026-07-05",
250
+ "amount": 42.18,
251
+ "merchant": "Whole Foods",
252
+ "name": "WHOLE FOODS",
169
253
  "category": "FOOD_AND_DRINK",
170
- "detailed_category": "FOOD_AND_DRINK_COFFEE"
254
+ "subcategory": "FOOD_AND_DRINK_GROCERIES",
255
+ "pending": false,
256
+ "currency": "USD"
171
257
  }
172
- ],
173
- "pagination": {
174
- "limit": 100,
175
- "next_cursor": "opaque-next-cursor",
176
- "has_more": true
177
- }
258
+ ]
178
259
  }
179
260
  ```
180
261
 
181
- Cursor model:
182
-
183
- 1. Omit `cursor` on the first call.
184
- 2. Keep `limit` and any filters such as `search` fixed while following a cursor chain.
185
- 3. If `pagination.next_cursor` is non-null, pass it unchanged to fetch the next page.
186
- 4. Stop when `next_cursor` is `null`.
187
- 5. Do not combine `cursor` with `last_knowledge`.
188
-
189
- ### `get_financial_state`
190
-
191
- Input:
262
+ Latest input:
192
263
 
193
264
  ```json
194
- {}
265
+ { "latest": true }
195
266
  ```
196
267
 
197
- Returns:
268
+ Latest response:
198
269
 
199
270
  ```json
200
271
  {
201
- "generated_at": "2026-03-11T12:00:00.000Z",
202
- "snapshots": [
203
- {
204
- "date": "2026-03-11",
205
- "net_worth": 125430.55,
206
- "total_assets": 168210.77,
207
- "total_liabilities": 42780.22
208
- }
209
- ],
210
- "recurring": [
211
- {
212
- "merchant": "Netflix",
213
- "amount": 15.49,
214
- "frequency": "MONTHLY",
215
- "next_date": "2026-03-18",
216
- "type": "subscription",
217
- "active": true
218
- }
219
- ],
220
- "budgets": [
221
- {
222
- "name": "Dining Out",
223
- "amount": 400,
224
- "spent": 182.55,
225
- "remaining": 217.45,
226
- "percent_used": 45.64,
227
- "period": "monthly",
228
- "status": "on_track"
229
- }
230
- ],
231
- "goals": [
232
- {
233
- "name": "Emergency Fund",
234
- "target": 10000,
235
- "current": 4200,
236
- "progress_percent": 42,
237
- "target_date": null,
238
- "status": "active",
239
- "category": "safety",
240
- "monthly_contribution_needed": 400
241
- }
242
- ],
243
- "health": {
244
- "score": 78,
245
- "label": "Good",
246
- "data_completeness": 94,
247
- "pillars": {
248
- "spend": 20,
249
- "save": 23,
250
- "borrow": 15,
251
- "build": 20
252
- },
253
- "message": null
254
- },
255
- "liabilities": [
256
- {
257
- "account_id": "acc_loan_1",
258
- "type": "student",
259
- "balance": 12450.22,
260
- "apr_percentage": 5.2,
261
- "minimum_payment": 145,
262
- "next_payment_due_date": "2026-03-22",
263
- "is_overdue": false
264
- }
265
- ],
266
- "holdings": [
267
- {
268
- "account_id": "acc_inv_1",
269
- "security_name": "Vanguard Total Stock Market ETF",
270
- "symbol": "VTI",
271
- "type": "etf",
272
- "quantity": 12.5,
273
- "value": 3541.25,
274
- "cost_basis": 3010
275
- }
276
- ],
277
- "category_spending": [
278
- {
279
- "category": "FOOD_AND_DRINK",
280
- "amount": 182.55
281
- }
282
- ]
272
+ "since": "2026-06-26",
273
+ "window_days": 10,
274
+ "count": 1,
275
+ "items": []
283
276
  }
284
277
  ```
285
278
 
286
- If Warm does not have enough state data yet, nullable fields remain `null`.
287
-
288
279
  ### `verify_key`
289
280
 
290
281
  Input:
@@ -302,10 +293,20 @@ Returns:
302
293
  }
303
294
  ```
304
295
 
296
+ ## v7 Breaking Changes
297
+
298
+ v7 is a clean pre-launch cutover with no backwards compatibility or alias window.
299
+
300
+ - API keys are audience-bound: context keys cannot invoke actions and automation keys cannot run context mode.
301
+ - `X-API-Key` is removed. Use `Authorization: Bearer`.
302
+ - Automation is a separate opt-in MCP server mode, not extra tools on the context server.
303
+
305
304
  ## Security
306
305
 
307
- - Read-only: no write, delete, transfer, or mutation tools
308
- - Scoped: the key only reads the owner's Warm data
306
+ - Context mode is read-only and uses a context-only key.
307
+ - Automation mode uses a separate automation-only key and a curated server-side operation allowlist.
308
+ - Streamable HTTP is bearer-protected with an independent `WARM_MCP_AUTH_TOKEN` where required.
309
+ - Write operations return an approval `{ id, status, approval_url, expires_at }`; invoke the exact input with its `approval_id`.
309
310
  - Revocable: delete the key in Settings to revoke access immediately
310
311
 
311
312
  ## Development
@@ -313,6 +314,7 @@ Returns:
313
314
  ```bash
314
315
  npm install
315
316
  npm run build
317
+ npm test
316
318
  ```
317
319
 
318
320
  ## License
@@ -1,3 +1,4 @@
1
+ export type WarmApiAudience = 'automation' | 'context';
1
2
  export declare function getWarmConfigDir(): string;
2
- export declare function getWarmApiKeyPath(): string;
3
+ export declare function getWarmApiKeyPath(audience?: WarmApiAudience): string;
3
4
  export declare function readConfigFile(configPath: string): string | null;
@@ -16,11 +16,13 @@ export function getWarmConfigDir() {
16
16
  }
17
17
  return path.join(os.homedir(), '.config', 'warm');
18
18
  }
19
- export function getWarmApiKeyPath() {
20
- if (process.env.WARM_API_KEY_FILE?.trim()) {
21
- return process.env.WARM_API_KEY_FILE.trim();
19
+ export function getWarmApiKeyPath(audience = 'context') {
20
+ const envKey = audience === 'automation' ? 'WARM_AUTOMATION_API_KEY_FILE' : 'WARM_CONTEXT_API_KEY_FILE';
21
+ const configuredPath = process.env[envKey]?.trim();
22
+ if (configuredPath) {
23
+ return configuredPath;
22
24
  }
23
- return path.join(getWarmConfigDir(), 'api_key');
25
+ return path.join(getWarmConfigDir(), `${audience}_api_key`);
24
26
  }
25
27
  export function readConfigFile(configPath) {
26
28
  try {
package/dist/http.d.ts CHANGED
@@ -1,8 +1,14 @@
1
+ import { type WarmServerMode } from './server.js';
1
2
  export interface HttpServerOptions {
2
3
  allowedHosts?: string[];
4
+ authToken?: string;
3
5
  host?: string;
6
+ mode?: WarmServerMode;
4
7
  path?: string;
5
8
  port?: number;
6
9
  }
10
+ export declare function isLoopbackHost(host: string): boolean;
11
+ export declare function validateHttpSecurity(options: Pick<HttpServerOptions, 'authToken' | 'host' | 'mode'>): void;
12
+ export declare function hasValidTransportBearerToken(authorization: string | undefined, authToken: string): boolean;
7
13
  export declare function resolveHttpServerOptions(overrides?: HttpServerOptions): Required<HttpServerOptions>;
8
14
  export declare function startHttpServer(options?: HttpServerOptions): Promise<any>;
package/dist/http.js CHANGED
@@ -1,9 +1,36 @@
1
1
  import { createMcpExpressApp } from '@modelcontextprotocol/sdk/server/express.js';
2
2
  import { StreamableHTTPServerTransport } from '@modelcontextprotocol/sdk/server/streamableHttp.js';
3
+ import { timingSafeEqual } from 'node:crypto';
3
4
  import { createWarmServer } from './server.js';
4
5
  const DEFAULT_HTTP_HOST = '127.0.0.1';
5
6
  const DEFAULT_HTTP_PORT = 3000;
6
7
  const DEFAULT_HTTP_PATH = '/mcp';
8
+ export function isLoopbackHost(host) {
9
+ const normalized = host.trim().toLowerCase().replace(/^\[|\]$/g, '');
10
+ return normalized === 'localhost' || normalized === '127.0.0.1' || normalized === '::1';
11
+ }
12
+ export function validateHttpSecurity(options) {
13
+ const mode = options.mode || 'context';
14
+ const hasAuthToken = Boolean(options.authToken?.trim());
15
+ if (mode === 'automation' && !hasAuthToken) {
16
+ throw new Error('Automation MCP HTTP requires WARM_MCP_AUTH_TOKEN.');
17
+ }
18
+ if (!isLoopbackHost(options.host || DEFAULT_HTTP_HOST) && !hasAuthToken) {
19
+ throw new Error('Non-loopback MCP HTTP requires WARM_MCP_AUTH_TOKEN.');
20
+ }
21
+ }
22
+ export function hasValidTransportBearerToken(authorization, authToken) {
23
+ const expected = `Bearer ${authToken}`;
24
+ if (!authorization) {
25
+ return false;
26
+ }
27
+ const actualBuffer = Buffer.from(authorization);
28
+ const expectedBuffer = Buffer.from(expected);
29
+ if (actualBuffer.length !== expectedBuffer.length) {
30
+ return false;
31
+ }
32
+ return timingSafeEqual(actualBuffer, expectedBuffer);
33
+ }
7
34
  function parsePort(value, fallback) {
8
35
  const parsed = Number(value);
9
36
  return Number.isInteger(parsed) && parsed > 0 ? parsed : fallback;
@@ -37,11 +64,13 @@ export function resolveHttpServerOptions(overrides = {}) {
37
64
  return {
38
65
  allowedHosts: overrides.allowedHosts ??
39
66
  parseAllowedHosts(process.env.WARM_MCP_ALLOWED_HOSTS || process.env.MCP_ALLOWED_HOSTS),
67
+ authToken: overrides.authToken ?? process.env.WARM_MCP_AUTH_TOKEN?.trim() ?? '',
40
68
  host: overrides.host ||
41
69
  process.env.WARM_MCP_HTTP_HOST ||
42
70
  process.env.MCP_HOST ||
43
71
  process.env.HOST ||
44
72
  DEFAULT_HTTP_HOST,
73
+ mode: overrides.mode ?? 'context',
45
74
  path: normalizePath(overrides.path || process.env.WARM_MCP_HTTP_PATH || process.env.MCP_PATH || DEFAULT_HTTP_PATH),
46
75
  port: overrides.port ??
47
76
  parsePort(process.env.WARM_MCP_HTTP_PORT || process.env.MCP_PORT || process.env.PORT, DEFAULT_HTTP_PORT),
@@ -54,12 +83,18 @@ function sendJsonMethodNotAllowed(res) {
54
83
  }
55
84
  export async function startHttpServer(options = {}) {
56
85
  const resolved = resolveHttpServerOptions(options);
86
+ validateHttpSecurity(resolved);
57
87
  const app = createMcpExpressApp({
58
88
  ...(resolved.allowedHosts.length > 0 ? { allowedHosts: resolved.allowedHosts } : {}),
59
89
  host: resolved.host,
60
90
  });
61
91
  app.post(resolved.path, async (req, res) => {
62
- const server = createWarmServer();
92
+ if (resolved.authToken &&
93
+ !hasValidTransportBearerToken(req.get('authorization'), resolved.authToken)) {
94
+ res.status(401).json(jsonRpcError('Unauthorized MCP transport request.'));
95
+ return;
96
+ }
97
+ const server = createWarmServer({ mode: resolved.mode });
63
98
  const transport = new StreamableHTTPServerTransport({
64
99
  sessionIdGenerator: undefined,
65
100
  });
package/dist/index.d.ts CHANGED
@@ -1,2 +1,3 @@
1
1
  #!/usr/bin/env node
2
- export declare function startStdioServer(): Promise<import("@modelcontextprotocol/sdk/server/mcp").McpServer>;
2
+ import { type WarmServerMode } from './server.js';
3
+ export declare function startStdioServer(mode?: WarmServerMode): Promise<import("@modelcontextprotocol/sdk/server/mcp").McpServer>;