@oliverames/ynab-mcp-server 2.1.0 → 2.1.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -17,7 +17,7 @@
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/v2.1.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
+ <a href="https://github.com/oliverames/ynab-mcp-server/releases/tag/v2.1.1"><img src="https://img.shields.io/github/v/release/oliverames/ynab-mcp-server?style=flat-square&color=f5a542&label=MCPB" alt="MCPB release"></a>
21
21
  <a href="LICENSE"><img src="https://img.shields.io/badge/license-MIT-f5a542?style=flat-square" alt="License"></a>
22
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>
23
23
  </p>
@@ -44,9 +44,9 @@ This server gives your AI assistant a safe interface to YNAB's API, turning natu
44
44
 
45
45
  ### Install with MCPB
46
46
 
47
- For Claude Desktop and other MCPB-compatible clients, download the local bundle from the [v2.0.0 release](https://github.com/oliverames/ynab-mcp-server/releases/tag/v2.1.0):
47
+ For Claude Desktop and other MCPB-compatible clients, download the local bundle from the [v2.1.1 release](https://github.com/oliverames/ynab-mcp-server/releases/tag/v2.1.1):
48
48
 
49
- [Download `ynab-mcp-server-2.1.0.mcpb`](https://github.com/oliverames/ynab-mcp-server/releases/download/v2.1.0/ynab-mcp-server-2.1.0.mcpb)
49
+ [Download `ynab-mcp-server-2.1.1.mcpb`](https://github.com/oliverames/ynab-mcp-server/releases/download/v2.1.1/ynab-mcp-server-2.1.1.mcpb)
50
50
 
51
51
  The bundle includes the YNAB favicon, production runtime dependencies, and setup prompts for your personal access token, optional default budget ID, and optional write-tool opt-in.
52
52
 
package/index.js CHANGED
@@ -41,13 +41,13 @@ if (!API_TOKEN) {
41
41
  const fallbackMessage = tokenLookupError
42
42
  ? ` ${tokenLookupError}.`
43
43
  : " Set YNAB_API_TOKEN_FILE or YNAB_OP_PATH to enable token fallback.";
44
- console.error(`YNAB_API_TOKEN environment variable is required.${fallbackMessage}`);
45
- process.exit(1);
44
+ console.error(`YNAB_API_TOKEN environment variable is required.${fallbackMessage} Starting in discovery-only mode.`);
46
45
  }
47
46
 
48
47
  const ynabRateLimit = createYnabRateLimiter();
49
- const api = new ynab.API(API_TOKEN, BASE_URL);
50
- api._configuration.config = { accessToken: API_TOKEN, basePath: BASE_URL, fetchApi: secureFetch };
48
+ const effectiveApiToken = API_TOKEN || "missing-token-for-tool-discovery";
49
+ const api = new ynab.API(effectiveApiToken, BASE_URL);
50
+ api._configuration.config = { accessToken: effectiveApiToken, basePath: BASE_URL, fetchApi: secureFetch };
51
51
  const DEFAULT_BUDGET_ID = process.env.YNAB_BUDGET_ID;
52
52
 
53
53
  // --- Helpers ---
@@ -320,7 +320,7 @@ async function ynabFetch(path, { method = "GET", body, query } = {}) {
320
320
  }
321
321
  const opts = {
322
322
  method,
323
- headers: { Authorization: `Bearer ${API_TOKEN}`, "Content-Type": "application/json" },
323
+ headers: { Authorization: `Bearer ${effectiveApiToken}`, "Content-Type": "application/json" },
324
324
  };
325
325
  if (body) opts.body = JSON.stringify(body);
326
326
  const res = await secureFetch(url, opts);
@@ -345,9 +345,39 @@ async function ynabFetch(path, { method = "GET", body, query } = {}) {
345
345
 
346
346
  const server = new McpServer({
347
347
  name: "ynab-mcp-server",
348
- version: "2.1.0",
348
+ version: "2.1.1",
349
349
  });
350
350
 
351
+ const registeredTools = new Map();
352
+ const toolCatalog = new Map();
353
+
354
+ function registerTool(name, config, handler) {
355
+ const registration = server.registerTool(name, config, handler);
356
+ toolCatalog.set(name, { config });
357
+ if (registration !== undefined) {
358
+ registeredTools.set(name, { config, handler });
359
+ }
360
+ return registration;
361
+ }
362
+
363
+ function listRegisteredYnabTools() {
364
+ return [...toolCatalog.entries()]
365
+ .filter(([name]) => !name.startsWith("ynab_"))
366
+ .map(([name, { config }]) => {
367
+ const writeMetadata = WRITE_TOOL_METADATA[name];
368
+ const isWrite = !!writeMetadata;
369
+ return {
370
+ name,
371
+ title: config?.title ?? name,
372
+ description: isWrite ? withWriteGateDescription(config?.description ?? "") : config?.description ?? "",
373
+ has_input_schema: !!config?.inputSchema,
374
+ is_write: isWrite,
375
+ registered: registeredTools.has(name),
376
+ status: isWrite && !writesEnabled() ? "hidden_requires_YNAB_ALLOW_WRITES_1" : "available",
377
+ };
378
+ });
379
+ }
380
+
351
381
  const WRITE_TOOL_METADATA = {
352
382
  create_account: { destructiveHint: false, idempotentHint: false },
353
383
  update_month_category: { destructiveHint: false, idempotentHint: true },
@@ -364,6 +394,7 @@ const WRITE_TOOL_METADATA = {
364
394
  update_transactions: { destructiveHint: false, idempotentHint: true },
365
395
  approve_transactions: { destructiveHint: false, idempotentHint: true },
366
396
  reassign_payee_transactions: { destructiveHint: false, idempotentHint: true },
397
+ ynab_write_tool_execute: { destructiveHint: false, idempotentHint: false },
367
398
  import_transactions: { destructiveHint: false, idempotentHint: false },
368
399
  create_scheduled_transaction: { destructiveHint: false, idempotentHint: false },
369
400
  update_scheduled_transaction: { destructiveHint: false, idempotentHint: true },
@@ -374,6 +405,17 @@ function writesEnabled() {
374
405
  return process.env.YNAB_ALLOW_WRITES === "1";
375
406
  }
376
407
 
408
+ function ynabAuthStatus() {
409
+ return {
410
+ authenticated: !!API_TOKEN,
411
+ default_budget_id_configured: !!DEFAULT_BUDGET_ID,
412
+ writes_enabled: writesEnabled(),
413
+ message: API_TOKEN
414
+ ? "YNAB MCP server has an API token configured."
415
+ : "YNAB MCP server is running in discovery-only mode. Set YNAB_API_TOKEN, YNAB_API_TOKEN_FILE, or YNAB_OP_PATH, then restart the MCP server before calling API tools.",
416
+ };
417
+ }
418
+
377
419
  function writeDisabledResult(name) {
378
420
  return {
379
421
  content: [{
@@ -428,7 +470,7 @@ server.registerTool = (name, config, handler) => {
428
470
 
429
471
  // ==================== User & Budgets ====================
430
472
 
431
- server.registerTool(
473
+ registerTool(
432
474
  "get_user",
433
475
  { description: "Get the authenticated user" },
434
476
  () =>
@@ -438,7 +480,7 @@ server.registerTool(
438
480
  })
439
481
  );
440
482
 
441
- server.registerTool(
483
+ registerTool(
442
484
  "list_budgets",
443
485
  { description: "List all budgets. Use a budget ID from the results in other tools, or omit budgetId to use the last-used budget." },
444
486
  () =>
@@ -454,7 +496,7 @@ server.registerTool(
454
496
  })
455
497
  );
456
498
 
457
- server.registerTool(
499
+ registerTool(
458
500
  "get_budget",
459
501
  { description: "Get a budget summary including name, currency format, and account/category/payee counts", inputSchema: { budgetId: z.string().optional().describe("Budget ID (uses default if not provided)") } },
460
502
  ({ budgetId }) =>
@@ -476,7 +518,7 @@ server.registerTool(
476
518
  })
477
519
  );
478
520
 
479
- server.registerTool(
521
+ registerTool(
480
522
  "get_budget_settings",
481
523
  { description: "Get budget settings (currency format, date format)", inputSchema: { budgetId: z.string().optional().describe("Budget ID (uses default if not provided)") } },
482
524
  ({ budgetId }) =>
@@ -512,7 +554,7 @@ function formatAccount(a) {
512
554
  return withCurrencyFields(out, a, ["balance", "cleared_balance", "uncleared_balance"]);
513
555
  }
514
556
 
515
- server.registerTool(
557
+ registerTool(
516
558
  "list_accounts",
517
559
  { description: "List all accounts in a budget", inputSchema: {
518
560
  budgetId: z.string().optional().describe("Budget ID (uses default if not provided)"),
@@ -526,7 +568,7 @@ server.registerTool(
526
568
  })
527
569
  );
528
570
 
529
- server.registerTool(
571
+ registerTool(
530
572
  "get_account",
531
573
  { description: "Get details for a specific account", inputSchema: {
532
574
  budgetId: z.string().optional().describe("Budget ID (uses default if not provided)"),
@@ -539,7 +581,7 @@ server.registerTool(
539
581
  })
540
582
  );
541
583
 
542
- server.registerTool(
584
+ registerTool(
543
585
  "create_account",
544
586
  { description: "Create a new account", inputSchema: {
545
587
  budgetId: z.string().optional().describe("Budget ID (uses default if not provided)"),
@@ -598,7 +640,7 @@ function formatCategory(c) {
598
640
  ]);
599
641
  }
600
642
 
601
- server.registerTool(
643
+ registerTool(
602
644
  "list_categories",
603
645
  { description: "List all category groups and their categories", inputSchema: {
604
646
  budgetId: z.string().optional().describe("Budget ID (uses default if not provided)"),
@@ -634,7 +676,7 @@ server.registerTool(
634
676
  })
635
677
  );
636
678
 
637
- server.registerTool(
679
+ registerTool(
638
680
  "get_category",
639
681
  { description: "Get a specific category", inputSchema: {
640
682
  budgetId: z.string().optional().describe("Budget ID (uses default if not provided)"),
@@ -647,7 +689,7 @@ server.registerTool(
647
689
  })
648
690
  );
649
691
 
650
- server.registerTool(
692
+ registerTool(
651
693
  "get_month_category",
652
694
  { description: "Get category budget for a specific month", inputSchema: {
653
695
  budgetId: z.string().optional().describe("Budget ID (uses default if not provided)"),
@@ -661,7 +703,7 @@ server.registerTool(
661
703
  })
662
704
  );
663
705
 
664
- server.registerTool(
706
+ registerTool(
665
707
  "update_month_category",
666
708
  { description: "Set the budgeted amount for a category in a specific month", inputSchema: {
667
709
  budgetId: z.string().optional().describe("Budget ID (uses default if not provided)"),
@@ -678,7 +720,7 @@ server.registerTool(
678
720
  })
679
721
  );
680
722
 
681
- server.registerTool(
723
+ registerTool(
682
724
  "update_category",
683
725
  { description: "Update a category's name, note, goal target, or move it to a different group", inputSchema: {
684
726
  budgetId: z.string().optional().describe("Budget ID (uses default if not provided)"),
@@ -708,7 +750,7 @@ server.registerTool(
708
750
  })
709
751
  );
710
752
 
711
- server.registerTool(
753
+ registerTool(
712
754
  "create_category",
713
755
  { description: "Create a new category in a category group", inputSchema: {
714
756
  budgetId: z.string().optional().describe("Budget ID (uses default if not provided)"),
@@ -735,7 +777,7 @@ server.registerTool(
735
777
  })
736
778
  );
737
779
 
738
- server.registerTool(
780
+ registerTool(
739
781
  "create_category_group",
740
782
  { description: "Create a new category group", inputSchema: {
741
783
  budgetId: z.string().optional().describe("Budget ID (uses default if not provided)"),
@@ -751,7 +793,7 @@ server.registerTool(
751
793
  })
752
794
  );
753
795
 
754
- server.registerTool(
796
+ registerTool(
755
797
  "update_category_group",
756
798
  { description: "Rename a category group", inputSchema: {
757
799
  budgetId: z.string().optional().describe("Budget ID (uses default if not provided)"),
@@ -770,7 +812,7 @@ server.registerTool(
770
812
 
771
813
  // ==================== Payees ====================
772
814
 
773
- server.registerTool(
815
+ registerTool(
774
816
  "list_payees",
775
817
  { description: "List all payees", inputSchema: {
776
818
  budgetId: z.string().optional().describe("Budget ID (uses default if not provided)"),
@@ -784,7 +826,7 @@ server.registerTool(
784
826
  })
785
827
  );
786
828
 
787
- server.registerTool(
829
+ registerTool(
788
830
  "get_payee",
789
831
  { description: "Get a specific payee", inputSchema: {
790
832
  budgetId: z.string().optional().describe("Budget ID (uses default if not provided)"),
@@ -797,7 +839,7 @@ server.registerTool(
797
839
  })
798
840
  );
799
841
 
800
- server.registerTool(
842
+ registerTool(
801
843
  "update_payee",
802
844
  { description: "Rename a payee", inputSchema: {
803
845
  budgetId: z.string().optional().describe("Budget ID (uses default if not provided)"),
@@ -813,7 +855,7 @@ server.registerTool(
813
855
  })
814
856
  );
815
857
 
816
- server.registerTool(
858
+ registerTool(
817
859
  "create_payee",
818
860
  { description: "Create a new payee", inputSchema: {
819
861
  budgetId: z.string().optional().describe("Budget ID (uses default if not provided)"),
@@ -831,7 +873,7 @@ server.registerTool(
831
873
 
832
874
  // ==================== Payee Locations ====================
833
875
 
834
- server.registerTool(
876
+ registerTool(
835
877
  "list_payee_locations",
836
878
  { description: "List all payee locations (GPS coordinates where transactions occurred)", inputSchema: { budgetId: z.string().optional().describe("Budget ID (uses default if not provided)") } },
837
879
  ({ budgetId }) =>
@@ -841,7 +883,7 @@ server.registerTool(
841
883
  })
842
884
  );
843
885
 
844
- server.registerTool(
886
+ registerTool(
845
887
  "get_payee_location",
846
888
  { description: "Get a specific payee location", inputSchema: {
847
889
  budgetId: z.string().optional().describe("Budget ID (uses default if not provided)"),
@@ -854,7 +896,7 @@ server.registerTool(
854
896
  })
855
897
  );
856
898
 
857
- server.registerTool(
899
+ registerTool(
858
900
  "get_payee_locations_by_payee",
859
901
  { description: "Get all locations for a specific payee", inputSchema: {
860
902
  budgetId: z.string().optional().describe("Budget ID (uses default if not provided)"),
@@ -869,7 +911,7 @@ server.registerTool(
869
911
 
870
912
  // ==================== Months ====================
871
913
 
872
- server.registerTool(
914
+ registerTool(
873
915
  "list_months",
874
916
  { description: "List all budget months", inputSchema: {
875
917
  budgetId: z.string().optional().describe("Budget ID (uses default if not provided)"),
@@ -898,7 +940,7 @@ server.registerTool(
898
940
  })
899
941
  );
900
942
 
901
- server.registerTool(
943
+ registerTool(
902
944
  "get_month",
903
945
  { description: "Get budget month detail with per-category breakdown", inputSchema: {
904
946
  budgetId: z.string().optional().describe("Budget ID (uses default if not provided)"),
@@ -962,7 +1004,7 @@ function formatMoneyMovement(m) {
962
1004
  }, m, ["amount"]);
963
1005
  }
964
1006
 
965
- server.registerTool(
1007
+ registerTool(
966
1008
  "list_money_movements",
967
1009
  { description: "List all money movements (budget re-allocations between categories)", inputSchema: { budgetId: z.string().optional().describe("Budget ID (uses default if not provided)") } },
968
1010
  ({ budgetId }) =>
@@ -972,7 +1014,7 @@ server.registerTool(
972
1014
  })
973
1015
  );
974
1016
 
975
- server.registerTool(
1017
+ registerTool(
976
1018
  "get_money_movements_by_month",
977
1019
  { description: "Get money movements for a specific month", inputSchema: {
978
1020
  budgetId: z.string().optional().describe("Budget ID (uses default if not provided)"),
@@ -985,7 +1027,7 @@ server.registerTool(
985
1027
  })
986
1028
  );
987
1029
 
988
- server.registerTool(
1030
+ registerTool(
989
1031
  "list_money_movement_groups",
990
1032
  { description: "List all money movement groups (batches of related money movements)", inputSchema: { budgetId: z.string().optional().describe("Budget ID (uses default if not provided)") } },
991
1033
  ({ budgetId }) =>
@@ -995,7 +1037,7 @@ server.registerTool(
995
1037
  })
996
1038
  );
997
1039
 
998
- server.registerTool(
1040
+ registerTool(
999
1041
  "get_money_movement_groups_by_month",
1000
1042
  { description: "Get money movement groups for a specific month", inputSchema: {
1001
1043
  budgetId: z.string().optional().describe("Budget ID (uses default if not provided)"),
@@ -1057,7 +1099,7 @@ function formatTransaction(t) {
1057
1099
  return withCurrencyFields(out, t, ["amount"]);
1058
1100
  }
1059
1101
 
1060
- server.registerTool(
1102
+ registerTool(
1061
1103
  "get_transactions",
1062
1104
  { description: "Get transactions with optional filters. Use type='unapproved' or type='uncategorized' to filter. Optionally filter by account, category, payee, or month. Each returned transaction includes 'import_payee_name_original' — the raw merchant string from the bank import (e.g. 'AplPay LS ONION RIVEMONTPELIER VT') — which encodes processor flag, merchant name (often longer than the cleaned payee_name), and city+state. This is the primary disambiguation field when payee_name is truncated or ambiguous. Note: large date ranges (6+ months on a busy budget) can return 50KB+ of data; narrow with categoryId/payeeId/month filters when possible.", inputSchema: {
1063
1105
  budgetId: z.string().optional().describe("Budget ID (uses default if not provided)"),
@@ -1100,7 +1142,7 @@ server.registerTool(
1100
1142
  })
1101
1143
  );
1102
1144
 
1103
- server.registerTool(
1145
+ registerTool(
1104
1146
  "get_transaction",
1105
1147
  { description: "Get a single transaction by ID. Automatically handles composite scheduled-transaction IDs (e.g. uuid_YYYY-MM-DD): the date suffix is stripped before the lookup. If a composite ID's underlying matched transaction has been deleted, falls back to returning the active scheduled-transaction template wrapped in a marker shape { resource_type: 'scheduled_transaction', reason: 'composite_id_with_no_matched_transaction', scheduled_transaction, requested_id } so callers can distinguish the two return shapes. Non-composite IDs preserve strict behavior: a 404 still surfaces as resource_not_found.", inputSchema: {
1106
1148
  budgetId: z.string().optional().describe("Budget ID (uses default if not provided)"),
@@ -1140,7 +1182,7 @@ server.registerTool(
1140
1182
  })
1141
1183
  );
1142
1184
 
1143
- server.registerTool(
1185
+ registerTool(
1144
1186
  "create_transaction",
1145
1187
  { 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: {
1146
1188
  budgetId: z.string().optional().describe("Budget ID (uses default if not provided)"),
@@ -1172,7 +1214,7 @@ server.registerTool(
1172
1214
  })
1173
1215
  );
1174
1216
 
1175
- server.registerTool(
1217
+ registerTool(
1176
1218
  "create_transactions",
1177
1219
  { 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: {
1178
1220
  budgetId: z.string().optional().describe("Budget ID (uses default if not provided)"),
@@ -1209,7 +1251,7 @@ server.registerTool(
1209
1251
  })
1210
1252
  );
1211
1253
 
1212
- server.registerTool(
1254
+ registerTool(
1213
1255
  "update_transaction",
1214
1256
  { description: "Update an existing transaction. Only provided fields are changed. Amounts in dollars.", inputSchema: {
1215
1257
  budgetId: z.string().optional().describe("Budget ID (uses default if not provided)"),
@@ -1234,7 +1276,7 @@ server.registerTool(
1234
1276
  })
1235
1277
  );
1236
1278
 
1237
- server.registerTool(
1279
+ registerTool(
1238
1280
  "delete_transaction",
1239
1281
  { description: "Delete a transaction", inputSchema: {
1240
1282
  budgetId: z.string().optional().describe("Budget ID (uses default if not provided)"),
@@ -1247,7 +1289,7 @@ server.registerTool(
1247
1289
  })
1248
1290
  );
1249
1291
 
1250
- server.registerTool(
1292
+ registerTool(
1251
1293
  "update_transactions",
1252
1294
  { description: "Batch update multiple transactions. Each transaction object must include its id and the fields to update. IMPORTANT: only use transaction IDs extracted from get_transactions / review_unapproved results — never compose IDs by hand (fabricated IDs return 'transaction does not exist in this budget' errors). For combined category+approval changes, include both 'categoryId' and 'approved: true' in the same entry. This tool refetches each transaction after the bulk update, verifies requested fields actually persisted, and retries mismatches once through single-transaction updates. Never trust review_unapproved counts alone after approving transactions; use this response's verification block or get_transaction to confirm fields.", inputSchema: {
1253
1295
  budgetId: z.string().optional().describe("Budget ID (uses default if not provided)"),
@@ -1307,7 +1349,7 @@ server.registerTool(
1307
1349
  })
1308
1350
  );
1309
1351
 
1310
- server.registerTool(
1352
+ registerTool(
1311
1353
  "approve_transactions",
1312
1354
  { description: "Approve unapproved transactions in bulk by filter, without hand-listing IDs. Fetches the current unapproved queue, optionally narrows by payeeId / categoryId / accountId, and sets approved:true on the matches. By default SKIPS uncategorized transactions (no category and not a transfer) so nothing is approved without a category; set includeUncategorized:true to override. Returns a compact summary (approved_count + verification counts), never full objects, so it is safe on large batches. The CALLER is responsible for getting user confirmation before invoking — this tool does not prompt.", inputSchema: {
1313
1355
  budgetId: z.string().optional().describe("Budget ID (uses default if not provided)"),
@@ -1350,7 +1392,7 @@ server.registerTool(
1350
1392
  })
1351
1393
  );
1352
1394
 
1353
- server.registerTool(
1395
+ registerTool(
1354
1396
  "reassign_payee_transactions",
1355
1397
  { description: "Move all transactions from one payee to another. The YNAB API has no payee-merge or payee-delete endpoint, so this is the merge workaround: refetch every transaction for fromPayeeId and set payee_id = toPayeeId. Use to consolidate a duplicate payee that a slightly different bank-import string created (e.g. fold 'Myles Court Barber' into the existing 'Myles Court Barbershop'). The emptied source payee still exists afterward and must be deleted manually in the YNAB UI (Settings → Manage Payees) if wanted. Returns a compact summary.", inputSchema: {
1356
1398
  budgetId: z.string().optional().describe("Budget ID (uses default if not provided)"),
@@ -1387,7 +1429,7 @@ server.registerTool(
1387
1429
  })
1388
1430
  );
1389
1431
 
1390
- server.registerTool(
1432
+ registerTool(
1391
1433
  "import_transactions",
1392
1434
  { description: "Trigger import of linked account transactions", inputSchema: { budgetId: z.string().optional().describe("Budget ID (uses default if not provided)") } },
1393
1435
  ({ budgetId }) =>
@@ -1439,7 +1481,7 @@ function formatScheduledTransaction(t) {
1439
1481
  return withCurrencyFields(out, t, ["amount"]);
1440
1482
  }
1441
1483
 
1442
- server.registerTool(
1484
+ registerTool(
1443
1485
  "list_scheduled_transactions",
1444
1486
  { 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: {
1445
1487
  budgetId: z.string().optional().describe("Budget ID (uses default if not provided)"),
@@ -1453,7 +1495,7 @@ server.registerTool(
1453
1495
  })
1454
1496
  );
1455
1497
 
1456
- server.registerTool(
1498
+ registerTool(
1457
1499
  "get_scheduled_transaction",
1458
1500
  { description: "Get a specific scheduled transaction", inputSchema: {
1459
1501
  budgetId: z.string().optional().describe("Budget ID (uses default if not provided)"),
@@ -1466,7 +1508,7 @@ server.registerTool(
1466
1508
  })
1467
1509
  );
1468
1510
 
1469
- server.registerTool(
1511
+ registerTool(
1470
1512
  "create_scheduled_transaction",
1471
1513
  { description: "Create a new scheduled (recurring) transaction", inputSchema: {
1472
1514
  budgetId: z.string().optional().describe("Budget ID (uses default if not provided)"),
@@ -1499,7 +1541,7 @@ server.registerTool(
1499
1541
  })
1500
1542
  );
1501
1543
 
1502
- server.registerTool(
1544
+ registerTool(
1503
1545
  "update_scheduled_transaction",
1504
1546
  { description: "Update an existing scheduled transaction. Only provided fields are changed. Amounts in dollars.", inputSchema: {
1505
1547
  budgetId: z.string().optional().describe("Budget ID (uses default if not provided)"),
@@ -1542,7 +1584,7 @@ server.registerTool(
1542
1584
  })
1543
1585
  );
1544
1586
 
1545
- server.registerTool(
1587
+ registerTool(
1546
1588
  "delete_scheduled_transaction",
1547
1589
  { description: "Delete a scheduled transaction", inputSchema: {
1548
1590
  budgetId: z.string().optional().describe("Budget ID (uses default if not provided)"),
@@ -1557,7 +1599,7 @@ server.registerTool(
1557
1599
 
1558
1600
  // ==================== Convenience Tools ====================
1559
1601
 
1560
- server.registerTool(
1602
+ registerTool(
1561
1603
  "search_categories",
1562
1604
  { description: "Search categories by partial name match (case-insensitive). Useful for finding category IDs when you only know part of the name.", inputSchema: {
1563
1605
  budgetId: z.string().optional().describe("Budget ID (uses default if not provided)"),
@@ -1589,7 +1631,7 @@ server.registerTool(
1589
1631
  })
1590
1632
  );
1591
1633
 
1592
- server.registerTool(
1634
+ registerTool(
1593
1635
  "search_payees",
1594
1636
  { description: "Search payees by partial name match (case-insensitive). Useful for finding payee IDs.", inputSchema: {
1595
1637
  budgetId: z.string().optional().describe("Budget ID (uses default if not provided)"),
@@ -1607,7 +1649,7 @@ server.registerTool(
1607
1649
  })
1608
1650
  );
1609
1651
 
1610
- server.registerTool(
1652
+ registerTool(
1611
1653
  "review_unapproved",
1612
1654
  { 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 is stale — the `matched_transaction_id` field is read-only via this API; YNAB web/iOS UI is required to clear that link. The transaction itself remains fully mutable: you CAN approve, recategorize, and edit memo via update_transaction. The broken match persists as a cosmetic flag until the user resolves it in the UI.), 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. For large budgets the full response can exceed 100KB; pass summary:true for counts + by-payee aggregates only, or compact:true to keep per-transaction rows (with IDs) while dropping bulky fields so the response fits inline.", inputSchema: {
1613
1655
  budgetId: z.string().optional().describe("Budget ID (uses default if not provided)"),
@@ -1747,7 +1789,7 @@ server.registerTool(
1747
1789
  })
1748
1790
  );
1749
1791
 
1750
- server.registerTool(
1792
+ registerTool(
1751
1793
  "get_overspent_categories",
1752
1794
  { 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: {
1753
1795
  budgetId: z.string().optional().describe("Budget ID (uses default if not provided)"),
@@ -1776,6 +1818,99 @@ server.registerTool(
1776
1818
  })
1777
1819
  );
1778
1820
 
1821
+ registerTool(
1822
+ "ynab_auth_status",
1823
+ {
1824
+ title: "YNAB Auth Status",
1825
+ description: "Check whether the YNAB MCP server has credentials configured and whether write tools are enabled.",
1826
+ inputSchema: {},
1827
+ },
1828
+ () => ok(ynabAuthStatus())
1829
+ );
1830
+
1831
+ registerTool(
1832
+ "ynab_tool_index",
1833
+ {
1834
+ title: "YNAB Tool Index",
1835
+ description: "Discover the YNAB MCP server tools. Use this when you need YNAB budgets, accounts, categories, payees, transactions, scheduled transactions, unapproved transaction review, approval, or budget cleanup tools.",
1836
+ inputSchema: {},
1837
+ },
1838
+ () => ok({
1839
+ server: "ynab-mcp-server",
1840
+ package: "@oliverames/ynab-mcp-server",
1841
+ auth: ynabAuthStatus(),
1842
+ writes_enabled: writesEnabled(),
1843
+ tools: listRegisteredYnabTools(),
1844
+ execute_with: "ynab_tool_execute",
1845
+ write_execute_with: writesEnabled() ? "ynab_write_tool_execute" : null,
1846
+ })
1847
+ );
1848
+
1849
+ registerTool(
1850
+ "ynab_tool_execute",
1851
+ {
1852
+ title: "Execute YNAB Tool",
1853
+ description: "Execute an existing read-only YNAB MCP tool by name. Use ynab_tool_index first to discover YNAB tool names, then pass the selected tool_name and its JSON input. Write-capable tools must be called directly or through ynab_write_tool_execute when YNAB_ALLOW_WRITES=1.",
1854
+ inputSchema: {
1855
+ tool_name: z.string().describe("Existing read-only YNAB tool name, such as review_unapproved, get_transactions, list_categories, search_categories, or search_payees."),
1856
+ input: z.record(z.string(), z.any()).optional().describe("JSON input for the selected YNAB tool. Omit or pass an empty object for tools that take no input."),
1857
+ },
1858
+ },
1859
+ async ({ tool_name: toolName, input = {} }) => {
1860
+ if (toolName.startsWith("ynab_")) {
1861
+ return {
1862
+ isError: true,
1863
+ content: [{ type: "text", text: "Refusing to execute YNAB discovery helper tools recursively." }],
1864
+ };
1865
+ }
1866
+ if (WRITE_TOOL_METADATA[toolName]) {
1867
+ return {
1868
+ isError: true,
1869
+ content: [{ type: "text", text: `${toolName} is a write-capable YNAB tool. Set YNAB_ALLOW_WRITES=1 and call it directly, or use ynab_write_tool_execute.` }],
1870
+ };
1871
+ }
1872
+ const tool = registeredTools.get(toolName);
1873
+ if (!tool) {
1874
+ return {
1875
+ isError: true,
1876
+ content: [{ type: "text", text: `Unknown YNAB tool: ${toolName}` }],
1877
+ };
1878
+ }
1879
+ return tool.handler(input);
1880
+ }
1881
+ );
1882
+
1883
+ registerTool(
1884
+ "ynab_write_tool_execute",
1885
+ {
1886
+ title: "Execute YNAB Write Tool",
1887
+ description: "Execute an existing write-capable YNAB MCP tool by name. This tool is registered only when YNAB_ALLOW_WRITES=1 and should be used only after explicit user confirmation.",
1888
+ inputSchema: {
1889
+ tool_name: z.string().describe("Existing write-capable YNAB tool name, such as update_transaction, update_transactions, approve_transactions, create_transaction, or delete_transaction."),
1890
+ input: z.record(z.string(), z.any()).optional().describe("JSON input for the selected YNAB write tool."),
1891
+ },
1892
+ },
1893
+ async ({ tool_name: toolName, input = {} }) => {
1894
+ if (toolName.startsWith("ynab_")) {
1895
+ return {
1896
+ isError: true,
1897
+ content: [{ type: "text", text: "Refusing to execute YNAB discovery helper tools recursively." }],
1898
+ };
1899
+ }
1900
+ if (!WRITE_TOOL_METADATA[toolName]) {
1901
+ return {
1902
+ isError: true,
1903
+ content: [{ type: "text", text: `${toolName} is not a write-capable YNAB tool. Use ynab_tool_execute for read-only tools.` }],
1904
+ };
1905
+ }
1906
+ const tool = registeredTools.get(toolName);
1907
+ if (!tool) {
1908
+ return writeDisabledResult(toolName);
1909
+ }
1910
+ return tool.handler(input);
1911
+ }
1912
+ );
1913
+
1779
1914
  // --- Start ---
1780
1915
 
1781
1916
  process.on("uncaughtException", (err) => {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@oliverames/ynab-mcp-server",
3
- "version": "2.1.0",
3
+ "version": "2.1.1",
4
4
  "description": "YNAB MCP server with full API coverage",
5
5
  "type": "module",
6
6
  "main": "index.js",
@@ -41,7 +41,15 @@ assert(lock.version === version, `package-lock root version matches ${version}`)
41
41
  assert(lock.packages?.[""]?.version === version, `package-lock package version matches ${version}`);
42
42
  assert(indexJs.includes(`version: "${version}"`), `index.js McpServer version matches ${version}`);
43
43
 
44
- const registeredToolCount = [...indexJs.matchAll(/server\.registerTool\(/g)].length;
44
+ const registeredToolNames = [...indexJs.matchAll(/^\s*registerTool\(\s*\n\s*"([^"]+)"/gm)]
45
+ .map((match) => match[1]);
46
+ const registeredToolCount = registeredToolNames
47
+ .filter((name) => !name.startsWith("ynab_"))
48
+ .length;
49
+ const discoveryHelpers = ["ynab_auth_status", "ynab_tool_index", "ynab_tool_execute", "ynab_write_tool_execute"];
50
+ for (const helperName of discoveryHelpers) {
51
+ assert(registeredToolNames.includes(helperName), `discovery helper ${helperName} is registered`);
52
+ }
45
53
  const readmeToolCounts = [...new Set(
46
54
  [...readme.matchAll(/\b(\d+) tools\b/gi)].map((match) => Number(match[1]))
47
55
  )];
@@ -7,10 +7,15 @@ const requiredTools = [
7
7
  "get_transactions",
8
8
  "search_categories",
9
9
  "search_payees",
10
+ "ynab_auth_status",
11
+ "ynab_tool_index",
12
+ "ynab_tool_execute",
10
13
  ];
11
14
 
12
15
  const options = parseSmokeOptions();
13
- const requiredWriteTools = process.env.YNAB_ALLOW_WRITES === "1" ? ["update_transactions"] : [];
16
+ const requiredWriteTools = process.env.YNAB_ALLOW_WRITES === "1"
17
+ ? ["update_transactions", "approve_transactions", "ynab_write_tool_execute"]
18
+ : [];
14
19
 
15
20
  await withSmokeClient(options, async (client, params) => {
16
21
  const result = await client.listTools();
@@ -21,10 +21,13 @@ const writeTools = [
21
21
  "update_transaction",
22
22
  "delete_transaction",
23
23
  "update_transactions",
24
+ "approve_transactions",
25
+ "reassign_payee_transactions",
24
26
  "import_transactions",
25
27
  "create_scheduled_transaction",
26
28
  "update_scheduled_transaction",
27
29
  "delete_scheduled_transaction",
30
+ "ynab_write_tool_execute",
28
31
  ];
29
32
 
30
33
  const requiredReadTools = [
@@ -82,8 +85,17 @@ async function listTools(overrides = {}) {
82
85
  const readOnlyTools = await listTools({ YNAB_ALLOW_WRITES: undefined });
83
86
  const readOnlyNames = new Set(readOnlyTools.map((tool) => tool.name));
84
87
 
88
+ const discoveryOnlyTools = await listTools({
89
+ YNAB_API_TOKEN: undefined,
90
+ YNAB_API_TOKEN_FILE: undefined,
91
+ YNAB_OP_PATH: undefined,
92
+ YNAB_ALLOW_WRITES: undefined,
93
+ });
94
+ const discoveryOnlyNames = new Set(discoveryOnlyTools.map((tool) => tool.name));
95
+
85
96
  for (const name of requiredReadTools) {
86
97
  assert.ok(readOnlyNames.has(name), `expected read tool ${name} to be available by default`);
98
+ assert.ok(discoveryOnlyNames.has(name), `expected read tool ${name} to be discoverable without auth`);
87
99
  }
88
100
 
89
101
  for (const name of writeTools) {