@oliverames/ynab-mcp-server 1.2.0 → 1.2.2

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 (4) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +47 -16
  3. package/index.js +121 -61
  4. package/package.json +3 -2
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Oliver Ames
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md CHANGED
@@ -10,19 +10,31 @@
10
10
  </p>
11
11
 
12
12
  <p align="center">
13
- <a href="https://www.npmjs.com/package/@oliverames/ynab-mcp-server"><img src="https://img.shields.io/npm/v/@oliverames/ynab-mcp-server" alt="npm version"></a>
14
- <a href="https://modelcontextprotocol.io"><img src="https://img.shields.io/badge/MCP-compatible-blue" alt="MCP compatible"></a>
15
- <a href="https://api.ynab.com"><img src="https://img.shields.io/badge/YNAB%20API-v1.79-green" alt="YNAB API v1.79"></a>
16
- <a href="https://opensource.org/licenses/MIT"><img src="https://img.shields.io/badge/license-MIT-brightgreen" alt="License: MIT"></a>
13
+ <code>43 tools</code> &bull;
14
+ <code>100% API coverage</code> &bull;
15
+ <code>YNAB API v1.79</code>
16
+ </p>
17
+
18
+ <p align="center">
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="LICENSE"><img src="https://img.shields.io/badge/license-MIT-f5a542?style=flat-square" alt="License"></a>
21
+ <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
+ </p>
23
+
24
+ <p align="center">
25
+ <a href="#quick-start">Quick Start</a> &bull;
26
+ <a href="#what-you-can-do">What You Can Do</a> &bull;
27
+ <a href="#tools-reference">All 43 Tools</a> &bull;
28
+ <a href="#environment-variables">Configuration</a>
17
29
  </p>
18
30
 
19
31
  ---
20
32
 
21
- **43 tools. 100% API coverage. Zero configuration.**
33
+ ## Why This Exists
22
34
 
23
- Connect any MCP-compatible AI assistant Claude, GPT, or your own agentsto your YNAB budget. Read transactions, categorize spending, manage accounts, review scheduled payments, track money movements, and more. All monetary values are automatically converted between dollars and YNAB's internal milliunits format so the AI never has to think about it.
35
+ YNAB's budgeting philosophy works best when you interact with your budget frequentlybut 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.
24
36
 
25
- 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.
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.
26
38
 
27
39
  ---
28
40
 
@@ -132,7 +144,9 @@ That's it. Your AI can now talk to YNAB.
132
144
  - **Bulk operations** — `create_transactions` and `update_transactions` handle arrays in a single API call.
133
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.
134
146
  - **Fuzzy search** — `search_categories` and `search_payees` do case-insensitive partial matching across all entries.
135
- - **Approval workflow** — `review_unapproved` groups transactions into "ready to approve" (has category) and "needs attention" (uncategorized), with a built-in warning against approving uncategorized 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).
136
150
 
137
151
  ---
138
152
 
@@ -143,7 +157,7 @@ That's it. Your AI can now talk to YNAB.
143
157
  | Tool | Description |
144
158
  |------|-------------|
145
159
  | `get_user` | Get the authenticated user |
146
- | `list_budgets` | List all budgets with IDs, names, and date ranges |
160
+ | `list_budgets` | List all budgets with IDs, names, date ranges, format settings, and default budget |
147
161
  | `get_budget` | Get budget summary (name, currency, account/category/payee counts) |
148
162
  | `get_budget_settings` | Get currency and date format settings |
149
163
 
@@ -151,8 +165,8 @@ That's it. Your AI can now talk to YNAB.
151
165
 
152
166
  | Tool | Description |
153
167
  |------|-------------|
154
- | `list_accounts` | List all accounts with balances in dollars |
155
- | `get_account` | Get details for a specific account |
168
+ | `list_accounts` | List all accounts with balances, debt details, and import status |
169
+ | `get_account` | Get full account details including notes and debt fields |
156
170
  | `create_account` | Create a new account (checking, savings, creditCard, mortgage, etc.) |
157
171
 
158
172
  **Supported account types:** `checking`, `savings`, `cash`, `creditCard`, `lineOfCredit`, `otherAsset`, `otherLiability`, `mortgage`, `autoLoan`, `studentLoan`, `personalLoan`, `medicalDebt`, `otherDebt`
@@ -165,7 +179,7 @@ That's it. Your AI can now talk to YNAB.
165
179
  | `get_category` | Get full category details including goal progress and cadence |
166
180
  | `get_month_category` | Get category budget for a specific month |
167
181
  | `update_month_category` | Set the budgeted amount for a category in a month |
168
- | `update_category` | Update name, note, goal target, or move to a different group |
182
+ | `update_category` | Update name, note, goal target, goal target date, or move to a different group |
169
183
  | `create_category` | Create a new category in an existing group (with optional goal) |
170
184
  | `create_category_group` | Create a new category group |
171
185
  | `update_category_group` | Rename a category group |
@@ -192,8 +206,8 @@ That's it. Your AI can now talk to YNAB.
192
206
 
193
207
  | Tool | Description |
194
208
  |------|-------------|
195
- | `list_months` | List budget months with income, budgeted, activity, to-be-budgeted, age of money |
196
- | `get_month` | Get month detail with per-category budget/activity/balance breakdown |
209
+ | `list_months` | List budget months with income, budgeted, activity, to-be-budgeted, age of money, and notes |
210
+ | `get_month` | Get month detail with per-category budget/activity/balance/goal breakdown |
197
211
 
198
212
  ### Money Movements
199
213
 
@@ -211,7 +225,7 @@ That's it. Your AI can now talk to YNAB.
211
225
  | `get_transactions` | Get transactions with filters: by account, category, payee, month, or status (`unapproved`/`uncategorized`) |
212
226
  | `get_transaction` | Get a single transaction by ID (includes subtransactions) |
213
227
  | `create_transaction` | Create a transaction with optional split (subtransactions must sum to total) |
214
- | `create_transactions` | Bulk create multiple transactions in a single API call |
228
+ | `create_transactions` | Bulk create multiple transactions in a single API call (supports split transactions) |
215
229
  | `update_transaction` | Partial update — only specified fields change |
216
230
  | `update_transactions` | Batch update multiple transactions at once |
217
231
  | `delete_transaction` | Delete a transaction |
@@ -233,7 +247,7 @@ That's it. Your AI can now talk to YNAB.
233
247
 
234
248
  | Tool | Description |
235
249
  |------|-------------|
236
- | `review_unapproved` | Get unapproved transactions grouped by readiness: "ready to approve" (categorized) vs. "needs category first" (uncategorized). Includes a warning against blind approval. |
250
+ | `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. |
237
251
 
238
252
  ---
239
253
 
@@ -316,3 +330,20 @@ Zero additional dependencies. No build step. Pure ESM.
316
330
  ## License
317
331
 
318
332
  MIT
333
+
334
+ ---
335
+
336
+ <p align="center">
337
+ <a href="https://www.buymeacoffee.com/oliverames">
338
+ <img src="https://img.shields.io/badge/Buy_Me_a_Coffee-support-f5a542?style=for-the-badge&logo=buy-me-a-coffee&logoColor=white" alt="Buy Me a Coffee">
339
+ </a>
340
+ </p>
341
+
342
+ <p align="center">
343
+ <sub>
344
+ Built by <a href="https://ames.consulting">Oliver Ames</a> in Vermont
345
+ &bull; <a href="https://github.com/oliverames">GitHub</a>
346
+ &bull; <a href="https://linkedin.com/in/oliverames">LinkedIn</a>
347
+ &bull; <a href="https://bsky.app/profile/oliverames.bsky.social">Bluesky</a>
348
+ </sub>
349
+ </p>
package/index.js CHANGED
@@ -1,5 +1,6 @@
1
1
  #!/usr/bin/env node
2
2
 
3
+ import { execFileSync } from "node:child_process";
3
4
  import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
4
5
  import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
5
6
  import { z } from "zod";
@@ -7,9 +8,19 @@ import * as ynab from "ynab";
7
8
 
8
9
  // --- Init ---
9
10
 
10
- const API_TOKEN = process.env.YNAB_API_TOKEN;
11
+ let API_TOKEN = process.env.YNAB_API_TOKEN;
11
12
  if (!API_TOKEN) {
12
- console.error("YNAB_API_TOKEN environment variable is required");
13
+ try {
14
+ API_TOKEN = execFileSync(
15
+ "op", ["read", "op://Development/YNAB API Token/credential"],
16
+ { encoding: "utf-8", timeout: 10000, stdio: ["pipe", "pipe", "pipe"] }
17
+ ).trim();
18
+ } catch {
19
+ // 1Password CLI unavailable or item not found
20
+ }
21
+ }
22
+ if (!API_TOKEN) {
23
+ console.error("YNAB_API_TOKEN environment variable is required (1Password fallback also failed)");
13
24
  process.exit(1);
14
25
  }
15
26
 
@@ -31,6 +42,36 @@ function milliunits(dollars) {
31
42
  return Math.round(dollars * 1000);
32
43
  }
33
44
 
45
+ function dollarsMap(obj) {
46
+ return obj ? Object.fromEntries(Object.entries(obj).map(([k, v]) => [k, dollars(v)])) : obj;
47
+ }
48
+
49
+ function mapTransactionInput(t) {
50
+ const out = {
51
+ account_id: t.accountId,
52
+ date: t.date,
53
+ amount: milliunits(t.amount),
54
+ payee_id: t.payeeId,
55
+ payee_name: t.payeeName,
56
+ category_id: t.categoryId,
57
+ memo: t.memo,
58
+ cleared: t.cleared,
59
+ approved: t.approved,
60
+ flag_color: t.flagColor,
61
+ import_id: t.importId,
62
+ };
63
+ if (t.subtransactions) {
64
+ out.subtransactions = t.subtransactions.map((s) => ({
65
+ amount: milliunits(s.amount),
66
+ category_id: s.categoryId,
67
+ payee_id: s.payeeId,
68
+ payee_name: s.payeeName,
69
+ memo: s.memo,
70
+ }));
71
+ }
72
+ return out;
73
+ }
74
+
34
75
  function ok(data) {
35
76
  return { content: [{ type: "text", text: JSON.stringify(data, null, 2) }] };
36
77
  }
@@ -70,7 +111,7 @@ async function ynabFetch(path, { method = "GET", body } = {}) {
70
111
 
71
112
  const server = new McpServer({
72
113
  name: "ynab-mcp-server",
73
- version: "1.2.0",
114
+ version: "1.2.1",
74
115
  });
75
116
 
76
117
  // ==================== User & Budgets ====================
@@ -85,7 +126,13 @@ server.tool("get_user", "Get the authenticated user", {}, () =>
85
126
  server.tool("list_budgets", "List all budgets. Use a budget ID from the results in other tools, or omit budgetId to use the last-used budget.", {}, () =>
86
127
  run(async () => {
87
128
  const { data } = await api.budgets.getBudgets();
88
- return ok(data.budgets.map((b) => ({ id: b.id, name: b.name, last_modified_on: b.last_modified_on, first_month: b.first_month, last_month: b.last_month })));
129
+ const result = {
130
+ budgets: data.budgets.map((b) => ({ id: b.id, name: b.name, last_modified_on: b.last_modified_on, first_month: b.first_month, last_month: b.last_month, date_format: b.date_format, currency_format: b.currency_format })),
131
+ };
132
+ if (data.default_budget) {
133
+ result.default_budget = { id: data.default_budget.id, name: data.default_budget.name };
134
+ }
135
+ return ok(result);
89
136
  })
90
137
  );
91
138
 
@@ -139,6 +186,10 @@ function formatAccount(a) {
139
186
  direct_import_linked: a.direct_import_linked,
140
187
  direct_import_in_error: a.direct_import_in_error,
141
188
  last_reconciled_at: a.last_reconciled_at,
189
+ debt_original_balance: dollars(a.debt_original_balance),
190
+ debt_interest_rates: a.debt_interest_rates,
191
+ debt_minimum_payments: dollarsMap(a.debt_minimum_payments),
192
+ debt_escrow_amounts: dollarsMap(a.debt_escrow_amounts),
142
193
  deleted: a.deleted,
143
194
  };
144
195
  if ("note" in a) out.note = a.note;
@@ -195,6 +246,7 @@ function formatCategory(c) {
195
246
  id: c.id,
196
247
  category_group_id: c.category_group_id,
197
248
  category_group_name: c.category_group_name,
249
+ original_category_group_id: c.original_category_group_id,
198
250
  name: c.name,
199
251
  hidden: c.hidden,
200
252
  note: c.note,
@@ -214,6 +266,7 @@ function formatCategory(c) {
214
266
  goal_overall_funded: dollars(c.goal_overall_funded),
215
267
  goal_overall_left: dollars(c.goal_overall_left),
216
268
  goal_needs_whole_amount: c.goal_needs_whole_amount,
269
+ deleted: c.deleted,
217
270
  };
218
271
  }
219
272
 
@@ -229,6 +282,7 @@ server.tool(
229
282
  id: g.id,
230
283
  name: g.name,
231
284
  hidden: g.hidden,
285
+ deleted: g.deleted,
232
286
  categories: g.categories.map((c) => ({
233
287
  id: c.id,
234
288
  name: c.name,
@@ -237,6 +291,7 @@ server.tool(
237
291
  activity: dollars(c.activity),
238
292
  balance: dollars(c.balance),
239
293
  goal_type: c.goal_type,
294
+ deleted: c.deleted,
240
295
  })),
241
296
  }))
242
297
  );
@@ -300,14 +355,16 @@ server.tool(
300
355
  note: z.string().nullable().optional().describe("Category note (null to clear)"),
301
356
  categoryGroupId: z.string().optional().describe("Move to a different category group"),
302
357
  goalTarget: z.number().nullable().optional().describe("Goal target amount in dollars (only if category already has a goal)"),
358
+ goalTargetDate: z.string().nullable().optional().describe("Goal target date in ISO format (e.g. 2026-12-01, null to clear)"),
303
359
  },
304
- ({ budgetId, categoryId, name, note, categoryGroupId, goalTarget }) =>
360
+ ({ budgetId, categoryId, name, note, categoryGroupId, goalTarget, goalTargetDate }) =>
305
361
  run(async () => {
306
362
  const cat = {};
307
363
  if (name !== undefined) cat.name = name;
308
364
  if (note !== undefined) cat.note = note;
309
365
  if (categoryGroupId !== undefined) cat.category_group_id = categoryGroupId;
310
366
  if (goalTarget !== undefined) cat.goal_target = goalTarget != null ? milliunits(goalTarget) : null;
367
+ if (goalTargetDate !== undefined) cat.goal_target_date = goalTargetDate;
311
368
 
312
369
  const { data } = await api.categories.updateCategory(resolveBudgetId(budgetId), categoryId, {
313
370
  category: cat,
@@ -386,7 +443,7 @@ server.tool(
386
443
  ({ budgetId }) =>
387
444
  run(async () => {
388
445
  const { data } = await api.payees.getPayees(resolveBudgetId(budgetId));
389
- return ok(data.payees.map((p) => ({ id: p.id, name: p.name, transfer_account_id: p.transfer_account_id })));
446
+ return ok(data.payees.map((p) => ({ id: p.id, name: p.name, transfer_account_id: p.transfer_account_id, deleted: p.deleted })));
390
447
  })
391
448
  );
392
449
 
@@ -474,11 +531,13 @@ server.tool(
474
531
  return ok(
475
532
  data.months.map((m) => ({
476
533
  month: m.month,
534
+ note: m.note,
477
535
  income: dollars(m.income),
478
536
  budgeted: dollars(m.budgeted),
479
537
  activity: dollars(m.activity),
480
538
  to_be_budgeted: dollars(m.to_be_budgeted),
481
539
  age_of_money: m.age_of_money,
540
+ deleted: m.deleted,
482
541
  }))
483
542
  );
484
543
  })
@@ -497,14 +556,17 @@ server.tool(
497
556
  const m = data.month;
498
557
  return ok({
499
558
  month: m.month,
559
+ note: m.note,
500
560
  income: dollars(m.income),
501
561
  budgeted: dollars(m.budgeted),
502
562
  activity: dollars(m.activity),
503
563
  to_be_budgeted: dollars(m.to_be_budgeted),
504
564
  age_of_money: m.age_of_money,
565
+ deleted: m.deleted,
505
566
  categories: m.categories?.map((c) => ({
506
567
  id: c.id,
507
568
  name: c.name,
569
+ hidden: c.hidden,
508
570
  category_group_name: c.category_group_name,
509
571
  budgeted: dollars(c.budgeted),
510
572
  activity: dollars(c.activity),
@@ -512,6 +574,7 @@ server.tool(
512
574
  goal_type: c.goal_type,
513
575
  goal_target: dollars(c.goal_target),
514
576
  goal_under_funded: dollars(c.goal_under_funded),
577
+ deleted: c.deleted,
515
578
  })),
516
579
  });
517
580
  })
@@ -530,6 +593,7 @@ function formatMoneyMovement(m) {
530
593
  from_category_id: m.from_category_id,
531
594
  to_category_id: m.to_category_id,
532
595
  amount: dollars(m.amount),
596
+ deleted: m.deleted,
533
597
  };
534
598
  }
535
599
 
@@ -602,17 +666,25 @@ function formatTransaction(t) {
602
666
  category_id: t.category_id,
603
667
  category_name: t.category_name,
604
668
  transfer_account_id: t.transfer_account_id,
669
+ transfer_transaction_id: t.transfer_transaction_id,
670
+ matched_transaction_id: t.matched_transaction_id,
605
671
  import_id: t.import_id,
606
672
  import_payee_name: t.import_payee_name,
673
+ import_payee_name_original: t.import_payee_name_original,
607
674
  debt_transaction_type: t.debt_transaction_type,
675
+ deleted: t.deleted,
608
676
  subtransactions: t.subtransactions?.map((s) => ({
609
677
  id: s.id,
678
+ transaction_id: s.transaction_id,
610
679
  amount: dollars(s.amount),
611
680
  memo: s.memo,
612
681
  payee_id: s.payee_id,
613
682
  payee_name: s.payee_name,
614
683
  category_id: s.category_id,
615
684
  category_name: s.category_name,
685
+ transfer_account_id: s.transfer_account_id,
686
+ transfer_transaction_id: s.transfer_transaction_id,
687
+ deleted: s.deleted,
616
688
  })),
617
689
  };
618
690
  }
@@ -693,32 +765,10 @@ server.tool(
693
765
  memo: z.string().optional().describe("Memo"),
694
766
  })).optional().describe("Split transaction into subtransactions. The subtransaction amounts must sum to the total transaction amount."),
695
767
  },
696
- ({ budgetId, accountId, date, amount, payeeId, payeeName, categoryId, memo, cleared, approved, flagColor, importId, subtransactions }) =>
768
+ ({ budgetId, ...txnFields }) =>
697
769
  run(async () => {
698
- const txn = {
699
- account_id: accountId,
700
- date,
701
- amount: milliunits(amount),
702
- payee_id: payeeId,
703
- payee_name: payeeName,
704
- category_id: categoryId,
705
- memo,
706
- cleared,
707
- approved,
708
- flag_color: flagColor,
709
- import_id: importId,
710
- };
711
- if (subtransactions) {
712
- txn.subtransactions = subtransactions.map((s) => ({
713
- amount: milliunits(s.amount),
714
- category_id: s.categoryId,
715
- payee_id: s.payeeId,
716
- payee_name: s.payeeName,
717
- memo: s.memo,
718
- }));
719
- }
720
770
  const { data } = await api.transactions.createTransaction(resolveBudgetId(budgetId), {
721
- transaction: txn,
771
+ transaction: mapTransactionInput(txnFields),
722
772
  });
723
773
  return ok(formatTransaction(data.transaction));
724
774
  })
@@ -741,25 +791,19 @@ server.tool(
741
791
  approved: z.boolean().optional().describe("Whether transaction is approved"),
742
792
  flagColor: z.enum(["red", "orange", "yellow", "green", "blue", "purple"]).optional().describe("Flag color"),
743
793
  importId: z.string().optional().describe("Unique import ID for deduplication (max 36 chars)"),
794
+ subtransactions: z.array(z.object({
795
+ amount: z.number().describe("Subtransaction amount in dollars"),
796
+ categoryId: z.string().optional().describe("Category ID"),
797
+ payeeId: z.string().optional().describe("Payee ID"),
798
+ payeeName: z.string().optional().describe("Payee name"),
799
+ memo: z.string().optional().describe("Memo"),
800
+ })).optional().describe("Split transaction into subtransactions"),
744
801
  })).describe("Array of transactions to create"),
745
802
  },
746
803
  ({ budgetId, transactions: txns }) =>
747
804
  run(async () => {
748
- const mapped = txns.map((t) => ({
749
- account_id: t.accountId,
750
- date: t.date,
751
- amount: milliunits(t.amount),
752
- payee_id: t.payeeId,
753
- payee_name: t.payeeName,
754
- category_id: t.categoryId,
755
- memo: t.memo,
756
- cleared: t.cleared,
757
- approved: t.approved,
758
- flag_color: t.flagColor,
759
- import_id: t.importId,
760
- }));
761
805
  const { data } = await api.transactions.createTransactions(resolveBudgetId(budgetId), {
762
- transactions: mapped,
806
+ transactions: txns.map(mapTransactionInput),
763
807
  });
764
808
  return ok({
765
809
  created: data.transactions?.map(formatTransaction),
@@ -777,10 +821,10 @@ server.tool(
777
821
  accountId: z.string().optional().describe("Account ID"),
778
822
  date: z.string().optional().describe("Transaction date (YYYY-MM-DD)"),
779
823
  amount: z.number().optional().describe("Amount in dollars"),
780
- payeeId: z.string().optional().describe("Payee ID"),
781
- payeeName: z.string().optional().describe("Payee name"),
824
+ payeeId: z.string().nullable().optional().describe("Payee ID (null to remove)"),
825
+ payeeName: z.string().nullable().optional().describe("Payee name (null to clear)"),
782
826
  categoryId: z.string().nullable().optional().describe("Category ID (null to uncategorize)"),
783
- memo: z.string().optional().describe("Transaction memo"),
827
+ memo: z.string().nullable().optional().describe("Transaction memo (null to clear)"),
784
828
  cleared: z.enum(["cleared", "uncleared", "reconciled"]).optional().describe("Cleared status"),
785
829
  approved: z.boolean().optional().describe("Whether transaction is approved"),
786
830
  flagColor: z.enum(["red", "orange", "yellow", "green", "blue", "purple"]).nullable().optional().describe("Flag color (null to remove)"),
@@ -829,16 +873,16 @@ server.tool(
829
873
  .array(
830
874
  z.object({
831
875
  id: z.string().describe("Transaction ID"),
832
- account_id: z.string().optional(),
833
- date: z.string().optional(),
876
+ accountId: z.string().optional().describe("Account ID"),
877
+ date: z.string().optional().describe("Transaction date (YYYY-MM-DD)"),
834
878
  amount: z.number().optional().describe("Amount in dollars"),
835
- payee_id: z.string().optional(),
836
- payee_name: z.string().optional(),
837
- category_id: z.string().optional(),
838
- memo: z.string().optional(),
839
- cleared: z.enum(["cleared", "uncleared", "reconciled"]).optional(),
840
- approved: z.boolean().optional(),
841
- flag_color: z.enum(["red", "orange", "yellow", "green", "blue", "purple"]).nullable().optional(),
879
+ payeeId: z.string().nullable().optional().describe("Payee ID (null to remove)"),
880
+ payeeName: z.string().nullable().optional().describe("Payee name (null to clear)"),
881
+ categoryId: z.string().nullable().optional().describe("Category ID (null to uncategorize)"),
882
+ memo: z.string().nullable().optional().describe("Transaction memo (null to clear)"),
883
+ cleared: z.enum(["cleared", "uncleared", "reconciled"]).optional().describe("Cleared status"),
884
+ approved: z.boolean().optional().describe("Whether transaction is approved"),
885
+ flagColor: z.enum(["red", "orange", "yellow", "green", "blue", "purple"]).nullable().optional().describe("Flag color (null to remove)"),
842
886
  })
843
887
  )
844
888
  .describe("Array of transaction updates"),
@@ -846,8 +890,17 @@ server.tool(
846
890
  ({ budgetId, transactions: txns }) =>
847
891
  run(async () => {
848
892
  const mapped = txns.map((t) => {
849
- const out = { ...t };
850
- if (out.amount !== undefined) out.amount = milliunits(out.amount);
893
+ const out = { id: t.id };
894
+ if (t.accountId !== undefined) out.account_id = t.accountId;
895
+ if (t.date !== undefined) out.date = t.date;
896
+ if (t.amount !== undefined) out.amount = milliunits(t.amount);
897
+ if (t.payeeId !== undefined) out.payee_id = t.payeeId;
898
+ if (t.payeeName !== undefined) out.payee_name = t.payeeName;
899
+ if (t.categoryId !== undefined) out.category_id = t.categoryId;
900
+ if (t.memo !== undefined) out.memo = t.memo;
901
+ if (t.cleared !== undefined) out.cleared = t.cleared;
902
+ if (t.approved !== undefined) out.approved = t.approved;
903
+ if (t.flagColor !== undefined) out.flag_color = t.flagColor;
851
904
  return out;
852
905
  });
853
906
  const { data } = await api.transactions.updateTransactions(resolveBudgetId(budgetId), {
@@ -890,14 +943,18 @@ function formatScheduledTransaction(t) {
890
943
  category_id: t.category_id,
891
944
  category_name: t.category_name,
892
945
  transfer_account_id: t.transfer_account_id,
946
+ deleted: t.deleted,
893
947
  subtransactions: t.subtransactions?.map((s) => ({
894
948
  id: s.id,
949
+ scheduled_transaction_id: s.scheduled_transaction_id,
895
950
  amount: dollars(s.amount),
896
951
  memo: s.memo,
897
952
  payee_id: s.payee_id,
898
953
  payee_name: s.payee_name,
899
954
  category_id: s.category_id,
900
955
  category_name: s.category_name,
956
+ transfer_account_id: s.transfer_account_id,
957
+ deleted: s.deleted,
901
958
  })),
902
959
  };
903
960
  }
@@ -1067,7 +1124,7 @@ server.tool(
1067
1124
  const q = query.toLowerCase();
1068
1125
  const matches = data.payees
1069
1126
  .filter((p) => p.name.toLowerCase().includes(q))
1070
- .map((p) => ({ id: p.id, name: p.name, transfer_account_id: p.transfer_account_id }));
1127
+ .map((p) => ({ id: p.id, name: p.name, transfer_account_id: p.transfer_account_id, deleted: p.deleted }));
1071
1128
  if (matches.length === 0) return ok({ message: `No payees matching "${query}"` });
1072
1129
  return ok(matches);
1073
1130
  })
@@ -1081,8 +1138,11 @@ server.tool(
1081
1138
  run(async () => {
1082
1139
  const { data } = await api.transactions.getTransactions(resolveBudgetId(budgetId), undefined, "unapproved");
1083
1140
  const txns = data.transactions.map(formatTransaction);
1084
- const categorized = txns.filter((t) => t.category_id && t.category_name !== "Uncategorized");
1085
- const uncategorized = txns.filter((t) => !t.category_id || t.category_name === "Uncategorized");
1141
+ const isCategorized = (t) => (t.category_id && t.category_name !== "Uncategorized")
1142
+ || (t.subtransactions && t.subtransactions.length > 0) // split transactions are categorized via subtransactions
1143
+ || t.transfer_account_id; // transfers don't need categories
1144
+ const categorized = txns.filter(isCategorized);
1145
+ const uncategorized = txns.filter((t) => !isCategorized(t));
1086
1146
  return ok({
1087
1147
  total: txns.length,
1088
1148
  ready_to_approve: {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@oliverames/ynab-mcp-server",
3
- "version": "1.2.0",
3
+ "version": "1.2.2",
4
4
  "description": "YNAB MCP server with full API coverage",
5
5
  "type": "module",
6
6
  "main": "index.js",
@@ -12,7 +12,8 @@
12
12
  ],
13
13
  "scripts": {
14
14
  "start": "node index.js",
15
- "test": "node test.js"
15
+ "test": "node test.js",
16
+ "postpublish": "$HOME/Developer/projects/ames-claude/bump-and-sync"
16
17
  },
17
18
  "dependencies": {
18
19
  "@modelcontextprotocol/sdk": "^1.12.1",