@oliverames/ynab-mcp-server 1.3.0 → 1.6.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.
Files changed (3) hide show
  1. package/README.md +36 -22
  2. package/index.js +360 -154
  3. package/package.json +7 -3
package/README.md CHANGED
@@ -1,5 +1,5 @@
1
1
  <p align="center">
2
- <img src="https://api.ynab.com/papi/logo_api_meadow.svg" alt="YNAB API" width="200">
2
+ <img src="assets/icon.png" width="80" height="80" alt="YNAB">
3
3
  </p>
4
4
 
5
5
  <h1 align="center">YNAB MCP Server</h1>
@@ -12,19 +12,21 @@
12
12
  <p align="center">
13
13
  <code>44 tools</code> &bull;
14
14
  <code>100% API coverage</code> &bull;
15
- <code>YNAB API v1.82</code>
15
+ <code>YNAB API v1.83</code>
16
16
  </p>
17
17
 
18
18
  <p align="center">
19
19
  <a href="https://www.npmjs.com/package/@oliverames/ynab-mcp-server"><img src="https://img.shields.io/npm/v/%40oliverames%2Fynab-mcp-server?style=flat-square&color=f5a542" alt="npm"></a>
20
+ <a href="https://github.com/oliverames/ynab-mcp-server/releases/tag/v1.4.0"><img src="https://img.shields.io/github/v/release/oliverames/ynab-mcp-server?style=flat-square&color=f5a542&label=MCPB" alt="MCPB release"></a>
20
21
  <a href="LICENSE"><img src="https://img.shields.io/badge/license-MIT-f5a542?style=flat-square" alt="License"></a>
21
22
  <a href="https://www.buymeacoffee.com/oliverames"><img src="https://img.shields.io/badge/Buy_Me_a_Coffee-support-f5a542?style=flat-square&logo=buy-me-a-coffee&logoColor=white" alt="Buy Me a Coffee"></a>
22
23
  </p>
23
24
 
24
25
  <p align="center">
25
26
  <a href="#quick-start">Quick Start</a> &bull;
27
+ <a href="#install-with-mcpb">MCPB Download</a> &bull;
26
28
  <a href="#what-you-can-do">What You Can Do</a> &bull;
27
- <a href="#tools-reference">All 43 Tools</a> &bull;
29
+ <a href="#tools-reference">All 44 Tools</a> &bull;
28
30
  <a href="#environment-variables">Configuration</a>
29
31
  </p>
30
32
 
@@ -32,7 +34,7 @@
32
34
 
33
35
  ## Why This Exists
34
36
 
35
- YNAB's budgeting philosophy works best when you interact with your budget frequently but the app interface isn't designed for quick queries or bulk operations. "How much did I spend on groceries this month?" shouldn't require navigating three screens. "Categorize all my Amazon orders from this week" shouldn't be a manual, one-by-one process.
37
+ YNAB's budgeting philosophy works best when you interact with your budget frequently - but the app interface isn't designed for quick queries or bulk operations. "How much did I spend on groceries this month?" shouldn't require navigating three screens. "Categorize all my Amazon orders from this week" shouldn't be a manual, one-by-one process.
36
38
 
37
39
  This server gives your AI assistant full access to YNAB's API, turning natural language into budget operations. All monetary values are automatically converted between dollars and YNAB's internal milliunits format so the AI never has to think about it. Built on the [official YNAB JavaScript SDK](https://github.com/ynab/ynab-sdk-js) with direct API calls for the newest endpoints (category creation, category groups, money movements) that the SDK hasn't caught up with yet.
38
40
 
@@ -40,6 +42,14 @@ This server gives your AI assistant full access to YNAB's API, turning natural l
40
42
 
41
43
  ## Quick Start
42
44
 
45
+ ### Install with MCPB
46
+
47
+ For Claude Desktop and other MCPB-compatible clients, download the local bundle from the [v1.4.0 release](https://github.com/oliverames/ynab-mcp-server/releases/tag/v1.4.0):
48
+
49
+ [Download `ynab-mcp-server-1.4.0.mcpb`](https://github.com/oliverames/ynab-mcp-server/releases/download/v1.4.0/ynab-mcp-server-1.4.0.mcpb)
50
+
51
+ The bundle includes the YNAB favicon, production runtime dependencies, and setup prompts for your personal access token and optional default budget ID.
52
+
43
53
  ### 1. Get a YNAB Personal Access Token
44
54
 
45
55
  Go to [YNAB Developer Settings](https://app.ynab.com/settings/developer) and create a new personal access token.
@@ -121,7 +131,7 @@ That's it. Your AI can now talk to YNAB.
121
131
 
122
132
  ## Features
123
133
 
124
- **Complete YNAB API v1.82 coverage** with 44 tools:
134
+ **Complete YNAB API v1.83 coverage** with 44 tools:
125
135
 
126
136
  | Resource | Tools | Capabilities |
127
137
  |----------|-------|-------------|
@@ -138,15 +148,17 @@ That's it. Your AI can now talk to YNAB.
138
148
 
139
149
  ### Design Decisions
140
150
 
141
- - **Dollar amounts everywhere** inputs and outputs are in dollars (`-12.34`), never milliunits (`-12340`). Conversion is automatic and transparent.
142
- - **Smart budget resolution** set `YNAB_BUDGET_ID` for a default, or omit it to auto-resolve to your last-used budget. Every tool accepts an optional `budgetId` override.
143
- - **Split transactions** first-class support for subtransactions in create, read, and format operations.
144
- - **Bulk operations** `create_transactions` and `update_transactions` handle arrays in a single API call.
145
- - **Fetch-then-merge updates** scheduled transaction updates (which use PUT semantics) automatically fetch the current state and merge your changes, so you only specify what changed.
146
- - **Fuzzy search** `search_categories` and `search_payees` do case-insensitive partial matching across all entries.
147
- - **Approval workflow** `review_unapproved` groups transactions into "ready to approve" (categorized, split, or transfer) and "needs attention" (uncategorized), with a built-in warning against approving uncategorized entries.
148
- - **Nullable updates** update tools accept `null` for clearable fields (`memo`, `payeeName`, `categoryId`, `flagColor`) to distinguish "don't change" (omit) from "clear this field" (`null`).
149
- - **Debt account support** loan and debt accounts include `debt_original_balance`, `debt_interest_rates`, `debt_minimum_payments`, and `debt_escrow_amounts` with correct unit conversion (rates stay as percentages, payments convert from milliunits).
151
+ - **Dollar amounts everywhere** - inputs and outputs are in dollars (`-12.34`), never milliunits (`-12340`). Conversion is automatic and transparent.
152
+ - **Smart budget resolution** - set `YNAB_BUDGET_ID` for a default, or omit it to auto-resolve to your last-used budget. Every tool accepts an optional `budgetId` override.
153
+ - **Split transactions** - first-class support for subtransactions in create, read, and format operations.
154
+ - **Bulk operations** - `create_transactions` and `update_transactions` handle arrays in a single API call.
155
+ - **Fetch-then-merge updates** - scheduled transaction updates (which use PUT semantics) automatically fetch the current state and merge your changes, so you only specify what changed.
156
+ - **Fuzzy search** - `search_categories` and `search_payees` do case-insensitive partial matching across all entries.
157
+ - **Approval workflow with anomaly flags** - `review_unapproved` groups transactions into "ready to approve" (categorized, split, or transfer) and "needs attention" (uncategorized), and attaches a `flags` array to each transaction surfacing anomalies: `manually_entered` (not bank-imported), `match_broken` (stale match reference), `scheduled_transaction_realized`, `new_payee`, `no_prior_amount_match` (novel amount for this payee), and `category_drift:was_X` (payee categorized differently in the prior 60 days). Group-level flags aggregate the union of all transaction flags.
158
+ - **Nullable updates** - update tools accept `null` for clearable fields (`memo`, `payeeName`, `categoryId`, `flagColor`) to distinguish "don't change" (omit) from "clear this field" (`null`).
159
+ - **Target behavior support** - category create/update tools expose `goalNeedsWholeAmount` for YNAB's "Set aside another" vs. "Refill up to" goal behavior.
160
+ - **Delta request support** - high-volume list tools accept `lastKnowledgeOfServer` and return `server_knowledge` when that parameter is provided.
161
+ - **Debt account support** - loan and debt accounts include `debt_original_balance`, `debt_interest_rates`, `debt_minimum_payments`, and `debt_escrow_amounts` with correct unit conversion (rates stay as percentages, payments convert from milliunits).
150
162
 
151
163
  ---
152
164
 
@@ -224,10 +236,10 @@ That's it. Your AI can now talk to YNAB.
224
236
  | Tool | Description |
225
237
  |------|-------------|
226
238
  | `get_transactions` | Get transactions with filters: by account, category, payee, month, or status (`unapproved`/`uncategorized`) |
227
- | `get_transaction` | Get a single transaction by ID (includes subtransactions) |
239
+ | `get_transaction` | Get a single transaction by ID (includes subtransactions). Auto-handles composite scheduled-transaction IDs like `uuid_YYYY-MM-DD`. |
228
240
  | `create_transaction` | Create a transaction with optional split (subtransactions must sum to total) |
229
241
  | `create_transactions` | Bulk create multiple transactions in a single API call (supports split transactions) |
230
- | `update_transaction` | Partial update only specified fields change |
242
+ | `update_transaction` | Partial update - only specified fields change |
231
243
  | `update_transactions` | Batch update multiple transactions at once |
232
244
  | `delete_transaction` | Delete a transaction |
233
245
  | `import_transactions` | Trigger import from linked bank accounts |
@@ -248,7 +260,7 @@ That's it. Your AI can now talk to YNAB.
248
260
 
249
261
  | Tool | Description |
250
262
  |------|-------------|
251
- | `review_unapproved` | Get unapproved transactions grouped by readiness: "ready to approve" (categorized, split, or transfer) vs. "needs category first" (uncategorized). Includes a warning against blind approval. |
263
+ | `review_unapproved` | Get unapproved transactions grouped by readiness: "ready to approve" (categorized, split, or transfer) vs. "needs category first" (uncategorized). Each transaction includes a `flags` array highlighting anomalies (manually_entered, match_broken, no_prior_amount_match, category_drift, new_payee, scheduled_transaction_realized) computed against 60 days of payee history. Includes a warning against blind approval. |
252
264
 
253
265
  ---
254
266
 
@@ -299,7 +311,7 @@ All amounts in tool inputs and outputs are in **dollars** (e.g., `-12.34` for a
299
311
 
300
312
  ## Rate Limiting
301
313
 
302
- The YNAB API allows **200 requests per hour** per access token, enforced on a rolling window. Each tool call typically uses one API request (except `update_scheduled_transaction` which uses two a GET to fetch current state, then a PUT to merge changes). The server surfaces rate limit errors as standard MCP error responses.
314
+ The YNAB API allows **200 requests per hour** per access token, enforced on a rolling window. Each tool call typically uses one API request (except `update_scheduled_transaction` which uses two - a GET to fetch current state, then a PUT to merge changes). The server surfaces rate limit errors as standard MCP error responses.
303
315
 
304
316
  ---
305
317
 
@@ -323,13 +335,15 @@ The YNAB API allows **200 requests per hour** per access token, enforced on a ro
323
335
 
324
336
  ## Testing
325
337
 
326
- The test suite (43 tests) runs against a live YNAB budget. It creates test data and cleans up after itself:
338
+ The integration test suite runs against a live YNAB budget. Most write tests create temporary transactions and delete or restore them, but category and category group creation is not reversible through the public API and is skipped unless explicitly enabled.
327
339
 
328
340
  ```bash
329
341
  YNAB_API_TOKEN=your-token YNAB_BUDGET_ID=your-budget-id npm test
330
342
  ```
331
343
 
332
- Tests cover all tool categories: reads, writes, bulk operations, search, split transactions, scheduled transaction CRUD with fetch-then-merge verification, category/group creation, money movements, and payee locations.
344
+ Use `YNAB_TEST_BUDGET_ID` to target a dedicated test budget without changing your server default. To include category and category group creation coverage, run with `YNAB_RUN_NONREVERSIBLE_TESTS=1`.
345
+
346
+ Tests cover all tool categories: reads, reversible writes, bulk operations, search, split transactions, scheduled transaction CRUD with fetch-then-merge verification, money movements, and payee locations.
333
347
 
334
348
  ---
335
349
 
@@ -344,8 +358,8 @@ YNAB_API_TOKEN=your-token npm start
344
358
 
345
359
  ### Dependencies
346
360
 
347
- - [`@modelcontextprotocol/sdk`](https://www.npmjs.com/package/@modelcontextprotocol/sdk) MCP server framework
348
- - [`ynab`](https://www.npmjs.com/package/ynab) Official YNAB JavaScript client
361
+ - [`@modelcontextprotocol/sdk`](https://www.npmjs.com/package/@modelcontextprotocol/sdk) - MCP server framework
362
+ - [`ynab`](https://www.npmjs.com/package/ynab) - Official YNAB JavaScript client
349
363
 
350
364
  Zero additional dependencies. No build step. Pure ESM.
351
365
 
package/index.js CHANGED
@@ -9,18 +9,22 @@ import * as ynab from "ynab";
9
9
  // --- Init ---
10
10
 
11
11
  let API_TOKEN = process.env.YNAB_API_TOKEN;
12
+ let opLookupError;
12
13
  if (!API_TOKEN && process.env.YNAB_OP_PATH) {
13
14
  try {
14
15
  API_TOKEN = execFileSync(
15
16
  "op", ["read", process.env.YNAB_OP_PATH],
16
17
  { encoding: "utf-8", timeout: 10000, stdio: ["pipe", "pipe", "pipe"] }
17
18
  ).trim();
18
- } catch {
19
- // 1Password CLI unavailable or item not found at YNAB_OP_PATH
19
+ } catch (e) {
20
+ opLookupError = e.stderr?.toString().trim() || e.message || "unknown 1Password CLI error";
20
21
  }
21
22
  }
22
23
  if (!API_TOKEN) {
23
- console.error("YNAB_API_TOKEN environment variable is required. Set YNAB_OP_PATH to enable 1Password CLI fallback (e.g. op://Vault/Item/credential).");
24
+ const opMessage = process.env.YNAB_OP_PATH
25
+ ? ` Could not read YNAB_OP_PATH via 1Password CLI: ${opLookupError}.`
26
+ : " Set YNAB_OP_PATH to enable 1Password CLI fallback (e.g. op://Vault/Item/credential).";
27
+ console.error(`YNAB_API_TOKEN environment variable is required.${opMessage}`);
24
28
  process.exit(1);
25
29
  }
26
30
 
@@ -45,6 +49,16 @@ function dollarsMap(obj) {
45
49
  return obj ? Object.fromEntries(Object.entries(obj).map(([k, v]) => [k, dollars(v)])) : obj;
46
50
  }
47
51
 
52
+ function withCurrencyFields(out, source, fields) {
53
+ for (const field of fields) {
54
+ const formatted = `${field}_formatted`;
55
+ const currency = `${field}_currency`;
56
+ if (formatted in source) out[formatted] = source[formatted];
57
+ if (currency in source) out[currency] = source[currency];
58
+ }
59
+ return out;
60
+ }
61
+
48
62
  function mapTransactionInput(t) {
49
63
  const out = {
50
64
  account_id: t.accountId,
@@ -71,7 +85,7 @@ function mapTransactionInput(t) {
71
85
  return out;
72
86
  }
73
87
 
74
- // Sparse patch mapper for update_transaction / update_transactions only includes fields that were explicitly provided
88
+ // Sparse patch mapper for update_transaction / update_transactions - only includes fields that were explicitly provided
75
89
  function mapTransactionUpdate(t) {
76
90
  const out = {};
77
91
  if (t.accountId !== undefined) out.account_id = t.accountId;
@@ -87,10 +101,22 @@ function mapTransactionUpdate(t) {
87
101
  return out;
88
102
  }
89
103
 
104
+ // YNAB scheduled transactions that realize get composite IDs like `uuid_YYYY-MM-DD`.
105
+ // Strip the date suffix so API lookups work correctly.
106
+ function normalizeTransactionId(id) {
107
+ return id.replace(/_\d{4}-\d{2}-\d{2}$/, "");
108
+ }
109
+
90
110
  function ok(data) {
91
111
  return { content: [{ type: "text", text: JSON.stringify(data, null, 2) }] };
92
112
  }
93
113
 
114
+ function collection(data, key, items, lastKnowledgeOfServer) {
115
+ return lastKnowledgeOfServer === undefined
116
+ ? items
117
+ : { [key]: items, server_knowledge: data.server_knowledge };
118
+ }
119
+
94
120
  async function run(fn) {
95
121
  try {
96
122
  return await fn();
@@ -106,14 +132,19 @@ async function run(fn) {
106
132
 
107
133
  // Direct API helper for endpoints not yet in the ynab SDK
108
134
  const BASE_URL = "https://api.ynab.com/v1";
109
- async function ynabFetch(path, { method = "GET", body } = {}) {
135
+ async function ynabFetch(path, { method = "GET", body, query } = {}) {
136
+ const url = new URL(`${BASE_URL}${path}`);
137
+ for (const [key, value] of Object.entries(query || {})) {
138
+ if (value !== undefined && value !== null) url.searchParams.set(key, String(value));
139
+ }
110
140
  const opts = {
111
141
  method,
112
142
  headers: { Authorization: `Bearer ${API_TOKEN}`, "Content-Type": "application/json" },
113
143
  };
114
144
  if (body) opts.body = JSON.stringify(body);
115
- const res = await fetch(`${BASE_URL}${path}`, opts);
116
- const json = await res.json();
145
+ const res = await fetch(url, opts);
146
+ const text = await res.text();
147
+ const json = text ? JSON.parse(text) : {};
117
148
  if (!res.ok) {
118
149
  const err = new Error(json?.error?.detail || `HTTP ${res.status}`);
119
150
  err.error = json?.error;
@@ -126,7 +157,7 @@ async function ynabFetch(path, { method = "GET", body } = {}) {
126
157
 
127
158
  const server = new McpServer({
128
159
  name: "ynab-mcp-server",
129
- version: "1.3.0",
160
+ version: "1.6.0",
130
161
  });
131
162
 
132
163
  // ==================== User & Budgets ====================
@@ -212,16 +243,20 @@ function formatAccount(a) {
212
243
  deleted: a.deleted,
213
244
  };
214
245
  if ("note" in a) out.note = a.note;
215
- return out;
246
+ return withCurrencyFields(out, a, ["balance", "cleared_balance", "uncleared_balance"]);
216
247
  }
217
248
 
218
249
  server.registerTool(
219
250
  "list_accounts",
220
- { description: "List all accounts in a budget", inputSchema: { budgetId: z.string().optional().describe("Budget ID (uses default if not provided)") } },
221
- ({ budgetId }) =>
251
+ { description: "List all accounts in a budget", inputSchema: {
252
+ budgetId: z.string().optional().describe("Budget ID (uses default if not provided)"),
253
+ lastKnowledgeOfServer: z.number().int().nonnegative().optional().describe("Delta request server knowledge. When provided, returns { accounts, server_knowledge }."),
254
+ } },
255
+ ({ budgetId, lastKnowledgeOfServer }) =>
222
256
  run(async () => {
223
- const { data } = await api.accounts.getAccounts(resolveBudgetId(budgetId));
224
- return ok(data.accounts.map(formatAccount));
257
+ const { data } = await api.accounts.getAccounts(resolveBudgetId(budgetId), lastKnowledgeOfServer);
258
+ const accounts = data.accounts.map(formatAccount);
259
+ return ok(collection(data, "accounts", accounts, lastKnowledgeOfServer));
225
260
  })
226
261
  );
227
262
 
@@ -258,7 +293,7 @@ server.registerTool(
258
293
  // ==================== Categories ====================
259
294
 
260
295
  function formatCategory(c) {
261
- return {
296
+ const out = {
262
297
  id: c.id,
263
298
  category_group_id: c.category_group_id,
264
299
  category_group_name: c.category_group_name,
@@ -275,6 +310,7 @@ function formatCategory(c) {
275
310
  goal_cadence_frequency: c.goal_cadence_frequency,
276
311
  goal_creation_month: c.goal_creation_month,
277
312
  goal_target: dollars(c.goal_target),
313
+ goal_target_month: c.goal_target_month,
278
314
  goal_target_date: c.goal_target_date,
279
315
  goal_percentage_complete: c.goal_percentage_complete,
280
316
  goal_months_to_budget: c.goal_months_to_budget,
@@ -282,34 +318,53 @@ function formatCategory(c) {
282
318
  goal_overall_funded: dollars(c.goal_overall_funded),
283
319
  goal_overall_left: dollars(c.goal_overall_left),
284
320
  goal_needs_whole_amount: c.goal_needs_whole_amount,
321
+ goal_snoozed_at: c.goal_snoozed_at,
285
322
  deleted: c.deleted,
286
323
  };
324
+ return withCurrencyFields(out, c, [
325
+ "budgeted",
326
+ "activity",
327
+ "balance",
328
+ "goal_target",
329
+ "goal_under_funded",
330
+ "goal_overall_funded",
331
+ "goal_overall_left",
332
+ ]);
287
333
  }
288
334
 
289
335
  server.registerTool(
290
336
  "list_categories",
291
- { description: "List all category groups and their categories", inputSchema: { budgetId: z.string().optional().describe("Budget ID (uses default if not provided)") } },
292
- ({ budgetId }) =>
337
+ { description: "List all category groups and their categories", inputSchema: {
338
+ budgetId: z.string().optional().describe("Budget ID (uses default if not provided)"),
339
+ lastKnowledgeOfServer: z.number().int().nonnegative().optional().describe("Delta request server knowledge. When provided, returns { category_groups, server_knowledge }."),
340
+ } },
341
+ ({ budgetId, lastKnowledgeOfServer }) =>
293
342
  run(async () => {
294
- const { data } = await api.categories.getCategories(resolveBudgetId(budgetId));
295
- return ok(
296
- data.category_groups.map((g) => ({
343
+ const { data } = await api.categories.getCategories(resolveBudgetId(budgetId), lastKnowledgeOfServer);
344
+ const categoryGroups = data.category_groups.map((g) => ({
297
345
  id: g.id,
298
346
  name: g.name,
299
347
  hidden: g.hidden,
300
348
  deleted: g.deleted,
301
- categories: g.categories.map((c) => ({
302
- id: c.id,
303
- name: c.name,
304
- hidden: c.hidden,
305
- budgeted: dollars(c.budgeted),
306
- activity: dollars(c.activity),
307
- balance: dollars(c.balance),
308
- goal_type: c.goal_type,
309
- deleted: c.deleted,
310
- })),
311
- }))
312
- );
349
+ categories: g.categories.map((c) =>
350
+ withCurrencyFields(
351
+ {
352
+ id: c.id,
353
+ name: c.name,
354
+ hidden: c.hidden,
355
+ budgeted: dollars(c.budgeted),
356
+ activity: dollars(c.activity),
357
+ balance: dollars(c.balance),
358
+ goal_type: c.goal_type,
359
+ goal_needs_whole_amount: c.goal_needs_whole_amount,
360
+ deleted: c.deleted,
361
+ },
362
+ c,
363
+ ["budgeted", "activity", "balance"]
364
+ )
365
+ ),
366
+ }));
367
+ return ok(collection(data, "category_groups", categoryGroups, lastKnowledgeOfServer));
313
368
  })
314
369
  );
315
370
 
@@ -367,8 +422,9 @@ server.registerTool(
367
422
  categoryGroupId: z.string().optional().describe("Move to a different category group"),
368
423
  goalTarget: z.number().nullable().optional().describe("Goal target amount in dollars (only if category already has a goal)"),
369
424
  goalTargetDate: z.string().nullable().optional().describe("Goal target date in ISO format (e.g. 2026-12-01, null to clear)"),
425
+ goalNeedsWholeAmount: z.boolean().nullable().optional().describe("For NEED goals, true uses 'Set aside another' behavior and false uses 'Refill up to' behavior"),
370
426
  } },
371
- ({ budgetId, categoryId, name, note, categoryGroupId, goalTarget, goalTargetDate }) =>
427
+ ({ budgetId, categoryId, name, note, categoryGroupId, goalTarget, goalTargetDate, goalNeedsWholeAmount }) =>
372
428
  run(async () => {
373
429
  const cat = {};
374
430
  if (name !== undefined) cat.name = name;
@@ -376,9 +432,11 @@ server.registerTool(
376
432
  if (categoryGroupId !== undefined) cat.category_group_id = categoryGroupId;
377
433
  if (goalTarget !== undefined) cat.goal_target = goalTarget != null ? milliunits(goalTarget) : null;
378
434
  if (goalTargetDate !== undefined) cat.goal_target_date = goalTargetDate;
435
+ if (goalNeedsWholeAmount !== undefined) cat.goal_needs_whole_amount = goalNeedsWholeAmount;
379
436
 
380
- const { data } = await api.categories.updateCategory(resolveBudgetId(budgetId), categoryId, {
381
- category: cat,
437
+ const data = await ynabFetch(`/plans/${resolveBudgetId(budgetId)}/categories/${categoryId}`, {
438
+ method: "PATCH",
439
+ body: { category: cat },
382
440
  });
383
441
  return ok(formatCategory(data.category));
384
442
  })
@@ -393,15 +451,17 @@ server.registerTool(
393
451
  note: z.string().optional().describe("Category note"),
394
452
  goalTarget: z.number().optional().describe("Goal target amount in dollars (creates a 'Needed for Spending' goal)"),
395
453
  goalTargetDate: z.string().optional().describe("Goal target date in ISO format (e.g. 2026-12-01)"),
454
+ goalNeedsWholeAmount: z.boolean().optional().describe("For NEED goals, true uses 'Set aside another' behavior and false uses 'Refill up to' behavior"),
396
455
  } },
397
- ({ budgetId, categoryGroupId, name, note, goalTarget, goalTargetDate }) =>
456
+ ({ budgetId, categoryGroupId, name, note, goalTarget, goalTargetDate, goalNeedsWholeAmount }) =>
398
457
  run(async () => {
399
458
  const bid = resolveBudgetId(budgetId);
400
459
  const cat = { category_group_id: categoryGroupId, name };
401
460
  if (note !== undefined) cat.note = note;
402
461
  if (goalTarget !== undefined) cat.goal_target = milliunits(goalTarget);
403
462
  if (goalTargetDate !== undefined) cat.goal_target_date = goalTargetDate;
404
- const data = await ynabFetch(`/budgets/${bid}/categories`, {
463
+ if (goalNeedsWholeAmount !== undefined) cat.goal_needs_whole_amount = goalNeedsWholeAmount;
464
+ const data = await ynabFetch(`/plans/${bid}/categories`, {
405
465
  method: "POST",
406
466
  body: { category: cat },
407
467
  });
@@ -417,7 +477,7 @@ server.registerTool(
417
477
  } },
418
478
  ({ budgetId, name }) =>
419
479
  run(async () => {
420
- const data = await ynabFetch(`/budgets/${resolveBudgetId(budgetId)}/category_groups`, {
480
+ const data = await ynabFetch(`/plans/${resolveBudgetId(budgetId)}/category_groups`, {
421
481
  method: "POST",
422
482
  body: { category_group: { name } },
423
483
  });
@@ -434,7 +494,7 @@ server.registerTool(
434
494
  } },
435
495
  ({ budgetId, categoryGroupId, name }) =>
436
496
  run(async () => {
437
- const data = await ynabFetch(`/budgets/${resolveBudgetId(budgetId)}/category_groups/${categoryGroupId}`, {
497
+ const data = await ynabFetch(`/plans/${resolveBudgetId(budgetId)}/category_groups/${categoryGroupId}`, {
438
498
  method: "PATCH",
439
499
  body: { category_group: { name } },
440
500
  });
@@ -446,11 +506,15 @@ server.registerTool(
446
506
 
447
507
  server.registerTool(
448
508
  "list_payees",
449
- { description: "List all payees", inputSchema: { budgetId: z.string().optional().describe("Budget ID (uses default if not provided)") } },
450
- ({ budgetId }) =>
509
+ { description: "List all payees", inputSchema: {
510
+ budgetId: z.string().optional().describe("Budget ID (uses default if not provided)"),
511
+ lastKnowledgeOfServer: z.number().int().nonnegative().optional().describe("Delta request server knowledge. When provided, returns { payees, server_knowledge }."),
512
+ } },
513
+ ({ budgetId, lastKnowledgeOfServer }) =>
451
514
  run(async () => {
452
- const { data } = await api.payees.getPayees(resolveBudgetId(budgetId));
453
- return ok(data.payees.map((p) => ({ id: p.id, name: p.name, transfer_account_id: p.transfer_account_id, deleted: p.deleted })));
515
+ const { data } = await api.payees.getPayees(resolveBudgetId(budgetId), lastKnowledgeOfServer);
516
+ const payees = data.payees.map((p) => ({ id: p.id, name: p.name, transfer_account_id: p.transfer_account_id, deleted: p.deleted }));
517
+ return ok(collection(data, "payees", payees, lastKnowledgeOfServer));
454
518
  })
455
519
  );
456
520
 
@@ -491,7 +555,7 @@ server.registerTool(
491
555
  } },
492
556
  ({ budgetId, name }) =>
493
557
  run(async () => {
494
- const data = await ynabFetch(`/budgets/${resolveBudgetId(budgetId)}/payees`, {
558
+ const data = await ynabFetch(`/plans/${resolveBudgetId(budgetId)}/payees`, {
495
559
  method: "POST",
496
560
  body: { payee: { name } },
497
561
  });
@@ -541,22 +605,30 @@ server.registerTool(
541
605
 
542
606
  server.registerTool(
543
607
  "list_months",
544
- { description: "List all budget months", inputSchema: { budgetId: z.string().optional().describe("Budget ID (uses default if not provided)") } },
545
- ({ budgetId }) =>
608
+ { description: "List all budget months", inputSchema: {
609
+ budgetId: z.string().optional().describe("Budget ID (uses default if not provided)"),
610
+ lastKnowledgeOfServer: z.number().int().nonnegative().optional().describe("Delta request server knowledge. When provided, returns { months, server_knowledge }."),
611
+ } },
612
+ ({ budgetId, lastKnowledgeOfServer }) =>
546
613
  run(async () => {
547
- const { data } = await api.months.getBudgetMonths(resolveBudgetId(budgetId));
548
- return ok(
549
- data.months.map((m) => ({
550
- month: m.month,
551
- note: m.note,
552
- income: dollars(m.income),
553
- budgeted: dollars(m.budgeted),
554
- activity: dollars(m.activity),
555
- to_be_budgeted: dollars(m.to_be_budgeted),
556
- age_of_money: m.age_of_money,
557
- deleted: m.deleted,
558
- }))
559
- );
614
+ const { data } = await api.months.getBudgetMonths(resolveBudgetId(budgetId), lastKnowledgeOfServer);
615
+ const months = data.months.map((m) =>
616
+ withCurrencyFields(
617
+ {
618
+ month: m.month,
619
+ note: m.note,
620
+ income: dollars(m.income),
621
+ budgeted: dollars(m.budgeted),
622
+ activity: dollars(m.activity),
623
+ to_be_budgeted: dollars(m.to_be_budgeted),
624
+ age_of_money: m.age_of_money,
625
+ deleted: m.deleted,
626
+ },
627
+ m,
628
+ ["income", "budgeted", "activity", "to_be_budgeted"]
629
+ )
630
+ );
631
+ return ok(collection(data, "months", months, lastKnowledgeOfServer));
560
632
  })
561
633
  );
562
634
 
@@ -570,7 +642,7 @@ server.registerTool(
570
642
  run(async () => {
571
643
  const { data } = await api.months.getBudgetMonth(resolveBudgetId(budgetId), month);
572
644
  const m = data.month;
573
- return ok({
645
+ const out = {
574
646
  month: m.month,
575
647
  note: m.note,
576
648
  income: dollars(m.income),
@@ -579,27 +651,38 @@ server.registerTool(
579
651
  to_be_budgeted: dollars(m.to_be_budgeted),
580
652
  age_of_money: m.age_of_money,
581
653
  deleted: m.deleted,
582
- categories: m.categories?.map((c) => ({
583
- id: c.id,
584
- name: c.name,
585
- hidden: c.hidden,
586
- category_group_name: c.category_group_name,
587
- budgeted: dollars(c.budgeted),
588
- activity: dollars(c.activity),
589
- balance: dollars(c.balance),
590
- goal_type: c.goal_type,
591
- goal_target: dollars(c.goal_target),
592
- goal_under_funded: dollars(c.goal_under_funded),
593
- deleted: c.deleted,
594
- })),
595
- });
654
+ categories: m.categories?.map((c) =>
655
+ withCurrencyFields(
656
+ {
657
+ id: c.id,
658
+ name: c.name,
659
+ hidden: c.hidden,
660
+ category_group_name: c.category_group_name,
661
+ budgeted: dollars(c.budgeted),
662
+ activity: dollars(c.activity),
663
+ balance: dollars(c.balance),
664
+ goal_type: c.goal_type,
665
+ goal_needs_whole_amount: c.goal_needs_whole_amount,
666
+ goal_target: dollars(c.goal_target),
667
+ goal_target_month: c.goal_target_month,
668
+ goal_target_date: c.goal_target_date,
669
+ goal_under_funded: dollars(c.goal_under_funded),
670
+ goal_snoozed_at: c.goal_snoozed_at,
671
+ deleted: c.deleted,
672
+ },
673
+ c,
674
+ ["budgeted", "activity", "balance", "goal_target", "goal_under_funded"]
675
+ )
676
+ ),
677
+ };
678
+ return ok(withCurrencyFields(out, m, ["income", "budgeted", "activity", "to_be_budgeted"]));
596
679
  })
597
680
  );
598
681
 
599
682
  // ==================== Money Movements ====================
600
683
 
601
684
  function formatMoneyMovement(m) {
602
- return {
685
+ return withCurrencyFields({
603
686
  id: m.id,
604
687
  month: m.month,
605
688
  moved_at: m.moved_at,
@@ -610,7 +693,7 @@ function formatMoneyMovement(m) {
610
693
  to_category_id: m.to_category_id,
611
694
  amount: dollars(m.amount),
612
695
  deleted: m.deleted,
613
- };
696
+ }, m, ["amount"]);
614
697
  }
615
698
 
616
699
  server.registerTool(
@@ -618,7 +701,7 @@ server.registerTool(
618
701
  { description: "List all money movements (budget re-allocations between categories)", inputSchema: { budgetId: z.string().optional().describe("Budget ID (uses default if not provided)") } },
619
702
  ({ budgetId }) =>
620
703
  run(async () => {
621
- const data = await ynabFetch(`/budgets/${resolveBudgetId(budgetId)}/money_movements`);
704
+ const data = await ynabFetch(`/plans/${resolveBudgetId(budgetId)}/money_movements`);
622
705
  return ok(data.money_movements.map(formatMoneyMovement));
623
706
  })
624
707
  );
@@ -631,7 +714,7 @@ server.registerTool(
631
714
  } },
632
715
  ({ budgetId, month }) =>
633
716
  run(async () => {
634
- const data = await ynabFetch(`/budgets/${resolveBudgetId(budgetId)}/months/${month}/money_movements`);
717
+ const data = await ynabFetch(`/plans/${resolveBudgetId(budgetId)}/months/${month}/money_movements`);
635
718
  return ok(data.money_movements.map(formatMoneyMovement));
636
719
  })
637
720
  );
@@ -641,7 +724,7 @@ server.registerTool(
641
724
  { description: "List all money movement groups (batches of related money movements)", inputSchema: { budgetId: z.string().optional().describe("Budget ID (uses default if not provided)") } },
642
725
  ({ budgetId }) =>
643
726
  run(async () => {
644
- const data = await ynabFetch(`/budgets/${resolveBudgetId(budgetId)}/money_movement_groups`);
727
+ const data = await ynabFetch(`/plans/${resolveBudgetId(budgetId)}/money_movement_groups`);
645
728
  return ok(data.money_movement_groups);
646
729
  })
647
730
  );
@@ -654,7 +737,7 @@ server.registerTool(
654
737
  } },
655
738
  ({ budgetId, month }) =>
656
739
  run(async () => {
657
- const data = await ynabFetch(`/budgets/${resolveBudgetId(budgetId)}/months/${month}/money_movement_groups`);
740
+ const data = await ynabFetch(`/plans/${resolveBudgetId(budgetId)}/months/${month}/money_movement_groups`);
658
741
  return ok(data.money_movement_groups);
659
742
  })
660
743
  );
@@ -662,43 +745,50 @@ server.registerTool(
662
745
  // ==================== Transactions ====================
663
746
 
664
747
  function formatTransaction(t) {
665
- return {
748
+ const out = {
666
749
  id: t.id,
667
750
  date: t.date,
668
751
  amount: dollars(t.amount),
669
- memo: t.memo,
752
+ memo: t.memo ?? null,
670
753
  cleared: t.cleared,
671
754
  approved: t.approved,
672
- flag_color: t.flag_color,
673
- flag_name: t.flag_name,
755
+ flag_color: t.flag_color ?? null,
756
+ flag_name: t.flag_name ?? null,
674
757
  account_id: t.account_id,
675
758
  account_name: t.account_name,
676
- payee_id: t.payee_id,
677
- payee_name: t.payee_name,
678
- category_id: t.category_id,
679
- category_name: t.category_name,
680
- transfer_account_id: t.transfer_account_id,
681
- transfer_transaction_id: t.transfer_transaction_id,
682
- matched_transaction_id: t.matched_transaction_id,
683
- import_id: t.import_id,
684
- import_payee_name: t.import_payee_name,
685
- import_payee_name_original: t.import_payee_name_original,
686
- debt_transaction_type: t.debt_transaction_type,
759
+ payee_id: t.payee_id ?? null,
760
+ payee_name: t.payee_name ?? null,
761
+ category_id: t.category_id ?? null,
762
+ category_name: t.category_name ?? null,
763
+ transfer_account_id: t.transfer_account_id ?? null,
764
+ transfer_transaction_id: t.transfer_transaction_id ?? null,
765
+ matched_transaction_id: t.matched_transaction_id ?? null,
766
+ import_id: t.import_id ?? null,
767
+ import_payee_name: t.import_payee_name ?? null,
768
+ import_payee_name_original: t.import_payee_name_original ?? null,
769
+ debt_transaction_type: t.debt_transaction_type ?? null,
687
770
  deleted: t.deleted,
688
- subtransactions: t.subtransactions?.map((s) => ({
689
- id: s.id,
690
- transaction_id: s.transaction_id,
691
- amount: dollars(s.amount),
692
- memo: s.memo,
693
- payee_id: s.payee_id,
694
- payee_name: s.payee_name,
695
- category_id: s.category_id,
696
- category_name: s.category_name,
697
- transfer_account_id: s.transfer_account_id,
698
- transfer_transaction_id: s.transfer_transaction_id,
699
- deleted: s.deleted,
700
- })),
771
+ subtransactions: t.subtransactions?.map((s) =>
772
+ withCurrencyFields(
773
+ {
774
+ id: s.id,
775
+ transaction_id: s.transaction_id,
776
+ amount: dollars(s.amount),
777
+ memo: s.memo ?? null,
778
+ payee_id: s.payee_id ?? null,
779
+ payee_name: s.payee_name ?? null,
780
+ category_id: s.category_id ?? null,
781
+ category_name: s.category_name ?? null,
782
+ transfer_account_id: s.transfer_account_id ?? null,
783
+ transfer_transaction_id: s.transfer_transaction_id ?? null,
784
+ deleted: s.deleted,
785
+ },
786
+ s,
787
+ ["amount"]
788
+ )
789
+ ),
701
790
  };
791
+ return withCurrencyFields(out, t, ["amount"]);
702
792
  }
703
793
 
704
794
  server.registerTool(
@@ -711,49 +801,55 @@ server.registerTool(
711
801
  categoryId: z.string().optional().describe("Filter by category ID"),
712
802
  payeeId: z.string().optional().describe("Filter by payee ID"),
713
803
  month: z.string().optional().describe("Filter by month (YYYY-MM-DD, first of month)"),
804
+ lastKnowledgeOfServer: z.number().int().nonnegative().optional().describe("Delta request server knowledge. When provided, returns { transactions, server_knowledge }."),
714
805
  } },
715
- ({ budgetId, sinceDate, type, accountId, categoryId, payeeId, month }) =>
806
+ ({ budgetId, sinceDate, type, accountId, categoryId, payeeId, month, lastKnowledgeOfServer }) =>
716
807
  run(async () => {
717
808
  const bid = resolveBudgetId(budgetId);
718
809
  let transactions;
810
+ let data;
811
+ const resourceFilters = [accountId, categoryId, payeeId, month].filter((value) => value !== undefined && value !== null && value !== "");
812
+ if (resourceFilters.length > 1) {
813
+ throw new Error("Provide only one of accountId, categoryId, payeeId, or month.");
814
+ }
719
815
 
720
816
  if (accountId) {
721
- const { data } = await api.transactions.getTransactionsByAccount(bid, accountId, sinceDate, type);
817
+ ({ data } = await api.transactions.getTransactionsByAccount(bid, accountId, sinceDate, type, lastKnowledgeOfServer));
722
818
  transactions = data.transactions;
723
819
  } else if (categoryId) {
724
- const { data } = await api.transactions.getTransactionsByCategory(bid, categoryId, sinceDate, type);
820
+ ({ data } = await api.transactions.getTransactionsByCategory(bid, categoryId, sinceDate, type, lastKnowledgeOfServer));
725
821
  transactions = data.transactions;
726
822
  } else if (payeeId) {
727
- const { data } = await api.transactions.getTransactionsByPayee(bid, payeeId, sinceDate, type);
823
+ ({ data } = await api.transactions.getTransactionsByPayee(bid, payeeId, sinceDate, type, lastKnowledgeOfServer));
728
824
  transactions = data.transactions;
729
825
  } else if (month) {
730
- const { data } = await api.transactions.getTransactionsByMonth(bid, month, sinceDate, type);
826
+ ({ data } = await api.transactions.getTransactionsByMonth(bid, month, sinceDate, type, lastKnowledgeOfServer));
731
827
  transactions = data.transactions;
732
828
  } else {
733
- const { data } = await api.transactions.getTransactions(bid, sinceDate, type);
829
+ ({ data } = await api.transactions.getTransactions(bid, sinceDate, type, lastKnowledgeOfServer));
734
830
  transactions = data.transactions;
735
831
  }
736
832
 
737
- return ok(transactions.map(formatTransaction));
833
+ return ok(collection(data, "transactions", transactions.map(formatTransaction), lastKnowledgeOfServer));
738
834
  })
739
835
  );
740
836
 
741
837
  server.registerTool(
742
838
  "get_transaction",
743
- { description: "Get a single transaction by ID", inputSchema: {
839
+ { description: "Get a single transaction by ID. Automatically handles composite scheduled-transaction IDs (e.g. uuid_YYYY-MM-DD).", inputSchema: {
744
840
  budgetId: z.string().optional().describe("Budget ID (uses default if not provided)"),
745
841
  transactionId: z.string().describe("Transaction ID"),
746
842
  } },
747
843
  ({ budgetId, transactionId }) =>
748
844
  run(async () => {
749
- const { data } = await api.transactions.getTransactionById(resolveBudgetId(budgetId), transactionId);
845
+ const { data } = await api.transactions.getTransactionById(resolveBudgetId(budgetId), normalizeTransactionId(transactionId));
750
846
  return ok(formatTransaction(data.transaction));
751
847
  })
752
848
  );
753
849
 
754
850
  server.registerTool(
755
851
  "create_transaction",
756
- { description: "Create a new transaction. Amounts are in dollars (positive for inflows, negative for outflows). Note: future-dated transactions cannot be created here use create_scheduled_transaction instead.", inputSchema: {
852
+ { description: "Create a new transaction. Amounts are in dollars (positive for inflows, negative for outflows). Note: future-dated transactions cannot be created here - use create_scheduled_transaction instead.", inputSchema: {
757
853
  budgetId: z.string().optional().describe("Budget ID (uses default if not provided)"),
758
854
  accountId: z.string().describe("Account ID"),
759
855
  date: z.string().describe("Transaction date (YYYY-MM-DD)"),
@@ -785,7 +881,7 @@ server.registerTool(
785
881
 
786
882
  server.registerTool(
787
883
  "create_transactions",
788
- { description: "Create multiple transactions at once. Amounts are in dollars. Returns created transactions and any duplicate import IDs. Future-dated transactions are not supported use create_scheduled_transaction instead.", inputSchema: {
884
+ { description: "Create multiple transactions at once. Amounts are in dollars. Returns created transactions and any duplicate import IDs. Future-dated transactions are not supported - use create_scheduled_transaction instead.", inputSchema: {
789
885
  budgetId: z.string().optional().describe("Budget ID (uses default if not provided)"),
790
886
  transactions: z.array(z.object({
791
887
  accountId: z.string().describe("Account ID"),
@@ -906,45 +1002,56 @@ server.registerTool(
906
1002
  // ==================== Scheduled Transactions ====================
907
1003
 
908
1004
  function formatScheduledTransaction(t) {
909
- return {
1005
+ const out = {
910
1006
  id: t.id,
911
1007
  date_first: t.date_first,
912
1008
  date_next: t.date_next,
913
1009
  frequency: t.frequency,
914
1010
  amount: dollars(t.amount),
915
- memo: t.memo,
916
- flag_color: t.flag_color,
917
- flag_name: t.flag_name,
1011
+ memo: t.memo ?? null,
1012
+ flag_color: t.flag_color ?? null,
1013
+ flag_name: t.flag_name ?? null,
918
1014
  account_id: t.account_id,
919
1015
  account_name: t.account_name,
920
- payee_id: t.payee_id,
921
- payee_name: t.payee_name,
922
- category_id: t.category_id,
923
- category_name: t.category_name,
924
- transfer_account_id: t.transfer_account_id,
1016
+ payee_id: t.payee_id ?? null,
1017
+ payee_name: t.payee_name ?? null,
1018
+ category_id: t.category_id ?? null,
1019
+ category_name: t.category_name ?? null,
1020
+ transfer_account_id: t.transfer_account_id ?? null,
925
1021
  deleted: t.deleted,
926
- subtransactions: t.subtransactions?.map((s) => ({
927
- id: s.id,
928
- scheduled_transaction_id: s.scheduled_transaction_id,
929
- amount: dollars(s.amount),
930
- memo: s.memo,
931
- payee_id: s.payee_id,
932
- payee_name: s.payee_name,
933
- category_id: s.category_id,
934
- category_name: s.category_name,
935
- transfer_account_id: s.transfer_account_id,
936
- deleted: s.deleted,
937
- })),
1022
+ subtransactions: t.subtransactions?.map((s) =>
1023
+ withCurrencyFields(
1024
+ {
1025
+ id: s.id,
1026
+ scheduled_transaction_id: s.scheduled_transaction_id,
1027
+ amount: dollars(s.amount),
1028
+ memo: s.memo ?? null,
1029
+ payee_id: s.payee_id ?? null,
1030
+ payee_name: s.payee_name ?? null,
1031
+ category_id: s.category_id ?? null,
1032
+ category_name: s.category_name ?? null,
1033
+ transfer_account_id: s.transfer_account_id ?? null,
1034
+ deleted: s.deleted,
1035
+ },
1036
+ s,
1037
+ ["amount"]
1038
+ )
1039
+ ),
938
1040
  };
1041
+ return withCurrencyFields(out, t, ["amount"]);
939
1042
  }
940
1043
 
941
1044
  server.registerTool(
942
1045
  "list_scheduled_transactions",
943
- { description: "List all scheduled (recurring) transactions", inputSchema: { budgetId: z.string().optional().describe("Budget ID (uses default if not provided)") } },
944
- ({ budgetId }) =>
1046
+ { description: "List all scheduled (recurring) transactions. NOTE: only manually-created recurring entries appear here — auto-imported recurring charges (subscriptions, utilities, insurance) are NOT included. Use prior-month transaction history to identify recurring charge timing instead.", inputSchema: {
1047
+ budgetId: z.string().optional().describe("Budget ID (uses default if not provided)"),
1048
+ lastKnowledgeOfServer: z.number().int().nonnegative().optional().describe("Delta request server knowledge. When provided, returns { scheduled_transactions, server_knowledge }."),
1049
+ } },
1050
+ ({ budgetId, lastKnowledgeOfServer }) =>
945
1051
  run(async () => {
946
- const { data } = await api.scheduledTransactions.getScheduledTransactions(resolveBudgetId(budgetId));
947
- return ok(data.scheduled_transactions.map(formatScheduledTransaction));
1052
+ const { data } = await api.scheduledTransactions.getScheduledTransactions(resolveBudgetId(budgetId), lastKnowledgeOfServer);
1053
+ const scheduledTransactions = data.scheduled_transactions.map(formatScheduledTransaction);
1054
+ return ok(collection(data, "scheduled_transactions", scheduledTransactions, lastKnowledgeOfServer));
948
1055
  })
949
1056
  );
950
1057
 
@@ -1012,7 +1119,7 @@ server.registerTool(
1012
1119
  ({ budgetId, scheduledTransactionId, accountId, date, frequency, amount, payeeId, payeeName, categoryId, memo, flagColor }) =>
1013
1120
  run(async () => {
1014
1121
  const bid = resolveBudgetId(budgetId);
1015
- // PUT replaces the full resource fetch current values to merge with updates
1122
+ // PUT replaces the full resource - fetch current values to merge with updates
1016
1123
  const { data: current } = await api.scheduledTransactions.getScheduledTransactionById(bid, scheduledTransactionId);
1017
1124
  const existing = current.scheduled_transaction;
1018
1125
 
@@ -1068,14 +1175,14 @@ server.registerTool(
1068
1175
  for (const c of g.categories) {
1069
1176
  if (c.hidden) continue;
1070
1177
  if (c.name.toLowerCase().includes(q)) {
1071
- matches.push({
1178
+ matches.push(withCurrencyFields({
1072
1179
  id: c.id,
1073
1180
  name: c.name,
1074
1181
  group: g.name,
1075
1182
  budgeted: dollars(c.budgeted),
1076
1183
  activity: dollars(c.activity),
1077
1184
  balance: dollars(c.balance),
1078
- });
1185
+ }, c, ["budgeted", "activity", "balance"]));
1079
1186
  }
1080
1187
  }
1081
1188
  }
@@ -1104,21 +1211,91 @@ server.registerTool(
1104
1211
 
1105
1212
  server.registerTool(
1106
1213
  "review_unapproved",
1107
- { description: "Get all unapproved transactions grouped by status: those already categorized (ready to approve) and those still uncategorized (need category first). Never approve uncategorized transactions without explicit user instruction.", inputSchema: { budgetId: z.string().optional().describe("Budget ID (uses default if not provided)") } },
1214
+ { description: "Get all unapproved transactions grouped by status: those already categorized (ready to approve) and those still uncategorized (need category first). Each transaction includes a 'flags' array: manually_entered (not bank-imported), match_broken (matched reference may be stale), scheduled_transaction_realized, new_payee (no transaction history for this payee), no_prior_amount_match (novel amount for this payee), category_drift:was_X (payee categorized differently before). Never approve uncategorized transactions without explicit user instruction.", inputSchema: { budgetId: z.string().optional().describe("Budget ID (uses default if not provided)") } },
1108
1215
  ({ budgetId }) =>
1109
1216
  run(async () => {
1110
- const { data } = await api.transactions.getTransactions(resolveBudgetId(budgetId), undefined, "unapproved");
1111
- const txns = data.transactions.map(formatTransaction);
1217
+ const bid = resolveBudgetId(budgetId);
1218
+
1219
+ // Fetch unapproved transactions
1220
+ const { data: unapprovedData } = await api.transactions.getTransactions(bid, undefined, "unapproved");
1221
+ const txns = unapprovedData.transactions.map(formatTransaction);
1222
+ const unapprovedIds = new Set(txns.map((t) => t.id));
1223
+
1224
+ // Fetch 60 days of approved history for context
1225
+ const since60 = new Date(Date.now() - 60 * 24 * 60 * 60 * 1000).toISOString().slice(0, 10);
1226
+ const { data: histData } = await api.transactions.getTransactions(bid, since60);
1227
+ const histTxns = histData.transactions.filter((t) => t.approved && !unapprovedIds.has(t.id));
1228
+
1229
+ // Build payee history lookups (using raw milliunits for history, convert to dollars for the set)
1230
+ const payeeAmounts = {}; // payeeId -> Set of dollar amounts seen
1231
+ const payeeCategories = {}; // payeeId -> Map of categoryId -> categoryName
1232
+ for (const h of histTxns) {
1233
+ if (!h.payee_id) continue;
1234
+ const pid = h.payee_id;
1235
+ const amt = dollars(h.amount);
1236
+ const cid = h.category_id;
1237
+ const cname = h.category_name;
1238
+ if (!payeeAmounts[pid]) payeeAmounts[pid] = new Set();
1239
+ payeeAmounts[pid].add(amt);
1240
+ if (cid) {
1241
+ if (!payeeCategories[pid]) payeeCategories[pid] = new Map();
1242
+ payeeCategories[pid].set(cid, cname);
1243
+ }
1244
+ }
1245
+
1246
+ // Attach flags to each unapproved transaction
1247
+ function flagTransaction(t) {
1248
+ const flags = [];
1249
+ const isTransfer = !!t.transfer_account_id;
1250
+ if (!t.import_id && !isTransfer) flags.push("manually_entered");
1251
+ if (t.matched_transaction_id && !t.import_id) flags.push("match_broken");
1252
+ if (/_\d{4}-\d{2}-\d{2}$/.test(t.id)) flags.push("scheduled_transaction_realized");
1253
+ if (t.payee_id) {
1254
+ const hasHistory = !!payeeAmounts[t.payee_id];
1255
+ if (!hasHistory) {
1256
+ flags.push("new_payee");
1257
+ } else {
1258
+ if (!payeeAmounts[t.payee_id].has(t.amount)) flags.push("no_prior_amount_match");
1259
+ if (t.category_id && payeeCategories[t.payee_id] && !payeeCategories[t.payee_id].has(t.category_id)) {
1260
+ const priorNames = [...payeeCategories[t.payee_id].values()].join(", ");
1261
+ flags.push(`category_drift:was_${priorNames}`);
1262
+ }
1263
+ }
1264
+ }
1265
+ return { ...t, flags };
1266
+ }
1267
+
1268
+ const flaggedTxns = txns.map(flagTransaction);
1269
+
1112
1270
  const isCategorized = (t) => (t.category_id && t.category_name !== "Uncategorized")
1113
- || (t.subtransactions && t.subtransactions.length > 0) // split transactions are categorized via subtransactions
1114
- || t.transfer_account_id; // transfers don't need categories
1271
+ || (t.subtransactions && t.subtransactions.length > 0)
1272
+ || t.transfer_account_id;
1115
1273
  const categorized = [], uncategorized = [];
1116
- for (const t of txns) (isCategorized(t) ? categorized : uncategorized).push(t);
1274
+ for (const t of flaggedTxns) (isCategorized(t) ? categorized : uncategorized).push(t);
1275
+
1276
+ // Group categorized transactions by payee for easier per-group review
1277
+ const byPayee = {};
1278
+ for (const t of categorized) {
1279
+ const key = t.payee_name || "Unknown Payee";
1280
+ if (!byPayee[key]) byPayee[key] = { payee: key, category_name: t.category_name, transactions: [] };
1281
+ byPayee[key].transactions.push(t);
1282
+ }
1283
+ const groups = Object.values(byPayee).map((g) => {
1284
+ // Aggregate flags across all transactions in the group (deduplicated)
1285
+ const allFlags = [...new Set(g.transactions.flatMap((t) => t.flags))];
1286
+ return {
1287
+ ...g,
1288
+ count: g.transactions.length,
1289
+ total: g.transactions.reduce((sum, t) => sum + t.amount, 0),
1290
+ flags: allFlags,
1291
+ };
1292
+ });
1293
+
1117
1294
  return ok({
1118
- total: txns.length,
1295
+ total: flaggedTxns.length,
1119
1296
  ready_to_approve: {
1120
1297
  count: categorized.length,
1121
- transactions: categorized,
1298
+ by_payee: groups,
1122
1299
  },
1123
1300
  needs_category_first: {
1124
1301
  count: uncategorized.length,
@@ -1129,6 +1306,35 @@ server.registerTool(
1129
1306
  })
1130
1307
  );
1131
1308
 
1309
+ server.registerTool(
1310
+ "get_overspent_categories",
1311
+ { description: "Get all categories with a negative balance for a given month. Use this to find prior-month overspends that are silently reducing the current month's Ready to Assign.", inputSchema: {
1312
+ budgetId: z.string().optional().describe("Budget ID (uses default if not provided)"),
1313
+ month: z.string().describe("Month in YYYY-MM-DD format (first of month)"),
1314
+ } },
1315
+ ({ budgetId, month }) =>
1316
+ run(async () => {
1317
+ const { data } = await api.months.getBudgetMonth(resolveBudgetId(budgetId), month);
1318
+ const overspent = (data.month.categories || [])
1319
+ .filter((c) => !c.deleted && c.balance < 0 && c.category_group_name !== "Internal Master Category")
1320
+ .map((c) => ({
1321
+ id: c.id,
1322
+ name: c.name,
1323
+ category_group_name: c.category_group_name,
1324
+ budgeted: dollars(c.budgeted),
1325
+ activity: dollars(c.activity),
1326
+ balance: dollars(c.balance),
1327
+ }))
1328
+ .sort((a, b) => a.balance - b.balance);
1329
+ return ok({
1330
+ month,
1331
+ overspent_count: overspent.length,
1332
+ total_overspent: overspent.reduce((sum, c) => sum + c.balance, 0),
1333
+ categories: overspent,
1334
+ });
1335
+ })
1336
+ );
1337
+
1132
1338
  // --- Start ---
1133
1339
 
1134
1340
  process.on("uncaughtException", (err) => {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@oliverames/ynab-mcp-server",
3
- "version": "1.3.0",
3
+ "version": "1.6.0",
4
4
  "description": "YNAB MCP server with full API coverage",
5
5
  "type": "module",
6
6
  "main": "index.js",
@@ -15,7 +15,11 @@
15
15
  "test": "node test.js"
16
16
  },
17
17
  "dependencies": {
18
- "@modelcontextprotocol/sdk": "^1.12.1",
19
- "ynab": "^2.5.0"
18
+ "@modelcontextprotocol/sdk": "^1.29.0",
19
+ "ynab": "^2.5.0",
20
+ "zod": "^4.3.6"
21
+ },
22
+ "engines": {
23
+ "node": ">=18"
20
24
  }
21
25
  }