@oliverames/ynab-mcp-server 2.1.0 → 3.0.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.
package/index.js CHANGED
@@ -2,6 +2,8 @@
2
2
 
3
3
  import { execFileSync } from "node:child_process";
4
4
  import { readFileSync } from "node:fs";
5
+ import { homedir } from "node:os";
6
+ import path from "node:path";
5
7
  import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
6
8
  import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
7
9
  import { z } from "zod";
@@ -13,44 +15,360 @@ const BASE_URL = "https://api.ynab.com/v1";
13
15
  const YNAB_API_HOST = "api.ynab.com";
14
16
  const MAX_TOKEN_FILE_BYTES = 4096;
15
17
  const MAX_RESPONSE_BYTES = Number.parseInt(process.env.YNAB_MAX_RESPONSE_BYTES || "8388608", 10);
18
+ const YNAB_RUNTIME_KEYS = [
19
+ "YNAB_API_TOKEN",
20
+ "YNAB_API_TOKEN_FILE",
21
+ "YNAB_OP_PATH",
22
+ "YNAB_BUDGET_ID",
23
+ "YNAB_ALLOW_WRITES",
24
+ ];
16
25
 
17
- let API_TOKEN = process.env.YNAB_API_TOKEN;
18
- let tokenLookupError;
19
- if (!API_TOKEN && process.env.YNAB_API_TOKEN_FILE) {
20
- try {
21
- const tokenFileContents = readFileSync(process.env.YNAB_API_TOKEN_FILE, "utf8");
22
- if (Buffer.byteLength(tokenFileContents, "utf8") > MAX_TOKEN_FILE_BYTES) {
23
- throw new Error(`token file exceeds ${MAX_TOKEN_FILE_BYTES} bytes`);
26
+ const runtimeConfig = resolveYnabRuntimeConfig();
27
+ const API_TOKEN = runtimeConfig.apiToken;
28
+ const tokenLookupError = runtimeConfig.tokenLookupError;
29
+ const DEFAULT_BUDGET_ID = runtimeConfig.values.YNAB_BUDGET_ID?.value;
30
+ if (!API_TOKEN) {
31
+ const fallbackMessage = tokenLookupError
32
+ ? ` ${tokenLookupError}.`
33
+ : " Add YNAB_API_TOKEN to the agent config file, set YNAB_API_TOKEN_FILE, or set YNAB_OP_PATH.";
34
+ console.error(`YNAB_API_TOKEN is required.${fallbackMessage} Starting MCP Server for YNAB in discovery-only mode.`);
35
+ }
36
+
37
+ const ynabRateLimit = createYnabRateLimiter();
38
+ const effectiveApiToken = API_TOKEN || "missing-token-for-tool-discovery";
39
+ const api = new ynab.API(effectiveApiToken, BASE_URL);
40
+ api._configuration.config = { accessToken: effectiveApiToken, basePath: BASE_URL, fetchApi: secureFetch };
41
+
42
+ // --- Helpers ---
43
+
44
+ function resolveYnabRuntimeConfig() {
45
+ const sources = loadYnabSettingSources();
46
+ const values = {};
47
+
48
+ for (const key of YNAB_RUNTIME_KEYS) {
49
+ const source = sources.find((candidate) => hasNonEmptyString(candidate.values[key]));
50
+ if (source) {
51
+ values[key] = {
52
+ value: source.values[key].trim(),
53
+ source: source.id,
54
+ source_label: source.label,
55
+ path: source.path,
56
+ section: source.section,
57
+ };
58
+ }
59
+ }
60
+
61
+ const lookupErrors = sources
62
+ .flatMap((source) => source.errors || [])
63
+ .filter(Boolean);
64
+
65
+ let apiToken = values.YNAB_API_TOKEN?.value;
66
+ let tokenSource = values.YNAB_API_TOKEN || null;
67
+
68
+ if (!apiToken && values.YNAB_API_TOKEN_FILE?.value) {
69
+ try {
70
+ const tokenFileContents = readFileSync(values.YNAB_API_TOKEN_FILE.value, "utf8");
71
+ if (Buffer.byteLength(tokenFileContents, "utf8") > MAX_TOKEN_FILE_BYTES) {
72
+ throw new Error(`token file exceeds ${MAX_TOKEN_FILE_BYTES} bytes`);
73
+ }
74
+ apiToken = tokenFileContents.trim();
75
+ tokenSource = {
76
+ source: "token_file",
77
+ source_label: "YNAB_API_TOKEN_FILE",
78
+ path: values.YNAB_API_TOKEN_FILE.value,
79
+ };
80
+ } catch (e) {
81
+ lookupErrors.push(`Could not read YNAB_API_TOKEN_FILE: ${e.message || String(e)}`);
24
82
  }
25
- API_TOKEN = tokenFileContents.trim();
83
+ }
84
+
85
+ if (!apiToken && values.YNAB_OP_PATH?.value) {
86
+ try {
87
+ apiToken = execFileSync(
88
+ "op", ["read", values.YNAB_OP_PATH.value],
89
+ { encoding: "utf-8", timeout: 10000, stdio: ["pipe", "pipe", "pipe"] }
90
+ ).trim();
91
+ tokenSource = {
92
+ source: "onepassword_cli",
93
+ source_label: "YNAB_OP_PATH via 1Password CLI",
94
+ };
95
+ } catch (e) {
96
+ lookupErrors.push(`Could not read YNAB_OP_PATH via 1Password CLI: ${e.stderr?.toString().trim() || e.message || "unknown 1Password CLI error"}`);
97
+ }
98
+ }
99
+
100
+ return {
101
+ apiToken,
102
+ tokenSource,
103
+ values,
104
+ sources_checked: sources.map((source) => ({
105
+ id: source.id,
106
+ label: source.label,
107
+ path: source.path,
108
+ section: source.section,
109
+ available: source.available,
110
+ keys_found: source.keysFound,
111
+ })),
112
+ config_fallback_disabled: process.env.YNAB_DISABLE_AGENT_CONFIG_FALLBACK === "1",
113
+ detected_agent: detectAgentRuntime(),
114
+ tokenLookupError: lookupErrors[lookupErrors.length - 1],
115
+ lookup_errors: lookupErrors,
116
+ };
117
+ }
118
+
119
+ function loadYnabSettingSources() {
120
+ const finalize = (sources) => sources.map((source) => ({
121
+ ...source,
122
+ keysFound: YNAB_RUNTIME_KEYS.filter((key) => hasNonEmptyString(source.values[key])),
123
+ }));
124
+
125
+ const sources = [{
126
+ id: "process_env",
127
+ label: "process environment",
128
+ path: null,
129
+ section: null,
130
+ available: true,
131
+ values: pickYnabValues(process.env),
132
+ errors: [],
133
+ }];
134
+
135
+ if (process.env.YNAB_DISABLE_AGENT_CONFIG_FALLBACK === "1") {
136
+ return finalize(sources);
137
+ }
138
+
139
+ const codexSources = loadCodexConfigSources();
140
+ const claudeSource = loadClaudeSettingsSource();
141
+ if (detectAgentRuntime() === "claude") {
142
+ sources.push(claudeSource, ...codexSources);
143
+ } else {
144
+ sources.push(...codexSources, claudeSource);
145
+ }
146
+ return finalize(sources);
147
+ }
148
+
149
+ function loadCodexConfigSources() {
150
+ const configPath = path.join(userHomeDir(), ".codex", "config.toml");
151
+ const emptySources = [
152
+ configSource("codex_shell_environment", "Codex shell_environment_policy.set", configPath, "shell_environment_policy.set"),
153
+ configSource("codex_mcp_env", "Codex mcp_servers.ynab.env", configPath, "mcp_servers.ynab.env"),
154
+ ];
155
+
156
+ let text;
157
+ try {
158
+ text = readFileSync(configPath, "utf8");
26
159
  } catch (e) {
27
- tokenLookupError = `Could not read YNAB_API_TOKEN_FILE: ${e.message || String(e)}`;
160
+ return emptySources.map((source) => ({
161
+ ...source,
162
+ available: false,
163
+ errors: isMissingFileError(e) ? [] : [`Could not read ${configPath}: ${e.message || String(e)}`],
164
+ }));
28
165
  }
166
+
167
+ const sections = parseSimpleTomlSections(text);
168
+ return emptySources.map((source) => ({
169
+ ...source,
170
+ available: true,
171
+ values: pickYnabValues(sections[source.section] || {}),
172
+ errors: [],
173
+ }));
29
174
  }
30
- if (!API_TOKEN && process.env.YNAB_OP_PATH) {
175
+
176
+ function loadClaudeSettingsSource() {
177
+ const settingsPath = path.join(userHomeDir(), ".claude", "settings.json");
178
+ const source = configSource("claude_settings_env", "Claude settings.json env", settingsPath, "env");
179
+
180
+ let settings;
31
181
  try {
32
- API_TOKEN = execFileSync(
33
- "op", ["read", process.env.YNAB_OP_PATH],
34
- { encoding: "utf-8", timeout: 10000, stdio: ["pipe", "pipe", "pipe"] }
35
- ).trim();
182
+ settings = JSON.parse(readFileSync(settingsPath, "utf8"));
36
183
  } catch (e) {
37
- tokenLookupError = `Could not read YNAB_OP_PATH via 1Password CLI: ${e.stderr?.toString().trim() || e.message || "unknown 1Password CLI error"}`;
184
+ return {
185
+ ...source,
186
+ available: false,
187
+ errors: isMissingFileError(e) ? [] : [`Could not read ${settingsPath}: ${e.message || String(e)}`],
188
+ };
38
189
  }
190
+
191
+ return {
192
+ ...source,
193
+ available: true,
194
+ values: pickYnabValues(settings?.env || {}),
195
+ errors: [],
196
+ };
39
197
  }
40
- if (!API_TOKEN) {
41
- const fallbackMessage = tokenLookupError
42
- ? ` ${tokenLookupError}.`
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);
198
+
199
+ function configSource(id, label, filePath, section) {
200
+ return {
201
+ id,
202
+ label,
203
+ path: filePath,
204
+ section,
205
+ available: false,
206
+ values: {},
207
+ errors: [],
208
+ };
46
209
  }
47
210
 
48
- 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 };
51
- const DEFAULT_BUDGET_ID = process.env.YNAB_BUDGET_ID;
211
+ function pickYnabValues(source) {
212
+ return Object.fromEntries(
213
+ YNAB_RUNTIME_KEYS
214
+ .filter((key) => hasNonEmptyString(source?.[key]))
215
+ .map((key) => [key, String(source[key])])
216
+ );
217
+ }
52
218
 
53
- // --- Helpers ---
219
+ function parseSimpleTomlSections(text) {
220
+ const sections = {};
221
+ let currentSection = "";
222
+
223
+ for (const rawLine of text.split(/\r?\n/)) {
224
+ const line = rawLine.trim();
225
+ if (!line || line.startsWith("#")) continue;
226
+
227
+ const sectionMatch = line.match(/^\[([^\]]+)\]$/);
228
+ if (sectionMatch) {
229
+ currentSection = sectionMatch[1].trim();
230
+ sections[currentSection] ||= {};
231
+ continue;
232
+ }
233
+
234
+ const assignmentMatch = line.match(/^([A-Za-z0-9_]+)\s*=\s*(.+)$/);
235
+ if (!assignmentMatch || !currentSection) continue;
236
+
237
+ const [, key, rawValue] = assignmentMatch;
238
+ const value = parseSimpleTomlString(rawValue);
239
+ if (value !== undefined) {
240
+ sections[currentSection] ||= {};
241
+ sections[currentSection][key] = value;
242
+ }
243
+ }
244
+
245
+ return sections;
246
+ }
247
+
248
+ function parseSimpleTomlString(rawValue) {
249
+ const value = stripTomlComment(rawValue).trim();
250
+ if (!value) return undefined;
251
+ if (value.startsWith("\"") && value.endsWith("\"")) {
252
+ try {
253
+ return JSON.parse(value);
254
+ } catch {
255
+ return value.slice(1, -1);
256
+ }
257
+ }
258
+ if (value.startsWith("'") && value.endsWith("'")) {
259
+ return value.slice(1, -1);
260
+ }
261
+ return value;
262
+ }
263
+
264
+ function stripTomlComment(value) {
265
+ let quote = null;
266
+ let escaped = false;
267
+ for (let i = 0; i < value.length; i += 1) {
268
+ const char = value[i];
269
+ if (escaped) {
270
+ escaped = false;
271
+ continue;
272
+ }
273
+ if (char === "\\") {
274
+ escaped = true;
275
+ continue;
276
+ }
277
+ if (quote) {
278
+ if (char === quote) quote = null;
279
+ continue;
280
+ }
281
+ if (char === "\"" || char === "'") {
282
+ quote = char;
283
+ continue;
284
+ }
285
+ if (char === "#") {
286
+ return value.slice(0, i);
287
+ }
288
+ }
289
+ return value;
290
+ }
291
+
292
+ function userHomeDir() {
293
+ return process.env.HOME || homedir();
294
+ }
295
+
296
+ function hasNonEmptyString(value) {
297
+ return typeof value === "string" && value.trim().length > 0;
298
+ }
299
+
300
+ function isMissingFileError(error) {
301
+ return error?.code === "ENOENT" || error?.code === "ENOTDIR";
302
+ }
303
+
304
+ function detectAgentRuntime() {
305
+ if (Object.keys(process.env).some((key) => key.startsWith("CODEX_"))) return "codex";
306
+ if (Object.keys(process.env).some((key) => key.startsWith("CLAUDE_"))) return "claude";
307
+ return "unknown";
308
+ }
309
+
310
+ function publicYnabRuntimeSettings() {
311
+ return Object.fromEntries(
312
+ Object.entries(runtimeConfig.values)
313
+ .filter(([key]) => key !== "YNAB_API_TOKEN")
314
+ .map(([key, entry]) => [key, {
315
+ configured: true,
316
+ source: entry.source,
317
+ source_label: entry.source_label,
318
+ }])
319
+ );
320
+ }
321
+
322
+ function ynabAuthSetupGuide() {
323
+ const agent = runtimeConfig.detected_agent;
324
+ const codexPath = path.join(userHomeDir(), ".codex", "config.toml");
325
+ const claudePath = path.join(userHomeDir(), ".claude", "settings.json");
326
+ const shouldShowCodex = agent === "codex" || agent === "unknown";
327
+ const shouldShowClaude = agent === "claude" || agent === "unknown";
328
+ const manualTargets = [];
329
+ const passwordManagerTargets = [];
330
+
331
+ if (shouldShowCodex) {
332
+ manualTargets.push({
333
+ agent: "codex",
334
+ path: codexPath,
335
+ section: "shell_environment_policy.set",
336
+ snippet: "[shell_environment_policy.set]\nYNAB_API_TOKEN = \"your-token-here\"",
337
+ });
338
+ passwordManagerTargets.push({
339
+ agent: "codex",
340
+ path: codexPath,
341
+ section: "shell_environment_policy.set",
342
+ snippet: "[shell_environment_policy.set]\nYNAB_OP_PATH = \"op://Personal/YNAB API Token/credential\"",
343
+ note: "Ask the user for permission before editing this config, and confirm the 1Password item path they want to use.",
344
+ });
345
+ }
346
+
347
+ if (shouldShowClaude) {
348
+ manualTargets.push({
349
+ agent: "claude",
350
+ path: claudePath,
351
+ section: "env",
352
+ snippet: "{\n \"env\": {\n \"YNAB_API_TOKEN\": \"your-token-here\"\n }\n}",
353
+ });
354
+ passwordManagerTargets.push({
355
+ agent: "claude",
356
+ path: claudePath,
357
+ section: "env",
358
+ snippet: "{\n \"env\": {\n \"YNAB_OP_PATH\": \"op://Personal/YNAB API Token/credential\"\n }\n}",
359
+ note: "Ask the user for permission before editing this config, and confirm the 1Password item path they want to use.",
360
+ });
361
+ }
362
+
363
+ return {
364
+ prompt_for_agent: "Ask the user whether they already have a YNAB API key in a password manager such as 1Password. If yes, ask permission to configure YNAB_OP_PATH for this agent. If no, ask them to add YNAB_API_TOKEN to the correct agent config file, then restart the MCP server.",
365
+ detected_agent: agent,
366
+ manual_api_key_targets: manualTargets,
367
+ password_manager_targets: passwordManagerTargets,
368
+ token_file_option: "Alternatively, set YNAB_API_TOKEN_FILE to a small local file containing only the token.",
369
+ restart_required: true,
370
+ };
371
+ }
54
372
 
55
373
  function resolveBudgetId(input) {
56
374
  return input || DEFAULT_BUDGET_ID || "last-used";
@@ -225,7 +543,49 @@ function collection(data, key, items, lastKnowledgeOfServer) {
225
543
  : { [key]: items, server_knowledge: data.server_knowledge };
226
544
  }
227
545
 
546
+ function pathSegment(value) {
547
+ return encodeURIComponent(String(value));
548
+ }
549
+
550
+ function allHistorySinceDate() {
551
+ return "1970-01-01";
552
+ }
553
+
554
+ function buildTransactionListPath({ budgetId, accountId, categoryId, payeeId, month }) {
555
+ const bid = pathSegment(resolveBudgetId(budgetId));
556
+ if (accountId) return `/plans/${bid}/accounts/${pathSegment(accountId)}/transactions`;
557
+ if (categoryId) return `/plans/${bid}/categories/${pathSegment(categoryId)}/transactions`;
558
+ if (payeeId) return `/plans/${bid}/payees/${pathSegment(payeeId)}/transactions`;
559
+ if (month) return `/plans/${bid}/months/${pathSegment(month)}/transactions`;
560
+ return `/plans/${bid}/transactions`;
561
+ }
562
+
563
+ async function fetchTransactions({
564
+ budgetId,
565
+ sinceDate,
566
+ untilDate,
567
+ type,
568
+ accountId,
569
+ categoryId,
570
+ payeeId,
571
+ month,
572
+ lastKnowledgeOfServer,
573
+ }) {
574
+ return ynabFetch(buildTransactionListPath({ budgetId, accountId, categoryId, payeeId, month }), {
575
+ query: {
576
+ since_date: sinceDate,
577
+ until_date: untilDate,
578
+ type,
579
+ last_knowledge_of_server: lastKnowledgeOfServer,
580
+ },
581
+ });
582
+ }
583
+
228
584
  async function run(fn) {
585
+ if (!API_TOKEN) {
586
+ return missingCredentialsResult();
587
+ }
588
+
229
589
  try {
230
590
  return await fn();
231
591
  } catch (e) {
@@ -238,6 +598,17 @@ async function run(fn) {
238
598
  }
239
599
  }
240
600
 
601
+ function missingCredentialsResult() {
602
+ return {
603
+ content: [{ type: "text", text: JSON.stringify({
604
+ error: "missing_credentials",
605
+ message: "YNAB API credentials are not configured for this MCP server process.",
606
+ auth: ynabAuthStatus(),
607
+ }, null, 2) }],
608
+ isError: true,
609
+ };
610
+ }
611
+
241
612
  function sanitizeErrorMessage(value) {
242
613
  let message = String(value ?? "");
243
614
  if (API_TOKEN) {
@@ -320,7 +691,7 @@ async function ynabFetch(path, { method = "GET", body, query } = {}) {
320
691
  }
321
692
  const opts = {
322
693
  method,
323
- headers: { Authorization: `Bearer ${API_TOKEN}`, "Content-Type": "application/json" },
694
+ headers: { Authorization: `Bearer ${effectiveApiToken}`, "Content-Type": "application/json" },
324
695
  };
325
696
  if (body) opts.body = JSON.stringify(body);
326
697
  const res = await secureFetch(url, opts);
@@ -344,10 +715,46 @@ async function ynabFetch(path, { method = "GET", body, query } = {}) {
344
715
  // --- Server ---
345
716
 
346
717
  const server = new McpServer({
347
- name: "ynab-mcp-server",
348
- version: "2.1.0",
718
+ name: "mcp-server-for-ynab",
719
+ version: "3.0.0",
349
720
  });
350
721
 
722
+ const registeredTools = new Map();
723
+ const toolCatalog = new Map();
724
+
725
+ function registerTool(name, config, handler) {
726
+ const registration = server.registerTool(name, config, handler);
727
+ toolCatalog.set(name, { config });
728
+ if (registration !== undefined) {
729
+ registeredTools.set(name, { config, handler });
730
+ }
731
+ return registration;
732
+ }
733
+
734
+ function listRegisteredYnabTools() {
735
+ return [...toolCatalog.entries()]
736
+ .filter(([name]) => !name.startsWith("ynab_"))
737
+ .map(([name, { config }]) => {
738
+ const writeMetadata = WRITE_TOOL_METADATA[name];
739
+ const isWrite = !!writeMetadata;
740
+ let status = "available";
741
+ if (isWrite && !writesEnabled()) {
742
+ status = "hidden_requires_YNAB_ALLOW_WRITES_1";
743
+ } else if (!API_TOKEN) {
744
+ status = "discoverable_requires_credentials";
745
+ }
746
+ return {
747
+ name,
748
+ title: config?.title ?? name,
749
+ description: isWrite ? withWriteGateDescription(config?.description ?? "") : config?.description ?? "",
750
+ has_input_schema: !!config?.inputSchema,
751
+ is_write: isWrite,
752
+ registered: registeredTools.has(name),
753
+ status,
754
+ };
755
+ });
756
+ }
757
+
351
758
  const WRITE_TOOL_METADATA = {
352
759
  create_account: { destructiveHint: false, idempotentHint: false },
353
760
  update_month_category: { destructiveHint: false, idempotentHint: true },
@@ -364,6 +771,7 @@ const WRITE_TOOL_METADATA = {
364
771
  update_transactions: { destructiveHint: false, idempotentHint: true },
365
772
  approve_transactions: { destructiveHint: false, idempotentHint: true },
366
773
  reassign_payee_transactions: { destructiveHint: false, idempotentHint: true },
774
+ ynab_write_tool_execute: { destructiveHint: false, idempotentHint: false },
367
775
  import_transactions: { destructiveHint: false, idempotentHint: false },
368
776
  create_scheduled_transaction: { destructiveHint: false, idempotentHint: false },
369
777
  update_scheduled_transaction: { destructiveHint: false, idempotentHint: true },
@@ -371,7 +779,29 @@ const WRITE_TOOL_METADATA = {
371
779
  };
372
780
 
373
781
  function writesEnabled() {
374
- return process.env.YNAB_ALLOW_WRITES === "1";
782
+ return runtimeConfig.values.YNAB_ALLOW_WRITES?.value === "1";
783
+ }
784
+
785
+ function ynabAuthStatus() {
786
+ const authenticated = !!API_TOKEN;
787
+ const writeToolsAvailable = authenticated && writesEnabled();
788
+ return {
789
+ authenticated,
790
+ default_budget_id_configured: !!DEFAULT_BUDGET_ID,
791
+ writes_enabled: writesEnabled(),
792
+ write_tools_available: writeToolsAvailable,
793
+ credential_source: runtimeConfig.tokenSource?.source ?? null,
794
+ credential_source_label: runtimeConfig.tokenSource?.source_label ?? null,
795
+ detected_agent: runtimeConfig.detected_agent,
796
+ config_fallback_disabled: runtimeConfig.config_fallback_disabled,
797
+ configured_settings: publicYnabRuntimeSettings(),
798
+ credential_sources_checked: runtimeConfig.sources_checked,
799
+ lookup_error: authenticated ? null : tokenLookupError ?? null,
800
+ setup: authenticated ? null : ynabAuthSetupGuide(),
801
+ message: authenticated
802
+ ? "MCP Server for YNAB has an API token configured."
803
+ : "MCP Server for YNAB is running in discovery-only mode. Check setup.prompt_for_agent for the safe credential setup flow, then restart the MCP server before calling API tools.",
804
+ };
375
805
  }
376
806
 
377
807
  function writeDisabledResult(name) {
@@ -428,7 +858,7 @@ server.registerTool = (name, config, handler) => {
428
858
 
429
859
  // ==================== User & Budgets ====================
430
860
 
431
- server.registerTool(
861
+ registerTool(
432
862
  "get_user",
433
863
  { description: "Get the authenticated user" },
434
864
  () =>
@@ -438,29 +868,31 @@ server.registerTool(
438
868
  })
439
869
  );
440
870
 
441
- server.registerTool(
871
+ registerTool(
442
872
  "list_budgets",
443
873
  { description: "List all budgets. Use a budget ID from the results in other tools, or omit budgetId to use the last-used budget." },
444
874
  () =>
445
875
  run(async () => {
446
- const { data } = await api.budgets.getBudgets();
876
+ const { data } = await api.plans.getPlans();
877
+ const plans = data.plans || data.budgets || [];
878
+ const defaultPlan = data.default_plan || data.default_budget;
447
879
  const result = {
448
- 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 })),
880
+ budgets: plans.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 })),
449
881
  };
450
- if (data.default_budget) {
451
- result.default_budget = { id: data.default_budget.id, name: data.default_budget.name };
882
+ if (defaultPlan) {
883
+ result.default_budget = { id: defaultPlan.id, name: defaultPlan.name };
452
884
  }
453
885
  return ok(result);
454
886
  })
455
887
  );
456
888
 
457
- server.registerTool(
889
+ registerTool(
458
890
  "get_budget",
459
891
  { 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
892
  ({ budgetId }) =>
461
893
  run(async () => {
462
- const { data } = await api.budgets.getBudgetById(resolveBudgetId(budgetId));
463
- const b = data.budget;
894
+ const { data } = await api.plans.getPlanById(resolveBudgetId(budgetId));
895
+ const b = data.plan || data.budget;
464
896
  return ok({
465
897
  id: b.id,
466
898
  name: b.name,
@@ -476,12 +908,12 @@ server.registerTool(
476
908
  })
477
909
  );
478
910
 
479
- server.registerTool(
911
+ registerTool(
480
912
  "get_budget_settings",
481
913
  { description: "Get budget settings (currency format, date format)", inputSchema: { budgetId: z.string().optional().describe("Budget ID (uses default if not provided)") } },
482
914
  ({ budgetId }) =>
483
915
  run(async () => {
484
- const { data } = await api.budgets.getBudgetSettingsById(resolveBudgetId(budgetId));
916
+ const { data } = await api.plans.getPlanSettingsById(resolveBudgetId(budgetId));
485
917
  return ok(data.settings);
486
918
  })
487
919
  );
@@ -512,7 +944,7 @@ function formatAccount(a) {
512
944
  return withCurrencyFields(out, a, ["balance", "cleared_balance", "uncleared_balance"]);
513
945
  }
514
946
 
515
- server.registerTool(
947
+ registerTool(
516
948
  "list_accounts",
517
949
  { description: "List all accounts in a budget", inputSchema: {
518
950
  budgetId: z.string().optional().describe("Budget ID (uses default if not provided)"),
@@ -526,7 +958,7 @@ server.registerTool(
526
958
  })
527
959
  );
528
960
 
529
- server.registerTool(
961
+ registerTool(
530
962
  "get_account",
531
963
  { description: "Get details for a specific account", inputSchema: {
532
964
  budgetId: z.string().optional().describe("Budget ID (uses default if not provided)"),
@@ -539,7 +971,7 @@ server.registerTool(
539
971
  })
540
972
  );
541
973
 
542
- server.registerTool(
974
+ registerTool(
543
975
  "create_account",
544
976
  { description: "Create a new account", inputSchema: {
545
977
  budgetId: z.string().optional().describe("Budget ID (uses default if not provided)"),
@@ -598,7 +1030,7 @@ function formatCategory(c) {
598
1030
  ]);
599
1031
  }
600
1032
 
601
- server.registerTool(
1033
+ registerTool(
602
1034
  "list_categories",
603
1035
  { description: "List all category groups and their categories", inputSchema: {
604
1036
  budgetId: z.string().optional().describe("Budget ID (uses default if not provided)"),
@@ -634,7 +1066,7 @@ server.registerTool(
634
1066
  })
635
1067
  );
636
1068
 
637
- server.registerTool(
1069
+ registerTool(
638
1070
  "get_category",
639
1071
  { description: "Get a specific category", inputSchema: {
640
1072
  budgetId: z.string().optional().describe("Budget ID (uses default if not provided)"),
@@ -647,7 +1079,7 @@ server.registerTool(
647
1079
  })
648
1080
  );
649
1081
 
650
- server.registerTool(
1082
+ registerTool(
651
1083
  "get_month_category",
652
1084
  { description: "Get category budget for a specific month", inputSchema: {
653
1085
  budgetId: z.string().optional().describe("Budget ID (uses default if not provided)"),
@@ -661,7 +1093,7 @@ server.registerTool(
661
1093
  })
662
1094
  );
663
1095
 
664
- server.registerTool(
1096
+ registerTool(
665
1097
  "update_month_category",
666
1098
  { description: "Set the budgeted amount for a category in a specific month", inputSchema: {
667
1099
  budgetId: z.string().optional().describe("Budget ID (uses default if not provided)"),
@@ -678,7 +1110,7 @@ server.registerTool(
678
1110
  })
679
1111
  );
680
1112
 
681
- server.registerTool(
1113
+ registerTool(
682
1114
  "update_category",
683
1115
  { description: "Update a category's name, note, goal target, or move it to a different group", inputSchema: {
684
1116
  budgetId: z.string().optional().describe("Budget ID (uses default if not provided)"),
@@ -708,7 +1140,7 @@ server.registerTool(
708
1140
  })
709
1141
  );
710
1142
 
711
- server.registerTool(
1143
+ registerTool(
712
1144
  "create_category",
713
1145
  { description: "Create a new category in a category group", inputSchema: {
714
1146
  budgetId: z.string().optional().describe("Budget ID (uses default if not provided)"),
@@ -735,7 +1167,7 @@ server.registerTool(
735
1167
  })
736
1168
  );
737
1169
 
738
- server.registerTool(
1170
+ registerTool(
739
1171
  "create_category_group",
740
1172
  { description: "Create a new category group", inputSchema: {
741
1173
  budgetId: z.string().optional().describe("Budget ID (uses default if not provided)"),
@@ -751,7 +1183,7 @@ server.registerTool(
751
1183
  })
752
1184
  );
753
1185
 
754
- server.registerTool(
1186
+ registerTool(
755
1187
  "update_category_group",
756
1188
  { description: "Rename a category group", inputSchema: {
757
1189
  budgetId: z.string().optional().describe("Budget ID (uses default if not provided)"),
@@ -770,7 +1202,7 @@ server.registerTool(
770
1202
 
771
1203
  // ==================== Payees ====================
772
1204
 
773
- server.registerTool(
1205
+ registerTool(
774
1206
  "list_payees",
775
1207
  { description: "List all payees", inputSchema: {
776
1208
  budgetId: z.string().optional().describe("Budget ID (uses default if not provided)"),
@@ -784,7 +1216,7 @@ server.registerTool(
784
1216
  })
785
1217
  );
786
1218
 
787
- server.registerTool(
1219
+ registerTool(
788
1220
  "get_payee",
789
1221
  { description: "Get a specific payee", inputSchema: {
790
1222
  budgetId: z.string().optional().describe("Budget ID (uses default if not provided)"),
@@ -797,7 +1229,7 @@ server.registerTool(
797
1229
  })
798
1230
  );
799
1231
 
800
- server.registerTool(
1232
+ registerTool(
801
1233
  "update_payee",
802
1234
  { description: "Rename a payee", inputSchema: {
803
1235
  budgetId: z.string().optional().describe("Budget ID (uses default if not provided)"),
@@ -813,7 +1245,7 @@ server.registerTool(
813
1245
  })
814
1246
  );
815
1247
 
816
- server.registerTool(
1248
+ registerTool(
817
1249
  "create_payee",
818
1250
  { description: "Create a new payee", inputSchema: {
819
1251
  budgetId: z.string().optional().describe("Budget ID (uses default if not provided)"),
@@ -831,7 +1263,7 @@ server.registerTool(
831
1263
 
832
1264
  // ==================== Payee Locations ====================
833
1265
 
834
- server.registerTool(
1266
+ registerTool(
835
1267
  "list_payee_locations",
836
1268
  { description: "List all payee locations (GPS coordinates where transactions occurred)", inputSchema: { budgetId: z.string().optional().describe("Budget ID (uses default if not provided)") } },
837
1269
  ({ budgetId }) =>
@@ -841,7 +1273,7 @@ server.registerTool(
841
1273
  })
842
1274
  );
843
1275
 
844
- server.registerTool(
1276
+ registerTool(
845
1277
  "get_payee_location",
846
1278
  { description: "Get a specific payee location", inputSchema: {
847
1279
  budgetId: z.string().optional().describe("Budget ID (uses default if not provided)"),
@@ -854,7 +1286,7 @@ server.registerTool(
854
1286
  })
855
1287
  );
856
1288
 
857
- server.registerTool(
1289
+ registerTool(
858
1290
  "get_payee_locations_by_payee",
859
1291
  { description: "Get all locations for a specific payee", inputSchema: {
860
1292
  budgetId: z.string().optional().describe("Budget ID (uses default if not provided)"),
@@ -869,7 +1301,7 @@ server.registerTool(
869
1301
 
870
1302
  // ==================== Months ====================
871
1303
 
872
- server.registerTool(
1304
+ registerTool(
873
1305
  "list_months",
874
1306
  { description: "List all budget months", inputSchema: {
875
1307
  budgetId: z.string().optional().describe("Budget ID (uses default if not provided)"),
@@ -877,7 +1309,7 @@ server.registerTool(
877
1309
  } },
878
1310
  ({ budgetId, lastKnowledgeOfServer }) =>
879
1311
  run(async () => {
880
- const { data } = await api.months.getBudgetMonths(resolveBudgetId(budgetId), lastKnowledgeOfServer);
1312
+ const { data } = await api.months.getPlanMonths(resolveBudgetId(budgetId), lastKnowledgeOfServer);
881
1313
  const months = data.months.map((m) =>
882
1314
  withCurrencyFields(
883
1315
  {
@@ -898,7 +1330,7 @@ server.registerTool(
898
1330
  })
899
1331
  );
900
1332
 
901
- server.registerTool(
1333
+ registerTool(
902
1334
  "get_month",
903
1335
  { description: "Get budget month detail with per-category breakdown", inputSchema: {
904
1336
  budgetId: z.string().optional().describe("Budget ID (uses default if not provided)"),
@@ -906,7 +1338,7 @@ server.registerTool(
906
1338
  } },
907
1339
  ({ budgetId, month }) =>
908
1340
  run(async () => {
909
- const { data } = await api.months.getBudgetMonth(resolveBudgetId(budgetId), month);
1341
+ const { data } = await api.months.getPlanMonth(resolveBudgetId(budgetId), month);
910
1342
  const m = data.month;
911
1343
  const out = {
912
1344
  month: m.month,
@@ -962,7 +1394,7 @@ function formatMoneyMovement(m) {
962
1394
  }, m, ["amount"]);
963
1395
  }
964
1396
 
965
- server.registerTool(
1397
+ registerTool(
966
1398
  "list_money_movements",
967
1399
  { description: "List all money movements (budget re-allocations between categories)", inputSchema: { budgetId: z.string().optional().describe("Budget ID (uses default if not provided)") } },
968
1400
  ({ budgetId }) =>
@@ -972,7 +1404,7 @@ server.registerTool(
972
1404
  })
973
1405
  );
974
1406
 
975
- server.registerTool(
1407
+ registerTool(
976
1408
  "get_money_movements_by_month",
977
1409
  { description: "Get money movements for a specific month", inputSchema: {
978
1410
  budgetId: z.string().optional().describe("Budget ID (uses default if not provided)"),
@@ -985,7 +1417,7 @@ server.registerTool(
985
1417
  })
986
1418
  );
987
1419
 
988
- server.registerTool(
1420
+ registerTool(
989
1421
  "list_money_movement_groups",
990
1422
  { 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
1423
  ({ budgetId }) =>
@@ -995,7 +1427,7 @@ server.registerTool(
995
1427
  })
996
1428
  );
997
1429
 
998
- server.registerTool(
1430
+ registerTool(
999
1431
  "get_money_movement_groups_by_month",
1000
1432
  { description: "Get money movement groups for a specific month", inputSchema: {
1001
1433
  budgetId: z.string().optional().describe("Budget ID (uses default if not provided)"),
@@ -1057,11 +1489,12 @@ function formatTransaction(t) {
1057
1489
  return withCurrencyFields(out, t, ["amount"]);
1058
1490
  }
1059
1491
 
1060
- server.registerTool(
1492
+ registerTool(
1061
1493
  "get_transactions",
1062
- { 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: {
1494
+ { 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. YNAB now defaults omitted sinceDate to one year ago; pass an explicit older sinceDate to retrieve older history. Note: large date ranges (6+ months on a busy budget) can return 50KB+ of data; narrow with categoryId/payeeId/month/sinceDate/untilDate filters when possible.", inputSchema: {
1063
1495
  budgetId: z.string().optional().describe("Budget ID (uses default if not provided)"),
1064
- sinceDate: z.string().optional().describe("Only return transactions on or after this date (YYYY-MM-DD)"),
1496
+ sinceDate: z.string().optional().describe("Only return transactions on or after this date (YYYY-MM-DD). If omitted, YNAB defaults to one year ago."),
1497
+ untilDate: z.string().optional().describe("Only return transactions on or before this date (YYYY-MM-DD)"),
1065
1498
  type: z.enum(["unapproved", "uncategorized"]).optional().describe("Filter by approval/categorization status"),
1066
1499
  accountId: z.string().optional().describe("Filter by account ID"),
1067
1500
  categoryId: z.string().optional().describe("Filter by category ID"),
@@ -1069,38 +1502,30 @@ server.registerTool(
1069
1502
  month: z.string().optional().describe("Filter by month (YYYY-MM-DD, first of month)"),
1070
1503
  lastKnowledgeOfServer: z.number().int().nonnegative().optional().describe("Delta request server knowledge. When provided, returns { transactions, server_knowledge }."),
1071
1504
  } },
1072
- ({ budgetId, sinceDate, type, accountId, categoryId, payeeId, month, lastKnowledgeOfServer }) =>
1505
+ ({ budgetId, sinceDate, untilDate, type, accountId, categoryId, payeeId, month, lastKnowledgeOfServer }) =>
1073
1506
  run(async () => {
1074
- const bid = resolveBudgetId(budgetId);
1075
- let transactions;
1076
- let data;
1077
1507
  const resourceFilters = [accountId, categoryId, payeeId, month].filter((value) => value !== undefined && value !== null && value !== "");
1078
1508
  if (resourceFilters.length > 1) {
1079
1509
  throw new Error("Provide only one of accountId, categoryId, payeeId, or month.");
1080
1510
  }
1081
1511
 
1082
- if (accountId) {
1083
- ({ data } = await api.transactions.getTransactionsByAccount(bid, accountId, sinceDate, type, lastKnowledgeOfServer));
1084
- transactions = data.transactions;
1085
- } else if (categoryId) {
1086
- ({ data } = await api.transactions.getTransactionsByCategory(bid, categoryId, sinceDate, type, lastKnowledgeOfServer));
1087
- transactions = data.transactions;
1088
- } else if (payeeId) {
1089
- ({ data } = await api.transactions.getTransactionsByPayee(bid, payeeId, sinceDate, type, lastKnowledgeOfServer));
1090
- transactions = data.transactions;
1091
- } else if (month) {
1092
- ({ data } = await api.transactions.getTransactionsByMonth(bid, month, sinceDate, type, lastKnowledgeOfServer));
1093
- transactions = data.transactions;
1094
- } else {
1095
- ({ data } = await api.transactions.getTransactions(bid, sinceDate, type, lastKnowledgeOfServer));
1096
- transactions = data.transactions;
1097
- }
1098
-
1512
+ const data = await fetchTransactions({
1513
+ budgetId,
1514
+ sinceDate,
1515
+ untilDate,
1516
+ type,
1517
+ accountId,
1518
+ categoryId,
1519
+ payeeId,
1520
+ month,
1521
+ lastKnowledgeOfServer,
1522
+ });
1523
+ const transactions = data.transactions;
1099
1524
  return ok(collection(data, "transactions", transactions.map(formatTransaction), lastKnowledgeOfServer));
1100
1525
  })
1101
1526
  );
1102
1527
 
1103
- server.registerTool(
1528
+ registerTool(
1104
1529
  "get_transaction",
1105
1530
  { 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
1531
  budgetId: z.string().optional().describe("Budget ID (uses default if not provided)"),
@@ -1140,7 +1565,7 @@ server.registerTool(
1140
1565
  })
1141
1566
  );
1142
1567
 
1143
- server.registerTool(
1568
+ registerTool(
1144
1569
  "create_transaction",
1145
1570
  { 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
1571
  budgetId: z.string().optional().describe("Budget ID (uses default if not provided)"),
@@ -1172,7 +1597,7 @@ server.registerTool(
1172
1597
  })
1173
1598
  );
1174
1599
 
1175
- server.registerTool(
1600
+ registerTool(
1176
1601
  "create_transactions",
1177
1602
  { 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
1603
  budgetId: z.string().optional().describe("Budget ID (uses default if not provided)"),
@@ -1209,7 +1634,7 @@ server.registerTool(
1209
1634
  })
1210
1635
  );
1211
1636
 
1212
- server.registerTool(
1637
+ registerTool(
1213
1638
  "update_transaction",
1214
1639
  { description: "Update an existing transaction. Only provided fields are changed. Amounts in dollars.", inputSchema: {
1215
1640
  budgetId: z.string().optional().describe("Budget ID (uses default if not provided)"),
@@ -1234,7 +1659,7 @@ server.registerTool(
1234
1659
  })
1235
1660
  );
1236
1661
 
1237
- server.registerTool(
1662
+ registerTool(
1238
1663
  "delete_transaction",
1239
1664
  { description: "Delete a transaction", inputSchema: {
1240
1665
  budgetId: z.string().optional().describe("Budget ID (uses default if not provided)"),
@@ -1247,7 +1672,7 @@ server.registerTool(
1247
1672
  })
1248
1673
  );
1249
1674
 
1250
- server.registerTool(
1675
+ registerTool(
1251
1676
  "update_transactions",
1252
1677
  { 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
1678
  budgetId: z.string().optional().describe("Budget ID (uses default if not provided)"),
@@ -1307,19 +1732,28 @@ server.registerTool(
1307
1732
  })
1308
1733
  );
1309
1734
 
1310
- server.registerTool(
1735
+ registerTool(
1311
1736
  "approve_transactions",
1312
- { 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: {
1737
+ { 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. Requires confirmed:true after explicit user confirmation.", inputSchema: {
1313
1738
  budgetId: z.string().optional().describe("Budget ID (uses default if not provided)"),
1739
+ confirmed: z.literal(true).describe("Required. Pass true only after the user explicitly confirms this approval action."),
1740
+ expectedMatchedCount: z.number().int().nonnegative().optional().describe("Optional safety check. If provided and the current match count differs, no transactions are approved."),
1741
+ sinceDate: z.string().optional().describe("Only inspect unapproved transactions on or after this date (YYYY-MM-DD). Defaults to all history."),
1742
+ untilDate: z.string().optional().describe("Only inspect unapproved transactions on or before this date (YYYY-MM-DD)."),
1314
1743
  payeeId: z.string().optional().describe("Only approve unapproved transactions for this payee"),
1315
1744
  categoryId: z.string().optional().describe("Only approve unapproved transactions in this category"),
1316
1745
  accountId: z.string().optional().describe("Only approve unapproved transactions in this account"),
1317
1746
  includeUncategorized: z.boolean().optional().describe("If true, also approve transactions with no category (default false — uncategorized are skipped for safety)"),
1318
1747
  } },
1319
- ({ budgetId, payeeId, categoryId, accountId, includeUncategorized }) =>
1748
+ ({ budgetId, expectedMatchedCount, sinceDate, untilDate, payeeId, categoryId, accountId, includeUncategorized }) =>
1320
1749
  run(async () => {
1321
1750
  const bid = resolveBudgetId(budgetId);
1322
- const { data } = await api.transactions.getTransactions(bid, undefined, "unapproved");
1751
+ const data = await fetchTransactions({
1752
+ budgetId: bid,
1753
+ sinceDate: sinceDate || allHistorySinceDate(),
1754
+ untilDate,
1755
+ type: "unapproved",
1756
+ });
1323
1757
  let txns = data.transactions.filter((t) => !t.deleted);
1324
1758
  if (payeeId) txns = txns.filter((t) => t.payee_id === payeeId);
1325
1759
  if (categoryId) txns = txns.filter((t) => t.category_id === categoryId);
@@ -1327,6 +1761,9 @@ server.registerTool(
1327
1761
  if (!includeUncategorized) {
1328
1762
  txns = txns.filter((t) => (t.category_id && t.category_name !== "Uncategorized") || t.transfer_account_id);
1329
1763
  }
1764
+ if (expectedMatchedCount !== undefined && expectedMatchedCount !== txns.length) {
1765
+ throw new Error(`approve_transactions matched ${txns.length} transactions, but expectedMatchedCount was ${expectedMatchedCount}; no transactions were approved.`);
1766
+ }
1330
1767
  if (txns.length === 0) {
1331
1768
  return ok({ approved_count: 0, matched: 0, message: "No matching unapproved transactions to approve." });
1332
1769
  }
@@ -1350,19 +1787,30 @@ server.registerTool(
1350
1787
  })
1351
1788
  );
1352
1789
 
1353
- server.registerTool(
1790
+ registerTool(
1354
1791
  "reassign_payee_transactions",
1355
- { 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: {
1792
+ { 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. Requires confirmed:true after explicit user confirmation.", inputSchema: {
1356
1793
  budgetId: z.string().optional().describe("Budget ID (uses default if not provided)"),
1794
+ confirmed: z.literal(true).describe("Required. Pass true only after the user explicitly confirms this payee reassignment."),
1795
+ expectedMatchedCount: z.number().int().nonnegative().optional().describe("Optional safety check. If provided and the current match count differs, no transactions are reassigned."),
1357
1796
  fromPayeeId: z.string().describe("Payee whose transactions will be moved"),
1358
1797
  toPayeeId: z.string().describe("Destination payee that transactions will be reassigned to"),
1359
- sinceDate: z.string().optional().describe("Only move transactions on or after this date (YYYY-MM-DD); omit to move all history"),
1798
+ sinceDate: z.string().optional().describe("Only move transactions on or after this date (YYYY-MM-DD); defaults to all history"),
1799
+ untilDate: z.string().optional().describe("Only move transactions on or before this date (YYYY-MM-DD)"),
1360
1800
  } },
1361
- ({ budgetId, fromPayeeId, toPayeeId, sinceDate }) =>
1801
+ ({ budgetId, expectedMatchedCount, fromPayeeId, toPayeeId, sinceDate, untilDate }) =>
1362
1802
  run(async () => {
1363
1803
  const bid = resolveBudgetId(budgetId);
1364
- const { data } = await api.transactions.getTransactionsByPayee(bid, fromPayeeId, sinceDate);
1804
+ const data = await fetchTransactions({
1805
+ budgetId: bid,
1806
+ payeeId: fromPayeeId,
1807
+ sinceDate: sinceDate || allHistorySinceDate(),
1808
+ untilDate,
1809
+ });
1365
1810
  const txns = data.transactions.filter((t) => !t.deleted);
1811
+ if (expectedMatchedCount !== undefined && expectedMatchedCount !== txns.length) {
1812
+ throw new Error(`reassign_payee_transactions matched ${txns.length} transactions, but expectedMatchedCount was ${expectedMatchedCount}; no transactions were reassigned.`);
1813
+ }
1366
1814
  if (txns.length === 0) {
1367
1815
  return ok({ reassigned_count: 0, message: "No transactions found for fromPayeeId in the given range." });
1368
1816
  }
@@ -1387,7 +1835,7 @@ server.registerTool(
1387
1835
  })
1388
1836
  );
1389
1837
 
1390
- server.registerTool(
1838
+ registerTool(
1391
1839
  "import_transactions",
1392
1840
  { description: "Trigger import of linked account transactions", inputSchema: { budgetId: z.string().optional().describe("Budget ID (uses default if not provided)") } },
1393
1841
  ({ budgetId }) =>
@@ -1439,7 +1887,7 @@ function formatScheduledTransaction(t) {
1439
1887
  return withCurrencyFields(out, t, ["amount"]);
1440
1888
  }
1441
1889
 
1442
- server.registerTool(
1890
+ registerTool(
1443
1891
  "list_scheduled_transactions",
1444
1892
  { 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
1893
  budgetId: z.string().optional().describe("Budget ID (uses default if not provided)"),
@@ -1453,7 +1901,7 @@ server.registerTool(
1453
1901
  })
1454
1902
  );
1455
1903
 
1456
- server.registerTool(
1904
+ registerTool(
1457
1905
  "get_scheduled_transaction",
1458
1906
  { description: "Get a specific scheduled transaction", inputSchema: {
1459
1907
  budgetId: z.string().optional().describe("Budget ID (uses default if not provided)"),
@@ -1466,7 +1914,7 @@ server.registerTool(
1466
1914
  })
1467
1915
  );
1468
1916
 
1469
- server.registerTool(
1917
+ registerTool(
1470
1918
  "create_scheduled_transaction",
1471
1919
  { description: "Create a new scheduled (recurring) transaction", inputSchema: {
1472
1920
  budgetId: z.string().optional().describe("Budget ID (uses default if not provided)"),
@@ -1499,7 +1947,7 @@ server.registerTool(
1499
1947
  })
1500
1948
  );
1501
1949
 
1502
- server.registerTool(
1950
+ registerTool(
1503
1951
  "update_scheduled_transaction",
1504
1952
  { description: "Update an existing scheduled transaction. Only provided fields are changed. Amounts in dollars.", inputSchema: {
1505
1953
  budgetId: z.string().optional().describe("Budget ID (uses default if not provided)"),
@@ -1542,7 +1990,7 @@ server.registerTool(
1542
1990
  })
1543
1991
  );
1544
1992
 
1545
- server.registerTool(
1993
+ registerTool(
1546
1994
  "delete_scheduled_transaction",
1547
1995
  { description: "Delete a scheduled transaction", inputSchema: {
1548
1996
  budgetId: z.string().optional().describe("Budget ID (uses default if not provided)"),
@@ -1557,7 +2005,7 @@ server.registerTool(
1557
2005
 
1558
2006
  // ==================== Convenience Tools ====================
1559
2007
 
1560
- server.registerTool(
2008
+ registerTool(
1561
2009
  "search_categories",
1562
2010
  { description: "Search categories by partial name match (case-insensitive). Useful for finding category IDs when you only know part of the name.", inputSchema: {
1563
2011
  budgetId: z.string().optional().describe("Budget ID (uses default if not provided)"),
@@ -1589,7 +2037,7 @@ server.registerTool(
1589
2037
  })
1590
2038
  );
1591
2039
 
1592
- server.registerTool(
2040
+ registerTool(
1593
2041
  "search_payees",
1594
2042
  { description: "Search payees by partial name match (case-insensitive). Useful for finding payee IDs.", inputSchema: {
1595
2043
  budgetId: z.string().optional().describe("Budget ID (uses default if not provided)"),
@@ -1607,7 +2055,7 @@ server.registerTool(
1607
2055
  })
1608
2056
  );
1609
2057
 
1610
- server.registerTool(
2058
+ registerTool(
1611
2059
  "review_unapproved",
1612
2060
  { 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
2061
  budgetId: z.string().optional().describe("Budget ID (uses default if not provided)"),
@@ -1747,7 +2195,7 @@ server.registerTool(
1747
2195
  })
1748
2196
  );
1749
2197
 
1750
- server.registerTool(
2198
+ registerTool(
1751
2199
  "get_overspent_categories",
1752
2200
  { 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
2201
  budgetId: z.string().optional().describe("Budget ID (uses default if not provided)"),
@@ -1755,7 +2203,7 @@ server.registerTool(
1755
2203
  } },
1756
2204
  ({ budgetId, month }) =>
1757
2205
  run(async () => {
1758
- const { data } = await api.months.getBudgetMonth(resolveBudgetId(budgetId), month);
2206
+ const { data } = await api.months.getPlanMonth(resolveBudgetId(budgetId), month);
1759
2207
  const overspent = (data.month.categories || [])
1760
2208
  .filter((c) => !c.deleted && c.balance < 0 && c.category_group_name !== "Internal Master Category")
1761
2209
  .map((c) => ({
@@ -1776,6 +2224,104 @@ server.registerTool(
1776
2224
  })
1777
2225
  );
1778
2226
 
2227
+ registerTool(
2228
+ "ynab_auth_status",
2229
+ {
2230
+ title: "YNAB Auth Status",
2231
+ description: "Check whether the YNAB MCP server has credentials configured and whether write tools are enabled.",
2232
+ inputSchema: {},
2233
+ },
2234
+ () => ok(ynabAuthStatus())
2235
+ );
2236
+
2237
+ registerTool(
2238
+ "ynab_tool_index",
2239
+ {
2240
+ title: "YNAB Tool Index",
2241
+ 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.",
2242
+ inputSchema: {},
2243
+ },
2244
+ () => ok({
2245
+ server: "mcp-server-for-ynab",
2246
+ package: "@oliverames/ynab-mcp-server",
2247
+ auth: ynabAuthStatus(),
2248
+ writes_enabled: writesEnabled(),
2249
+ writes_available: writesEnabled() && !!API_TOKEN,
2250
+ tools: listRegisteredYnabTools(),
2251
+ execute_with: "ynab_tool_execute",
2252
+ write_execute_with: writesEnabled() && !!API_TOKEN ? "ynab_write_tool_execute" : null,
2253
+ })
2254
+ );
2255
+
2256
+ registerTool(
2257
+ "ynab_tool_execute",
2258
+ {
2259
+ title: "Execute YNAB Tool",
2260
+ 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.",
2261
+ inputSchema: {
2262
+ tool_name: z.string().describe("Existing read-only YNAB tool name, such as review_unapproved, get_transactions, list_categories, search_categories, or search_payees."),
2263
+ 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."),
2264
+ },
2265
+ },
2266
+ async ({ tool_name: toolName, input = {} }) => {
2267
+ if (toolName.startsWith("ynab_")) {
2268
+ return {
2269
+ isError: true,
2270
+ content: [{ type: "text", text: "Refusing to execute YNAB discovery helper tools recursively." }],
2271
+ };
2272
+ }
2273
+ if (WRITE_TOOL_METADATA[toolName]) {
2274
+ return {
2275
+ isError: true,
2276
+ 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.` }],
2277
+ };
2278
+ }
2279
+ const tool = registeredTools.get(toolName);
2280
+ if (!tool) {
2281
+ return {
2282
+ isError: true,
2283
+ content: [{ type: "text", text: `Unknown YNAB tool: ${toolName}` }],
2284
+ };
2285
+ }
2286
+ return tool.handler(input);
2287
+ }
2288
+ );
2289
+
2290
+ registerTool(
2291
+ "ynab_write_tool_execute",
2292
+ {
2293
+ title: "Execute YNAB Write Tool",
2294
+ description: "Execute an existing write-capable YNAB MCP tool by name. This tool is registered only when YNAB_ALLOW_WRITES=1 and requires confirmed:true after explicit user confirmation.",
2295
+ inputSchema: {
2296
+ confirmed: z.literal(true).describe("Required. Pass true only after the user explicitly confirms this write action."),
2297
+ tool_name: z.string().describe("Existing write-capable YNAB tool name, such as update_transaction, update_transactions, approve_transactions, create_transaction, or delete_transaction."),
2298
+ input: z.record(z.string(), z.any()).optional().describe("JSON input for the selected YNAB write tool."),
2299
+ },
2300
+ },
2301
+ async ({ tool_name: toolName, input = {} }) => {
2302
+ if (toolName.startsWith("ynab_")) {
2303
+ return {
2304
+ isError: true,
2305
+ content: [{ type: "text", text: "Refusing to execute YNAB discovery helper tools recursively." }],
2306
+ };
2307
+ }
2308
+ if (!WRITE_TOOL_METADATA[toolName]) {
2309
+ return {
2310
+ isError: true,
2311
+ content: [{ type: "text", text: `${toolName} is not a write-capable YNAB tool. Use ynab_tool_execute for read-only tools.` }],
2312
+ };
2313
+ }
2314
+ const tool = registeredTools.get(toolName);
2315
+ if (!tool) {
2316
+ return writeDisabledResult(toolName);
2317
+ }
2318
+ const confirmedInput = ["approve_transactions", "reassign_payee_transactions"].includes(toolName) && input.confirmed === undefined
2319
+ ? { ...input, confirmed: true }
2320
+ : input;
2321
+ return tool.handler(confirmedInput);
2322
+ }
2323
+ );
2324
+
1779
2325
  // --- Start ---
1780
2326
 
1781
2327
  process.on("uncaughtException", (err) => {