@oliverames/ynab-mcp-server 1.0.0 → 1.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (2) hide show
  1. package/index.js +188 -47
  2. package/package.json +20 -33
package/index.js CHANGED
@@ -19,8 +19,7 @@ const DEFAULT_BUDGET_ID = process.env.YNAB_BUDGET_ID;
19
19
  // --- Helpers ---
20
20
 
21
21
  function resolveBudgetId(input) {
22
- const id = input || DEFAULT_BUDGET_ID;
23
- if (!id) throw new Error("budgetId is required (pass it or set YNAB_BUDGET_ID env var)");
22
+ const id = input || DEFAULT_BUDGET_ID || "last-used";
24
23
  return id;
25
24
  }
26
25
 
@@ -40,8 +39,12 @@ async function run(fn) {
40
39
  try {
41
40
  return await fn();
42
41
  } catch (e) {
43
- const msg = e?.error?.detail || e?.message || String(e);
44
- return { content: [{ type: "text", text: `Error: ${msg}` }] };
42
+ const detail = e?.error?.detail;
43
+ const name = e?.error?.name;
44
+ const msg = detail
45
+ ? (name ? `${name}: ${detail}` : detail)
46
+ : (e?.message || String(e));
47
+ return { content: [{ type: "text", text: `Error: ${msg}` }], isError: true };
45
48
  }
46
49
  }
47
50
 
@@ -61,7 +64,7 @@ server.tool("get_user", "Get the authenticated user", {}, () =>
61
64
  })
62
65
  );
63
66
 
64
- server.tool("list_budgets", "List all budgets", {}, () =>
67
+ 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.", {}, () =>
65
68
  run(async () => {
66
69
  const { data } = await api.budgets.getBudgets();
67
70
  return ok(data.budgets.map((b) => ({ id: b.id, name: b.name, last_modified_on: b.last_modified_on })));
@@ -100,6 +103,26 @@ server.tool(
100
103
 
101
104
  // ==================== Accounts ====================
102
105
 
106
+ function formatAccount(a) {
107
+ const out = {
108
+ id: a.id,
109
+ name: a.name,
110
+ type: a.type,
111
+ on_budget: a.on_budget,
112
+ closed: a.closed,
113
+ balance: dollars(a.balance),
114
+ cleared_balance: dollars(a.cleared_balance),
115
+ uncleared_balance: dollars(a.uncleared_balance),
116
+ transfer_payee_id: a.transfer_payee_id,
117
+ direct_import_linked: a.direct_import_linked,
118
+ direct_import_in_error: a.direct_import_in_error,
119
+ last_reconciled_at: a.last_reconciled_at,
120
+ deleted: a.deleted,
121
+ };
122
+ if ("note" in a) out.note = a.note;
123
+ return out;
124
+ }
125
+
103
126
  server.tool(
104
127
  "list_accounts",
105
128
  "List all accounts in a budget",
@@ -107,18 +130,7 @@ server.tool(
107
130
  ({ budgetId }) =>
108
131
  run(async () => {
109
132
  const { data } = await api.accounts.getAccounts(resolveBudgetId(budgetId));
110
- return ok(
111
- data.accounts.map((a) => ({
112
- id: a.id,
113
- name: a.name,
114
- type: a.type,
115
- on_budget: a.on_budget,
116
- closed: a.closed,
117
- balance: dollars(a.balance),
118
- cleared_balance: dollars(a.cleared_balance),
119
- uncleared_balance: dollars(a.uncleared_balance),
120
- }))
121
- );
133
+ return ok(data.accounts.map(formatAccount));
122
134
  })
123
135
  );
124
136
 
@@ -132,8 +144,7 @@ server.tool(
132
144
  ({ budgetId, accountId }) =>
133
145
  run(async () => {
134
146
  const { data } = await api.accounts.getAccountById(resolveBudgetId(budgetId), accountId);
135
- const a = data.account;
136
- return ok({ ...a, balance: dollars(a.balance), cleared_balance: dollars(a.cleared_balance), uncleared_balance: dollars(a.uncleared_balance) });
147
+ return ok(formatAccount(data.account));
137
148
  })
138
149
  );
139
150
 
@@ -151,12 +162,35 @@ server.tool(
151
162
  const { data } = await api.accounts.createAccount(resolveBudgetId(budgetId), {
152
163
  account: { name, type, balance: milliunits(bal) },
153
164
  });
154
- return ok(data.account);
165
+ return ok(formatAccount(data.account));
155
166
  })
156
167
  );
157
168
 
158
169
  // ==================== Categories ====================
159
170
 
171
+ function formatCategory(c) {
172
+ return {
173
+ id: c.id,
174
+ category_group_id: c.category_group_id,
175
+ category_group_name: c.category_group_name,
176
+ name: c.name,
177
+ hidden: c.hidden,
178
+ note: c.note,
179
+ budgeted: dollars(c.budgeted),
180
+ activity: dollars(c.activity),
181
+ balance: dollars(c.balance),
182
+ goal_type: c.goal_type,
183
+ goal_target: dollars(c.goal_target),
184
+ goal_target_date: c.goal_target_date,
185
+ goal_percentage_complete: c.goal_percentage_complete,
186
+ goal_months_to_budget: c.goal_months_to_budget,
187
+ goal_under_funded: dollars(c.goal_under_funded),
188
+ goal_overall_funded: dollars(c.goal_overall_funded),
189
+ goal_overall_left: dollars(c.goal_overall_left),
190
+ goal_needs_whole_amount: c.goal_needs_whole_amount,
191
+ };
192
+ }
193
+
160
194
  server.tool(
161
195
  "list_categories",
162
196
  "List all category groups and their categories",
@@ -176,6 +210,7 @@ server.tool(
176
210
  budgeted: dollars(c.budgeted),
177
211
  activity: dollars(c.activity),
178
212
  balance: dollars(c.balance),
213
+ goal_type: c.goal_type,
179
214
  })),
180
215
  }))
181
216
  );
@@ -192,8 +227,7 @@ server.tool(
192
227
  ({ budgetId, categoryId }) =>
193
228
  run(async () => {
194
229
  const { data } = await api.categories.getCategoryById(resolveBudgetId(budgetId), categoryId);
195
- const c = data.category;
196
- return ok({ ...c, budgeted: dollars(c.budgeted), activity: dollars(c.activity), balance: dollars(c.balance), goal_target: dollars(c.goal_target) });
230
+ return ok(formatCategory(data.category));
197
231
  })
198
232
  );
199
233
 
@@ -208,8 +242,7 @@ server.tool(
208
242
  ({ budgetId, month, categoryId }) =>
209
243
  run(async () => {
210
244
  const { data } = await api.categories.getMonthCategoryById(resolveBudgetId(budgetId), month, categoryId);
211
- const c = data.category;
212
- return ok({ ...c, budgeted: dollars(c.budgeted), activity: dollars(c.activity), balance: dollars(c.balance) });
245
+ return ok(formatCategory(data.category));
213
246
  })
214
247
  );
215
248
 
@@ -227,8 +260,33 @@ server.tool(
227
260
  const { data } = await api.categories.updateMonthCategory(resolveBudgetId(budgetId), month, categoryId, {
228
261
  category: { budgeted: milliunits(budgeted) },
229
262
  });
230
- const c = data.category;
231
- return ok({ ...c, budgeted: dollars(c.budgeted), activity: dollars(c.activity), balance: dollars(c.balance) });
263
+ return ok(formatCategory(data.category));
264
+ })
265
+ );
266
+
267
+ server.tool(
268
+ "update_category",
269
+ "Update a category's name, note, goal target, or move it to a different group",
270
+ {
271
+ budgetId: z.string().optional().describe("Budget ID (uses default if not provided)"),
272
+ categoryId: z.string().describe("Category ID"),
273
+ name: z.string().optional().describe("New category name"),
274
+ note: z.string().nullable().optional().describe("Category note (null to clear)"),
275
+ categoryGroupId: z.string().optional().describe("Move to a different category group"),
276
+ goalTarget: z.number().nullable().optional().describe("Goal target amount in dollars (only if category already has a goal)"),
277
+ },
278
+ ({ budgetId, categoryId, name, note, categoryGroupId, goalTarget }) =>
279
+ run(async () => {
280
+ const cat = {};
281
+ if (name !== undefined) cat.name = name;
282
+ if (note !== undefined) cat.note = note;
283
+ if (categoryGroupId !== undefined) cat.category_group_id = categoryGroupId;
284
+ if (goalTarget !== undefined) cat.goal_target = goalTarget != null ? milliunits(goalTarget) : null;
285
+
286
+ const { data } = await api.categories.updateCategory(resolveBudgetId(budgetId), categoryId, {
287
+ category: cat,
288
+ });
289
+ return ok(formatCategory(data.category));
232
290
  })
233
291
  );
234
292
 
@@ -292,6 +350,7 @@ server.tool(
292
350
  budgeted: dollars(m.budgeted),
293
351
  activity: dollars(m.activity),
294
352
  to_be_budgeted: dollars(m.to_be_budgeted),
353
+ age_of_money: m.age_of_money,
295
354
  }))
296
355
  );
297
356
  })
@@ -314,12 +373,17 @@ server.tool(
314
373
  budgeted: dollars(m.budgeted),
315
374
  activity: dollars(m.activity),
316
375
  to_be_budgeted: dollars(m.to_be_budgeted),
376
+ age_of_money: m.age_of_money,
317
377
  categories: m.categories?.map((c) => ({
318
378
  id: c.id,
319
379
  name: c.name,
380
+ category_group_name: c.category_group_name,
320
381
  budgeted: dollars(c.budgeted),
321
382
  activity: dollars(c.activity),
322
383
  balance: dollars(c.balance),
384
+ goal_type: c.goal_type,
385
+ goal_target: dollars(c.goal_target),
386
+ goal_under_funded: dollars(c.goal_under_funded),
323
387
  })),
324
388
  });
325
389
  })
@@ -344,6 +408,9 @@ function formatTransaction(t) {
344
408
  category_id: t.category_id,
345
409
  category_name: t.category_name,
346
410
  transfer_account_id: t.transfer_account_id,
411
+ import_id: t.import_id,
412
+ import_payee_name: t.import_payee_name,
413
+ debt_transaction_type: t.debt_transaction_type,
347
414
  subtransactions: t.subtransactions?.map((s) => ({
348
415
  id: s.id,
349
416
  amount: dollars(s.amount),
@@ -383,12 +450,8 @@ server.tool(
383
450
  const { data } = await api.transactions.getTransactionsByPayee(bid, payeeId, sinceDate, type);
384
451
  transactions = data.transactions;
385
452
  } else if (month) {
386
- // getTransactionsByMonth doesn't exist in SDK use sinceDate filter
387
- const startDate = month;
388
- const [y, m] = month.split("-").map(Number);
389
- const endDate = new Date(y, m, 0).toISOString().slice(0, 10); // last day of month
390
- const { data } = await api.transactions.getTransactions(bid, startDate, type);
391
- transactions = data.transactions.filter((t) => t.date <= endDate);
453
+ const { data } = await api.transactions.getTransactionsByMonth(bid, month, sinceDate, type);
454
+ transactions = data.transactions;
392
455
  } else {
393
456
  const { data } = await api.transactions.getTransactions(bid, sinceDate, type);
394
457
  transactions = data.transactions;
@@ -427,22 +490,41 @@ server.tool(
427
490
  cleared: z.enum(["cleared", "uncleared", "reconciled"]).optional().describe("Cleared status"),
428
491
  approved: z.boolean().optional().describe("Whether transaction is approved"),
429
492
  flagColor: z.enum(["red", "orange", "yellow", "green", "blue", "purple"]).optional().describe("Flag color"),
493
+ importId: z.string().optional().describe("Unique import ID for deduplication (max 36 chars). If omitted and the transaction is later imported, duplicates may be created."),
494
+ subtransactions: z.array(z.object({
495
+ amount: z.number().describe("Subtransaction amount in dollars"),
496
+ categoryId: z.string().optional().describe("Category ID"),
497
+ payeeId: z.string().optional().describe("Payee ID"),
498
+ payeeName: z.string().optional().describe("Payee name"),
499
+ memo: z.string().optional().describe("Memo"),
500
+ })).optional().describe("Split transaction into subtransactions. The subtransaction amounts must sum to the total transaction amount."),
430
501
  },
431
- ({ budgetId, accountId, date, amount, payeeId, payeeName, categoryId, memo, cleared, approved, flagColor }) =>
502
+ ({ budgetId, accountId, date, amount, payeeId, payeeName, categoryId, memo, cleared, approved, flagColor, importId, subtransactions }) =>
432
503
  run(async () => {
504
+ const txn = {
505
+ account_id: accountId,
506
+ date,
507
+ amount: milliunits(amount),
508
+ payee_id: payeeId,
509
+ payee_name: payeeName,
510
+ category_id: categoryId,
511
+ memo,
512
+ cleared,
513
+ approved,
514
+ flag_color: flagColor,
515
+ import_id: importId,
516
+ };
517
+ if (subtransactions) {
518
+ txn.subtransactions = subtransactions.map((s) => ({
519
+ amount: milliunits(s.amount),
520
+ category_id: s.categoryId,
521
+ payee_id: s.payeeId,
522
+ payee_name: s.payeeName,
523
+ memo: s.memo,
524
+ }));
525
+ }
433
526
  const { data } = await api.transactions.createTransaction(resolveBudgetId(budgetId), {
434
- transaction: {
435
- account_id: accountId,
436
- date,
437
- amount: milliunits(amount),
438
- payee_id: payeeId,
439
- payee_name: payeeName,
440
- category_id: categoryId,
441
- memo,
442
- cleared,
443
- approved,
444
- flag_color: flagColor,
445
- },
527
+ transaction: txn,
446
528
  });
447
529
  return ok(formatTransaction(data.transaction));
448
530
  })
@@ -496,7 +578,7 @@ server.tool(
496
578
  ({ budgetId, transactionId }) =>
497
579
  run(async () => {
498
580
  const { data } = await api.transactions.deleteTransaction(resolveBudgetId(budgetId), transactionId);
499
- return ok(data.transaction);
581
+ return ok(formatTransaction(data.transaction));
500
582
  })
501
583
  );
502
584
 
@@ -569,6 +651,16 @@ function formatScheduledTransaction(t) {
569
651
  payee_name: t.payee_name,
570
652
  category_id: t.category_id,
571
653
  category_name: t.category_name,
654
+ transfer_account_id: t.transfer_account_id,
655
+ subtransactions: t.subtransactions?.map((s) => ({
656
+ id: s.id,
657
+ amount: dollars(s.amount),
658
+ memo: s.memo,
659
+ payee_id: s.payee_id,
660
+ payee_name: s.payee_name,
661
+ category_id: s.category_id,
662
+ category_name: s.category_name,
663
+ })),
572
664
  };
573
665
  }
574
666
 
@@ -631,6 +723,50 @@ server.tool(
631
723
  })
632
724
  );
633
725
 
726
+ server.tool(
727
+ "update_scheduled_transaction",
728
+ "Update an existing scheduled transaction. Only provided fields are changed. Amounts in dollars.",
729
+ {
730
+ budgetId: z.string().optional().describe("Budget ID (uses default if not provided)"),
731
+ scheduledTransactionId: z.string().describe("Scheduled transaction ID"),
732
+ accountId: z.string().optional().describe("Account ID"),
733
+ date: z.string().optional().describe("Next occurrence date (YYYY-MM-DD)"),
734
+ frequency: z.enum(["never", "daily", "weekly", "everyOtherWeek", "twiceAMonth", "every4Weeks", "monthly", "everyOtherMonth", "every3Months", "every4Months", "twiceAYear", "yearly", "everyOtherYear"]).optional().describe("Recurrence frequency"),
735
+ amount: z.number().optional().describe("Amount in dollars (negative for outflows)"),
736
+ payeeId: z.string().nullable().optional().describe("Payee ID"),
737
+ payeeName: z.string().nullable().optional().describe("Payee name"),
738
+ categoryId: z.string().nullable().optional().describe("Category ID"),
739
+ memo: z.string().nullable().optional().describe("Memo"),
740
+ flagColor: z.enum(["red", "orange", "yellow", "green", "blue", "purple"]).nullable().optional().describe("Flag color"),
741
+ },
742
+ ({ budgetId, scheduledTransactionId, accountId, date, frequency, amount, payeeId, payeeName, categoryId, memo, flagColor }) =>
743
+ run(async () => {
744
+ const bid = resolveBudgetId(budgetId);
745
+ // PUT replaces the full resource — fetch current values to merge with updates
746
+ const { data: current } = await api.scheduledTransactions.getScheduledTransactionById(bid, scheduledTransactionId);
747
+ const existing = current.scheduled_transaction;
748
+
749
+ const st = {
750
+ account_id: accountId ?? existing.account_id,
751
+ date: date ?? existing.date_next,
752
+ frequency: frequency ?? existing.frequency,
753
+ amount: amount !== undefined ? milliunits(amount) : existing.amount,
754
+ payee_id: payeeId !== undefined ? payeeId : existing.payee_id,
755
+ payee_name: payeeName !== undefined ? payeeName : existing.payee_name,
756
+ category_id: categoryId !== undefined ? categoryId : existing.category_id,
757
+ memo: memo !== undefined ? memo : existing.memo,
758
+ flag_color: flagColor !== undefined ? flagColor : existing.flag_color,
759
+ };
760
+
761
+ const { data } = await api.scheduledTransactions.updateScheduledTransaction(
762
+ bid,
763
+ scheduledTransactionId,
764
+ { scheduled_transaction: st }
765
+ );
766
+ return ok(formatScheduledTransaction(data.scheduled_transaction));
767
+ })
768
+ );
769
+
634
770
  server.tool(
635
771
  "delete_scheduled_transaction",
636
772
  "Delete a scheduled transaction",
@@ -641,7 +777,7 @@ server.tool(
641
777
  ({ budgetId, scheduledTransactionId }) =>
642
778
  run(async () => {
643
779
  const { data } = await api.scheduledTransactions.deleteScheduledTransaction(resolveBudgetId(budgetId), scheduledTransactionId);
644
- return ok(data.scheduled_transaction);
780
+ return ok(formatScheduledTransaction(data.scheduled_transaction));
645
781
  })
646
782
  );
647
783
 
@@ -726,5 +862,10 @@ server.tool(
726
862
 
727
863
  // --- Start ---
728
864
 
865
+ process.on("uncaughtException", (err) => {
866
+ console.error("Uncaught exception:", err);
867
+ process.exit(1);
868
+ });
869
+
729
870
  const transport = new StdioServerTransport();
730
871
  await server.connect(transport);
package/package.json CHANGED
@@ -1,33 +1,20 @@
1
- {
2
- "name": "@oliverames/ynab-mcp-server",
3
- "version": "1.0.0",
4
- "description": "YNAB MCP server with full API coverage",
5
- "type": "module",
6
- "main": "index.js",
7
- "bin": {
8
- "ynab-mcp-server": "index.js"
9
- },
10
- "files": [
11
- "index.js"
12
- ],
13
- "keywords": [
14
- "mcp",
15
- "ynab",
16
- "budgeting",
17
- "model-context-protocol"
18
- ],
19
- "author": "Oliver Ames",
20
- "license": "MIT",
21
- "repository": {
22
- "type": "git",
23
- "url": "https://github.com/oliverames/oliver-claude-marketplace",
24
- "directory": "extensions/ynab-mcp-server"
25
- },
26
- "scripts": {
27
- "start": "node index.js"
28
- },
29
- "dependencies": {
30
- "@modelcontextprotocol/sdk": "^1.12.1",
31
- "ynab": "^2.5.0"
32
- }
33
- }
1
+ {
2
+ "name": "@oliverames/ynab-mcp-server",
3
+ "version": "1.1.0",
4
+ "description": "YNAB MCP server with full API coverage",
5
+ "type": "module",
6
+ "main": "index.js",
7
+ "bin": {
8
+ "ynab-mcp-server": "index.js"
9
+ },
10
+ "files": [
11
+ "index.js"
12
+ ],
13
+ "scripts": {
14
+ "start": "node index.js"
15
+ },
16
+ "dependencies": {
17
+ "@modelcontextprotocol/sdk": "^1.12.1",
18
+ "ynab": "^2.5.0"
19
+ }
20
+ }