@oliverames/ynab-mcp-server 1.3.0 → 1.4.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 +23 -19
  2. package/index.js +244 -143
  3. package/package.json +7 -3
package/README.md CHANGED
@@ -12,7 +12,7 @@
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">
@@ -24,7 +24,7 @@
24
24
  <p align="center">
25
25
  <a href="#quick-start">Quick Start</a> &bull;
26
26
  <a href="#what-you-can-do">What You Can Do</a> &bull;
27
- <a href="#tools-reference">All 43 Tools</a> &bull;
27
+ <a href="#tools-reference">All 44 Tools</a> &bull;
28
28
  <a href="#environment-variables">Configuration</a>
29
29
  </p>
30
30
 
@@ -32,7 +32,7 @@
32
32
 
33
33
  ## Why This Exists
34
34
 
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.
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.
36
36
 
37
37
  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
38
 
@@ -121,7 +121,7 @@ That's it. Your AI can now talk to YNAB.
121
121
 
122
122
  ## Features
123
123
 
124
- **Complete YNAB API v1.82 coverage** with 44 tools:
124
+ **Complete YNAB API v1.83 coverage** with 44 tools:
125
125
 
126
126
  | Resource | Tools | Capabilities |
127
127
  |----------|-------|-------------|
@@ -138,15 +138,17 @@ That's it. Your AI can now talk to YNAB.
138
138
 
139
139
  ### Design Decisions
140
140
 
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).
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
+ - **Target behavior support** - category create/update tools expose `goalNeedsWholeAmount` for YNAB's "Set aside another" vs. "Refill up to" goal behavior.
150
+ - **Delta request support** - high-volume list tools accept `lastKnowledgeOfServer` and return `server_knowledge` when that parameter is provided.
151
+ - **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
152
 
151
153
  ---
152
154
 
@@ -227,7 +229,7 @@ That's it. Your AI can now talk to YNAB.
227
229
  | `get_transaction` | Get a single transaction by ID (includes subtransactions) |
228
230
  | `create_transaction` | Create a transaction with optional split (subtransactions must sum to total) |
229
231
  | `create_transactions` | Bulk create multiple transactions in a single API call (supports split transactions) |
230
- | `update_transaction` | Partial update only specified fields change |
232
+ | `update_transaction` | Partial update - only specified fields change |
231
233
  | `update_transactions` | Batch update multiple transactions at once |
232
234
  | `delete_transaction` | Delete a transaction |
233
235
  | `import_transactions` | Trigger import from linked bank accounts |
@@ -299,7 +301,7 @@ All amounts in tool inputs and outputs are in **dollars** (e.g., `-12.34` for a
299
301
 
300
302
  ## Rate Limiting
301
303
 
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.
304
+ 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
305
 
304
306
  ---
305
307
 
@@ -323,13 +325,15 @@ The YNAB API allows **200 requests per hour** per access token, enforced on a ro
323
325
 
324
326
  ## Testing
325
327
 
326
- The test suite (43 tests) runs against a live YNAB budget. It creates test data and cleans up after itself:
328
+ 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
329
 
328
330
  ```bash
329
331
  YNAB_API_TOKEN=your-token YNAB_BUDGET_ID=your-budget-id npm test
330
332
  ```
331
333
 
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.
334
+ 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`.
335
+
336
+ 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
337
 
334
338
  ---
335
339
 
@@ -344,8 +348,8 @@ YNAB_API_TOKEN=your-token npm start
344
348
 
345
349
  ### Dependencies
346
350
 
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
351
+ - [`@modelcontextprotocol/sdk`](https://www.npmjs.com/package/@modelcontextprotocol/sdk) - MCP server framework
352
+ - [`ynab`](https://www.npmjs.com/package/ynab) - Official YNAB JavaScript client
349
353
 
350
354
  Zero additional dependencies. No build step. Pure ESM.
351
355
 
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;
@@ -91,6 +105,12 @@ function ok(data) {
91
105
  return { content: [{ type: "text", text: JSON.stringify(data, null, 2) }] };
92
106
  }
93
107
 
108
+ function collection(data, key, items, lastKnowledgeOfServer) {
109
+ return lastKnowledgeOfServer === undefined
110
+ ? items
111
+ : { [key]: items, server_knowledge: data.server_knowledge };
112
+ }
113
+
94
114
  async function run(fn) {
95
115
  try {
96
116
  return await fn();
@@ -106,14 +126,19 @@ async function run(fn) {
106
126
 
107
127
  // Direct API helper for endpoints not yet in the ynab SDK
108
128
  const BASE_URL = "https://api.ynab.com/v1";
109
- async function ynabFetch(path, { method = "GET", body } = {}) {
129
+ async function ynabFetch(path, { method = "GET", body, query } = {}) {
130
+ const url = new URL(`${BASE_URL}${path}`);
131
+ for (const [key, value] of Object.entries(query || {})) {
132
+ if (value !== undefined && value !== null) url.searchParams.set(key, String(value));
133
+ }
110
134
  const opts = {
111
135
  method,
112
136
  headers: { Authorization: `Bearer ${API_TOKEN}`, "Content-Type": "application/json" },
113
137
  };
114
138
  if (body) opts.body = JSON.stringify(body);
115
- const res = await fetch(`${BASE_URL}${path}`, opts);
116
- const json = await res.json();
139
+ const res = await fetch(url, opts);
140
+ const text = await res.text();
141
+ const json = text ? JSON.parse(text) : {};
117
142
  if (!res.ok) {
118
143
  const err = new Error(json?.error?.detail || `HTTP ${res.status}`);
119
144
  err.error = json?.error;
@@ -212,16 +237,20 @@ function formatAccount(a) {
212
237
  deleted: a.deleted,
213
238
  };
214
239
  if ("note" in a) out.note = a.note;
215
- return out;
240
+ return withCurrencyFields(out, a, ["balance", "cleared_balance", "uncleared_balance"]);
216
241
  }
217
242
 
218
243
  server.registerTool(
219
244
  "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 }) =>
245
+ { description: "List all accounts in a budget", inputSchema: {
246
+ budgetId: z.string().optional().describe("Budget ID (uses default if not provided)"),
247
+ lastKnowledgeOfServer: z.number().int().nonnegative().optional().describe("Delta request server knowledge. When provided, returns { accounts, server_knowledge }."),
248
+ } },
249
+ ({ budgetId, lastKnowledgeOfServer }) =>
222
250
  run(async () => {
223
- const { data } = await api.accounts.getAccounts(resolveBudgetId(budgetId));
224
- return ok(data.accounts.map(formatAccount));
251
+ const { data } = await api.accounts.getAccounts(resolveBudgetId(budgetId), lastKnowledgeOfServer);
252
+ const accounts = data.accounts.map(formatAccount);
253
+ return ok(collection(data, "accounts", accounts, lastKnowledgeOfServer));
225
254
  })
226
255
  );
227
256
 
@@ -258,7 +287,7 @@ server.registerTool(
258
287
  // ==================== Categories ====================
259
288
 
260
289
  function formatCategory(c) {
261
- return {
290
+ const out = {
262
291
  id: c.id,
263
292
  category_group_id: c.category_group_id,
264
293
  category_group_name: c.category_group_name,
@@ -275,6 +304,7 @@ function formatCategory(c) {
275
304
  goal_cadence_frequency: c.goal_cadence_frequency,
276
305
  goal_creation_month: c.goal_creation_month,
277
306
  goal_target: dollars(c.goal_target),
307
+ goal_target_month: c.goal_target_month,
278
308
  goal_target_date: c.goal_target_date,
279
309
  goal_percentage_complete: c.goal_percentage_complete,
280
310
  goal_months_to_budget: c.goal_months_to_budget,
@@ -282,34 +312,53 @@ function formatCategory(c) {
282
312
  goal_overall_funded: dollars(c.goal_overall_funded),
283
313
  goal_overall_left: dollars(c.goal_overall_left),
284
314
  goal_needs_whole_amount: c.goal_needs_whole_amount,
315
+ goal_snoozed_at: c.goal_snoozed_at,
285
316
  deleted: c.deleted,
286
317
  };
318
+ return withCurrencyFields(out, c, [
319
+ "budgeted",
320
+ "activity",
321
+ "balance",
322
+ "goal_target",
323
+ "goal_under_funded",
324
+ "goal_overall_funded",
325
+ "goal_overall_left",
326
+ ]);
287
327
  }
288
328
 
289
329
  server.registerTool(
290
330
  "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 }) =>
331
+ { description: "List all category groups and their categories", inputSchema: {
332
+ budgetId: z.string().optional().describe("Budget ID (uses default if not provided)"),
333
+ lastKnowledgeOfServer: z.number().int().nonnegative().optional().describe("Delta request server knowledge. When provided, returns { category_groups, server_knowledge }."),
334
+ } },
335
+ ({ budgetId, lastKnowledgeOfServer }) =>
293
336
  run(async () => {
294
- const { data } = await api.categories.getCategories(resolveBudgetId(budgetId));
295
- return ok(
296
- data.category_groups.map((g) => ({
337
+ const { data } = await api.categories.getCategories(resolveBudgetId(budgetId), lastKnowledgeOfServer);
338
+ const categoryGroups = data.category_groups.map((g) => ({
297
339
  id: g.id,
298
340
  name: g.name,
299
341
  hidden: g.hidden,
300
342
  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
- );
343
+ categories: g.categories.map((c) =>
344
+ withCurrencyFields(
345
+ {
346
+ id: c.id,
347
+ name: c.name,
348
+ hidden: c.hidden,
349
+ budgeted: dollars(c.budgeted),
350
+ activity: dollars(c.activity),
351
+ balance: dollars(c.balance),
352
+ goal_type: c.goal_type,
353
+ goal_needs_whole_amount: c.goal_needs_whole_amount,
354
+ deleted: c.deleted,
355
+ },
356
+ c,
357
+ ["budgeted", "activity", "balance"]
358
+ )
359
+ ),
360
+ }));
361
+ return ok(collection(data, "category_groups", categoryGroups, lastKnowledgeOfServer));
313
362
  })
314
363
  );
315
364
 
@@ -367,8 +416,9 @@ server.registerTool(
367
416
  categoryGroupId: z.string().optional().describe("Move to a different category group"),
368
417
  goalTarget: z.number().nullable().optional().describe("Goal target amount in dollars (only if category already has a goal)"),
369
418
  goalTargetDate: z.string().nullable().optional().describe("Goal target date in ISO format (e.g. 2026-12-01, null to clear)"),
419
+ goalNeedsWholeAmount: z.boolean().nullable().optional().describe("For NEED goals, true uses 'Set aside another' behavior and false uses 'Refill up to' behavior"),
370
420
  } },
371
- ({ budgetId, categoryId, name, note, categoryGroupId, goalTarget, goalTargetDate }) =>
421
+ ({ budgetId, categoryId, name, note, categoryGroupId, goalTarget, goalTargetDate, goalNeedsWholeAmount }) =>
372
422
  run(async () => {
373
423
  const cat = {};
374
424
  if (name !== undefined) cat.name = name;
@@ -376,9 +426,11 @@ server.registerTool(
376
426
  if (categoryGroupId !== undefined) cat.category_group_id = categoryGroupId;
377
427
  if (goalTarget !== undefined) cat.goal_target = goalTarget != null ? milliunits(goalTarget) : null;
378
428
  if (goalTargetDate !== undefined) cat.goal_target_date = goalTargetDate;
429
+ if (goalNeedsWholeAmount !== undefined) cat.goal_needs_whole_amount = goalNeedsWholeAmount;
379
430
 
380
- const { data } = await api.categories.updateCategory(resolveBudgetId(budgetId), categoryId, {
381
- category: cat,
431
+ const data = await ynabFetch(`/plans/${resolveBudgetId(budgetId)}/categories/${categoryId}`, {
432
+ method: "PATCH",
433
+ body: { category: cat },
382
434
  });
383
435
  return ok(formatCategory(data.category));
384
436
  })
@@ -393,15 +445,17 @@ server.registerTool(
393
445
  note: z.string().optional().describe("Category note"),
394
446
  goalTarget: z.number().optional().describe("Goal target amount in dollars (creates a 'Needed for Spending' goal)"),
395
447
  goalTargetDate: z.string().optional().describe("Goal target date in ISO format (e.g. 2026-12-01)"),
448
+ goalNeedsWholeAmount: z.boolean().optional().describe("For NEED goals, true uses 'Set aside another' behavior and false uses 'Refill up to' behavior"),
396
449
  } },
397
- ({ budgetId, categoryGroupId, name, note, goalTarget, goalTargetDate }) =>
450
+ ({ budgetId, categoryGroupId, name, note, goalTarget, goalTargetDate, goalNeedsWholeAmount }) =>
398
451
  run(async () => {
399
452
  const bid = resolveBudgetId(budgetId);
400
453
  const cat = { category_group_id: categoryGroupId, name };
401
454
  if (note !== undefined) cat.note = note;
402
455
  if (goalTarget !== undefined) cat.goal_target = milliunits(goalTarget);
403
456
  if (goalTargetDate !== undefined) cat.goal_target_date = goalTargetDate;
404
- const data = await ynabFetch(`/budgets/${bid}/categories`, {
457
+ if (goalNeedsWholeAmount !== undefined) cat.goal_needs_whole_amount = goalNeedsWholeAmount;
458
+ const data = await ynabFetch(`/plans/${bid}/categories`, {
405
459
  method: "POST",
406
460
  body: { category: cat },
407
461
  });
@@ -417,7 +471,7 @@ server.registerTool(
417
471
  } },
418
472
  ({ budgetId, name }) =>
419
473
  run(async () => {
420
- const data = await ynabFetch(`/budgets/${resolveBudgetId(budgetId)}/category_groups`, {
474
+ const data = await ynabFetch(`/plans/${resolveBudgetId(budgetId)}/category_groups`, {
421
475
  method: "POST",
422
476
  body: { category_group: { name } },
423
477
  });
@@ -434,7 +488,7 @@ server.registerTool(
434
488
  } },
435
489
  ({ budgetId, categoryGroupId, name }) =>
436
490
  run(async () => {
437
- const data = await ynabFetch(`/budgets/${resolveBudgetId(budgetId)}/category_groups/${categoryGroupId}`, {
491
+ const data = await ynabFetch(`/plans/${resolveBudgetId(budgetId)}/category_groups/${categoryGroupId}`, {
438
492
  method: "PATCH",
439
493
  body: { category_group: { name } },
440
494
  });
@@ -446,11 +500,15 @@ server.registerTool(
446
500
 
447
501
  server.registerTool(
448
502
  "list_payees",
449
- { description: "List all payees", inputSchema: { budgetId: z.string().optional().describe("Budget ID (uses default if not provided)") } },
450
- ({ budgetId }) =>
503
+ { description: "List all payees", inputSchema: {
504
+ budgetId: z.string().optional().describe("Budget ID (uses default if not provided)"),
505
+ lastKnowledgeOfServer: z.number().int().nonnegative().optional().describe("Delta request server knowledge. When provided, returns { payees, server_knowledge }."),
506
+ } },
507
+ ({ budgetId, lastKnowledgeOfServer }) =>
451
508
  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 })));
509
+ const { data } = await api.payees.getPayees(resolveBudgetId(budgetId), lastKnowledgeOfServer);
510
+ const payees = data.payees.map((p) => ({ id: p.id, name: p.name, transfer_account_id: p.transfer_account_id, deleted: p.deleted }));
511
+ return ok(collection(data, "payees", payees, lastKnowledgeOfServer));
454
512
  })
455
513
  );
456
514
 
@@ -491,7 +549,7 @@ server.registerTool(
491
549
  } },
492
550
  ({ budgetId, name }) =>
493
551
  run(async () => {
494
- const data = await ynabFetch(`/budgets/${resolveBudgetId(budgetId)}/payees`, {
552
+ const data = await ynabFetch(`/plans/${resolveBudgetId(budgetId)}/payees`, {
495
553
  method: "POST",
496
554
  body: { payee: { name } },
497
555
  });
@@ -541,22 +599,30 @@ server.registerTool(
541
599
 
542
600
  server.registerTool(
543
601
  "list_months",
544
- { description: "List all budget months", inputSchema: { budgetId: z.string().optional().describe("Budget ID (uses default if not provided)") } },
545
- ({ budgetId }) =>
602
+ { description: "List all budget months", inputSchema: {
603
+ budgetId: z.string().optional().describe("Budget ID (uses default if not provided)"),
604
+ lastKnowledgeOfServer: z.number().int().nonnegative().optional().describe("Delta request server knowledge. When provided, returns { months, server_knowledge }."),
605
+ } },
606
+ ({ budgetId, lastKnowledgeOfServer }) =>
546
607
  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
- );
608
+ const { data } = await api.months.getBudgetMonths(resolveBudgetId(budgetId), lastKnowledgeOfServer);
609
+ const months = data.months.map((m) =>
610
+ withCurrencyFields(
611
+ {
612
+ month: m.month,
613
+ note: m.note,
614
+ income: dollars(m.income),
615
+ budgeted: dollars(m.budgeted),
616
+ activity: dollars(m.activity),
617
+ to_be_budgeted: dollars(m.to_be_budgeted),
618
+ age_of_money: m.age_of_money,
619
+ deleted: m.deleted,
620
+ },
621
+ m,
622
+ ["income", "budgeted", "activity", "to_be_budgeted"]
623
+ )
624
+ );
625
+ return ok(collection(data, "months", months, lastKnowledgeOfServer));
560
626
  })
561
627
  );
562
628
 
@@ -570,7 +636,7 @@ server.registerTool(
570
636
  run(async () => {
571
637
  const { data } = await api.months.getBudgetMonth(resolveBudgetId(budgetId), month);
572
638
  const m = data.month;
573
- return ok({
639
+ const out = {
574
640
  month: m.month,
575
641
  note: m.note,
576
642
  income: dollars(m.income),
@@ -579,27 +645,38 @@ server.registerTool(
579
645
  to_be_budgeted: dollars(m.to_be_budgeted),
580
646
  age_of_money: m.age_of_money,
581
647
  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
- });
648
+ categories: m.categories?.map((c) =>
649
+ withCurrencyFields(
650
+ {
651
+ id: c.id,
652
+ name: c.name,
653
+ hidden: c.hidden,
654
+ category_group_name: c.category_group_name,
655
+ budgeted: dollars(c.budgeted),
656
+ activity: dollars(c.activity),
657
+ balance: dollars(c.balance),
658
+ goal_type: c.goal_type,
659
+ goal_needs_whole_amount: c.goal_needs_whole_amount,
660
+ goal_target: dollars(c.goal_target),
661
+ goal_target_month: c.goal_target_month,
662
+ goal_target_date: c.goal_target_date,
663
+ goal_under_funded: dollars(c.goal_under_funded),
664
+ goal_snoozed_at: c.goal_snoozed_at,
665
+ deleted: c.deleted,
666
+ },
667
+ c,
668
+ ["budgeted", "activity", "balance", "goal_target", "goal_under_funded"]
669
+ )
670
+ ),
671
+ };
672
+ return ok(withCurrencyFields(out, m, ["income", "budgeted", "activity", "to_be_budgeted"]));
596
673
  })
597
674
  );
598
675
 
599
676
  // ==================== Money Movements ====================
600
677
 
601
678
  function formatMoneyMovement(m) {
602
- return {
679
+ return withCurrencyFields({
603
680
  id: m.id,
604
681
  month: m.month,
605
682
  moved_at: m.moved_at,
@@ -610,7 +687,7 @@ function formatMoneyMovement(m) {
610
687
  to_category_id: m.to_category_id,
611
688
  amount: dollars(m.amount),
612
689
  deleted: m.deleted,
613
- };
690
+ }, m, ["amount"]);
614
691
  }
615
692
 
616
693
  server.registerTool(
@@ -618,7 +695,7 @@ server.registerTool(
618
695
  { description: "List all money movements (budget re-allocations between categories)", inputSchema: { budgetId: z.string().optional().describe("Budget ID (uses default if not provided)") } },
619
696
  ({ budgetId }) =>
620
697
  run(async () => {
621
- const data = await ynabFetch(`/budgets/${resolveBudgetId(budgetId)}/money_movements`);
698
+ const data = await ynabFetch(`/plans/${resolveBudgetId(budgetId)}/money_movements`);
622
699
  return ok(data.money_movements.map(formatMoneyMovement));
623
700
  })
624
701
  );
@@ -631,7 +708,7 @@ server.registerTool(
631
708
  } },
632
709
  ({ budgetId, month }) =>
633
710
  run(async () => {
634
- const data = await ynabFetch(`/budgets/${resolveBudgetId(budgetId)}/months/${month}/money_movements`);
711
+ const data = await ynabFetch(`/plans/${resolveBudgetId(budgetId)}/months/${month}/money_movements`);
635
712
  return ok(data.money_movements.map(formatMoneyMovement));
636
713
  })
637
714
  );
@@ -641,7 +718,7 @@ server.registerTool(
641
718
  { 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
719
  ({ budgetId }) =>
643
720
  run(async () => {
644
- const data = await ynabFetch(`/budgets/${resolveBudgetId(budgetId)}/money_movement_groups`);
721
+ const data = await ynabFetch(`/plans/${resolveBudgetId(budgetId)}/money_movement_groups`);
645
722
  return ok(data.money_movement_groups);
646
723
  })
647
724
  );
@@ -654,7 +731,7 @@ server.registerTool(
654
731
  } },
655
732
  ({ budgetId, month }) =>
656
733
  run(async () => {
657
- const data = await ynabFetch(`/budgets/${resolveBudgetId(budgetId)}/months/${month}/money_movement_groups`);
734
+ const data = await ynabFetch(`/plans/${resolveBudgetId(budgetId)}/months/${month}/money_movement_groups`);
658
735
  return ok(data.money_movement_groups);
659
736
  })
660
737
  );
@@ -662,43 +739,50 @@ server.registerTool(
662
739
  // ==================== Transactions ====================
663
740
 
664
741
  function formatTransaction(t) {
665
- return {
742
+ const out = {
666
743
  id: t.id,
667
744
  date: t.date,
668
745
  amount: dollars(t.amount),
669
- memo: t.memo,
746
+ memo: t.memo ?? null,
670
747
  cleared: t.cleared,
671
748
  approved: t.approved,
672
- flag_color: t.flag_color,
673
- flag_name: t.flag_name,
749
+ flag_color: t.flag_color ?? null,
750
+ flag_name: t.flag_name ?? null,
674
751
  account_id: t.account_id,
675
752
  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,
753
+ payee_id: t.payee_id ?? null,
754
+ payee_name: t.payee_name ?? null,
755
+ category_id: t.category_id ?? null,
756
+ category_name: t.category_name ?? null,
757
+ transfer_account_id: t.transfer_account_id ?? null,
758
+ transfer_transaction_id: t.transfer_transaction_id ?? null,
759
+ matched_transaction_id: t.matched_transaction_id ?? null,
760
+ import_id: t.import_id ?? null,
761
+ import_payee_name: t.import_payee_name ?? null,
762
+ import_payee_name_original: t.import_payee_name_original ?? null,
763
+ debt_transaction_type: t.debt_transaction_type ?? null,
687
764
  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
- })),
765
+ subtransactions: t.subtransactions?.map((s) =>
766
+ withCurrencyFields(
767
+ {
768
+ id: s.id,
769
+ transaction_id: s.transaction_id,
770
+ amount: dollars(s.amount),
771
+ memo: s.memo ?? null,
772
+ payee_id: s.payee_id ?? null,
773
+ payee_name: s.payee_name ?? null,
774
+ category_id: s.category_id ?? null,
775
+ category_name: s.category_name ?? null,
776
+ transfer_account_id: s.transfer_account_id ?? null,
777
+ transfer_transaction_id: s.transfer_transaction_id ?? null,
778
+ deleted: s.deleted,
779
+ },
780
+ s,
781
+ ["amount"]
782
+ )
783
+ ),
701
784
  };
785
+ return withCurrencyFields(out, t, ["amount"]);
702
786
  }
703
787
 
704
788
  server.registerTool(
@@ -711,30 +795,36 @@ server.registerTool(
711
795
  categoryId: z.string().optional().describe("Filter by category ID"),
712
796
  payeeId: z.string().optional().describe("Filter by payee ID"),
713
797
  month: z.string().optional().describe("Filter by month (YYYY-MM-DD, first of month)"),
798
+ lastKnowledgeOfServer: z.number().int().nonnegative().optional().describe("Delta request server knowledge. When provided, returns { transactions, server_knowledge }."),
714
799
  } },
715
- ({ budgetId, sinceDate, type, accountId, categoryId, payeeId, month }) =>
800
+ ({ budgetId, sinceDate, type, accountId, categoryId, payeeId, month, lastKnowledgeOfServer }) =>
716
801
  run(async () => {
717
802
  const bid = resolveBudgetId(budgetId);
718
803
  let transactions;
804
+ let data;
805
+ const resourceFilters = [accountId, categoryId, payeeId, month].filter((value) => value !== undefined && value !== null && value !== "");
806
+ if (resourceFilters.length > 1) {
807
+ throw new Error("Provide only one of accountId, categoryId, payeeId, or month.");
808
+ }
719
809
 
720
810
  if (accountId) {
721
- const { data } = await api.transactions.getTransactionsByAccount(bid, accountId, sinceDate, type);
811
+ ({ data } = await api.transactions.getTransactionsByAccount(bid, accountId, sinceDate, type, lastKnowledgeOfServer));
722
812
  transactions = data.transactions;
723
813
  } else if (categoryId) {
724
- const { data } = await api.transactions.getTransactionsByCategory(bid, categoryId, sinceDate, type);
814
+ ({ data } = await api.transactions.getTransactionsByCategory(bid, categoryId, sinceDate, type, lastKnowledgeOfServer));
725
815
  transactions = data.transactions;
726
816
  } else if (payeeId) {
727
- const { data } = await api.transactions.getTransactionsByPayee(bid, payeeId, sinceDate, type);
817
+ ({ data } = await api.transactions.getTransactionsByPayee(bid, payeeId, sinceDate, type, lastKnowledgeOfServer));
728
818
  transactions = data.transactions;
729
819
  } else if (month) {
730
- const { data } = await api.transactions.getTransactionsByMonth(bid, month, sinceDate, type);
820
+ ({ data } = await api.transactions.getTransactionsByMonth(bid, month, sinceDate, type, lastKnowledgeOfServer));
731
821
  transactions = data.transactions;
732
822
  } else {
733
- const { data } = await api.transactions.getTransactions(bid, sinceDate, type);
823
+ ({ data } = await api.transactions.getTransactions(bid, sinceDate, type, lastKnowledgeOfServer));
734
824
  transactions = data.transactions;
735
825
  }
736
826
 
737
- return ok(transactions.map(formatTransaction));
827
+ return ok(collection(data, "transactions", transactions.map(formatTransaction), lastKnowledgeOfServer));
738
828
  })
739
829
  );
740
830
 
@@ -753,7 +843,7 @@ server.registerTool(
753
843
 
754
844
  server.registerTool(
755
845
  "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: {
846
+ { 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
847
  budgetId: z.string().optional().describe("Budget ID (uses default if not provided)"),
758
848
  accountId: z.string().describe("Account ID"),
759
849
  date: z.string().describe("Transaction date (YYYY-MM-DD)"),
@@ -785,7 +875,7 @@ server.registerTool(
785
875
 
786
876
  server.registerTool(
787
877
  "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: {
878
+ { 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
879
  budgetId: z.string().optional().describe("Budget ID (uses default if not provided)"),
790
880
  transactions: z.array(z.object({
791
881
  accountId: z.string().describe("Account ID"),
@@ -906,45 +996,56 @@ server.registerTool(
906
996
  // ==================== Scheduled Transactions ====================
907
997
 
908
998
  function formatScheduledTransaction(t) {
909
- return {
999
+ const out = {
910
1000
  id: t.id,
911
1001
  date_first: t.date_first,
912
1002
  date_next: t.date_next,
913
1003
  frequency: t.frequency,
914
1004
  amount: dollars(t.amount),
915
- memo: t.memo,
916
- flag_color: t.flag_color,
917
- flag_name: t.flag_name,
1005
+ memo: t.memo ?? null,
1006
+ flag_color: t.flag_color ?? null,
1007
+ flag_name: t.flag_name ?? null,
918
1008
  account_id: t.account_id,
919
1009
  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,
1010
+ payee_id: t.payee_id ?? null,
1011
+ payee_name: t.payee_name ?? null,
1012
+ category_id: t.category_id ?? null,
1013
+ category_name: t.category_name ?? null,
1014
+ transfer_account_id: t.transfer_account_id ?? null,
925
1015
  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
- })),
1016
+ subtransactions: t.subtransactions?.map((s) =>
1017
+ withCurrencyFields(
1018
+ {
1019
+ id: s.id,
1020
+ scheduled_transaction_id: s.scheduled_transaction_id,
1021
+ amount: dollars(s.amount),
1022
+ memo: s.memo ?? null,
1023
+ payee_id: s.payee_id ?? null,
1024
+ payee_name: s.payee_name ?? null,
1025
+ category_id: s.category_id ?? null,
1026
+ category_name: s.category_name ?? null,
1027
+ transfer_account_id: s.transfer_account_id ?? null,
1028
+ deleted: s.deleted,
1029
+ },
1030
+ s,
1031
+ ["amount"]
1032
+ )
1033
+ ),
938
1034
  };
1035
+ return withCurrencyFields(out, t, ["amount"]);
939
1036
  }
940
1037
 
941
1038
  server.registerTool(
942
1039
  "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 }) =>
1040
+ { description: "List all scheduled (recurring) transactions", inputSchema: {
1041
+ budgetId: z.string().optional().describe("Budget ID (uses default if not provided)"),
1042
+ lastKnowledgeOfServer: z.number().int().nonnegative().optional().describe("Delta request server knowledge. When provided, returns { scheduled_transactions, server_knowledge }."),
1043
+ } },
1044
+ ({ budgetId, lastKnowledgeOfServer }) =>
945
1045
  run(async () => {
946
- const { data } = await api.scheduledTransactions.getScheduledTransactions(resolveBudgetId(budgetId));
947
- return ok(data.scheduled_transactions.map(formatScheduledTransaction));
1046
+ const { data } = await api.scheduledTransactions.getScheduledTransactions(resolveBudgetId(budgetId), lastKnowledgeOfServer);
1047
+ const scheduledTransactions = data.scheduled_transactions.map(formatScheduledTransaction);
1048
+ return ok(collection(data, "scheduled_transactions", scheduledTransactions, lastKnowledgeOfServer));
948
1049
  })
949
1050
  );
950
1051
 
@@ -1012,7 +1113,7 @@ server.registerTool(
1012
1113
  ({ budgetId, scheduledTransactionId, accountId, date, frequency, amount, payeeId, payeeName, categoryId, memo, flagColor }) =>
1013
1114
  run(async () => {
1014
1115
  const bid = resolveBudgetId(budgetId);
1015
- // PUT replaces the full resource fetch current values to merge with updates
1116
+ // PUT replaces the full resource - fetch current values to merge with updates
1016
1117
  const { data: current } = await api.scheduledTransactions.getScheduledTransactionById(bid, scheduledTransactionId);
1017
1118
  const existing = current.scheduled_transaction;
1018
1119
 
@@ -1068,14 +1169,14 @@ server.registerTool(
1068
1169
  for (const c of g.categories) {
1069
1170
  if (c.hidden) continue;
1070
1171
  if (c.name.toLowerCase().includes(q)) {
1071
- matches.push({
1172
+ matches.push(withCurrencyFields({
1072
1173
  id: c.id,
1073
1174
  name: c.name,
1074
1175
  group: g.name,
1075
1176
  budgeted: dollars(c.budgeted),
1076
1177
  activity: dollars(c.activity),
1077
1178
  balance: dollars(c.balance),
1078
- });
1179
+ }, c, ["budgeted", "activity", "balance"]));
1079
1180
  }
1080
1181
  }
1081
1182
  }
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.4.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
  }