@oliverames/ynab-mcp-server 2.1.1 → 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,45 +15,361 @@ 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`);
24
- }
25
- API_TOKEN = tokenFileContents.trim();
26
- } catch (e) {
27
- tokenLookupError = `Could not read YNAB_API_TOKEN_FILE: ${e.message || String(e)}`;
28
- }
29
- }
30
- if (!API_TOKEN && process.env.YNAB_OP_PATH) {
31
- 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();
36
- } catch (e) {
37
- tokenLookupError = `Could not read YNAB_OP_PATH via 1Password CLI: ${e.stderr?.toString().trim() || e.message || "unknown 1Password CLI error"}`;
38
- }
39
- }
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;
40
30
  if (!API_TOKEN) {
41
31
  const fallbackMessage = tokenLookupError
42
32
  ? ` ${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} Starting in discovery-only mode.`);
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.`);
45
35
  }
46
36
 
47
37
  const ynabRateLimit = createYnabRateLimiter();
48
38
  const effectiveApiToken = API_TOKEN || "missing-token-for-tool-discovery";
49
39
  const api = new ynab.API(effectiveApiToken, BASE_URL);
50
40
  api._configuration.config = { accessToken: effectiveApiToken, basePath: BASE_URL, fetchApi: secureFetch };
51
- const DEFAULT_BUDGET_ID = process.env.YNAB_BUDGET_ID;
52
41
 
53
42
  // --- Helpers ---
54
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)}`);
82
+ }
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");
159
+ } catch (e) {
160
+ return emptySources.map((source) => ({
161
+ ...source,
162
+ available: false,
163
+ errors: isMissingFileError(e) ? [] : [`Could not read ${configPath}: ${e.message || String(e)}`],
164
+ }));
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
+ }));
174
+ }
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;
181
+ try {
182
+ settings = JSON.parse(readFileSync(settingsPath, "utf8"));
183
+ } catch (e) {
184
+ return {
185
+ ...source,
186
+ available: false,
187
+ errors: isMissingFileError(e) ? [] : [`Could not read ${settingsPath}: ${e.message || String(e)}`],
188
+ };
189
+ }
190
+
191
+ return {
192
+ ...source,
193
+ available: true,
194
+ values: pickYnabValues(settings?.env || {}),
195
+ errors: [],
196
+ };
197
+ }
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
+ };
209
+ }
210
+
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
+ }
218
+
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
+ }
372
+
55
373
  function resolveBudgetId(input) {
56
374
  return input || DEFAULT_BUDGET_ID || "last-used";
57
375
  }
@@ -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) {
@@ -344,8 +715,8 @@ 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.1",
718
+ name: "mcp-server-for-ynab",
719
+ version: "3.0.0",
349
720
  });
350
721
 
351
722
  const registeredTools = new Map();
@@ -366,6 +737,12 @@ function listRegisteredYnabTools() {
366
737
  .map(([name, { config }]) => {
367
738
  const writeMetadata = WRITE_TOOL_METADATA[name];
368
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
+ }
369
746
  return {
370
747
  name,
371
748
  title: config?.title ?? name,
@@ -373,7 +750,7 @@ function listRegisteredYnabTools() {
373
750
  has_input_schema: !!config?.inputSchema,
374
751
  is_write: isWrite,
375
752
  registered: registeredTools.has(name),
376
- status: isWrite && !writesEnabled() ? "hidden_requires_YNAB_ALLOW_WRITES_1" : "available",
753
+ status,
377
754
  };
378
755
  });
379
756
  }
@@ -402,17 +779,28 @@ const WRITE_TOOL_METADATA = {
402
779
  };
403
780
 
404
781
  function writesEnabled() {
405
- return process.env.YNAB_ALLOW_WRITES === "1";
782
+ return runtimeConfig.values.YNAB_ALLOW_WRITES?.value === "1";
406
783
  }
407
784
 
408
785
  function ynabAuthStatus() {
786
+ const authenticated = !!API_TOKEN;
787
+ const writeToolsAvailable = authenticated && writesEnabled();
409
788
  return {
410
- authenticated: !!API_TOKEN,
789
+ authenticated,
411
790
  default_budget_id_configured: !!DEFAULT_BUDGET_ID,
412
791
  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.",
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.",
416
804
  };
417
805
  }
418
806
 
@@ -485,12 +873,14 @@ registerTool(
485
873
  { description: "List all budgets. Use a budget ID from the results in other tools, or omit budgetId to use the last-used budget." },
486
874
  () =>
487
875
  run(async () => {
488
- 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;
489
879
  const result = {
490
- 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 })),
491
881
  };
492
- if (data.default_budget) {
493
- 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 };
494
884
  }
495
885
  return ok(result);
496
886
  })
@@ -501,8 +891,8 @@ registerTool(
501
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)") } },
502
892
  ({ budgetId }) =>
503
893
  run(async () => {
504
- const { data } = await api.budgets.getBudgetById(resolveBudgetId(budgetId));
505
- const b = data.budget;
894
+ const { data } = await api.plans.getPlanById(resolveBudgetId(budgetId));
895
+ const b = data.plan || data.budget;
506
896
  return ok({
507
897
  id: b.id,
508
898
  name: b.name,
@@ -523,7 +913,7 @@ registerTool(
523
913
  { description: "Get budget settings (currency format, date format)", inputSchema: { budgetId: z.string().optional().describe("Budget ID (uses default if not provided)") } },
524
914
  ({ budgetId }) =>
525
915
  run(async () => {
526
- const { data } = await api.budgets.getBudgetSettingsById(resolveBudgetId(budgetId));
916
+ const { data } = await api.plans.getPlanSettingsById(resolveBudgetId(budgetId));
527
917
  return ok(data.settings);
528
918
  })
529
919
  );
@@ -919,7 +1309,7 @@ registerTool(
919
1309
  } },
920
1310
  ({ budgetId, lastKnowledgeOfServer }) =>
921
1311
  run(async () => {
922
- const { data } = await api.months.getBudgetMonths(resolveBudgetId(budgetId), lastKnowledgeOfServer);
1312
+ const { data } = await api.months.getPlanMonths(resolveBudgetId(budgetId), lastKnowledgeOfServer);
923
1313
  const months = data.months.map((m) =>
924
1314
  withCurrencyFields(
925
1315
  {
@@ -948,7 +1338,7 @@ registerTool(
948
1338
  } },
949
1339
  ({ budgetId, month }) =>
950
1340
  run(async () => {
951
- const { data } = await api.months.getBudgetMonth(resolveBudgetId(budgetId), month);
1341
+ const { data } = await api.months.getPlanMonth(resolveBudgetId(budgetId), month);
952
1342
  const m = data.month;
953
1343
  const out = {
954
1344
  month: m.month,
@@ -1101,9 +1491,10 @@ function formatTransaction(t) {
1101
1491
 
1102
1492
  registerTool(
1103
1493
  "get_transactions",
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: {
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: {
1105
1495
  budgetId: z.string().optional().describe("Budget ID (uses default if not provided)"),
1106
- 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)"),
1107
1498
  type: z.enum(["unapproved", "uncategorized"]).optional().describe("Filter by approval/categorization status"),
1108
1499
  accountId: z.string().optional().describe("Filter by account ID"),
1109
1500
  categoryId: z.string().optional().describe("Filter by category ID"),
@@ -1111,33 +1502,25 @@ registerTool(
1111
1502
  month: z.string().optional().describe("Filter by month (YYYY-MM-DD, first of month)"),
1112
1503
  lastKnowledgeOfServer: z.number().int().nonnegative().optional().describe("Delta request server knowledge. When provided, returns { transactions, server_knowledge }."),
1113
1504
  } },
1114
- ({ budgetId, sinceDate, type, accountId, categoryId, payeeId, month, lastKnowledgeOfServer }) =>
1505
+ ({ budgetId, sinceDate, untilDate, type, accountId, categoryId, payeeId, month, lastKnowledgeOfServer }) =>
1115
1506
  run(async () => {
1116
- const bid = resolveBudgetId(budgetId);
1117
- let transactions;
1118
- let data;
1119
1507
  const resourceFilters = [accountId, categoryId, payeeId, month].filter((value) => value !== undefined && value !== null && value !== "");
1120
1508
  if (resourceFilters.length > 1) {
1121
1509
  throw new Error("Provide only one of accountId, categoryId, payeeId, or month.");
1122
1510
  }
1123
1511
 
1124
- if (accountId) {
1125
- ({ data } = await api.transactions.getTransactionsByAccount(bid, accountId, sinceDate, type, lastKnowledgeOfServer));
1126
- transactions = data.transactions;
1127
- } else if (categoryId) {
1128
- ({ data } = await api.transactions.getTransactionsByCategory(bid, categoryId, sinceDate, type, lastKnowledgeOfServer));
1129
- transactions = data.transactions;
1130
- } else if (payeeId) {
1131
- ({ data } = await api.transactions.getTransactionsByPayee(bid, payeeId, sinceDate, type, lastKnowledgeOfServer));
1132
- transactions = data.transactions;
1133
- } else if (month) {
1134
- ({ data } = await api.transactions.getTransactionsByMonth(bid, month, sinceDate, type, lastKnowledgeOfServer));
1135
- transactions = data.transactions;
1136
- } else {
1137
- ({ data } = await api.transactions.getTransactions(bid, sinceDate, type, lastKnowledgeOfServer));
1138
- transactions = data.transactions;
1139
- }
1140
-
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;
1141
1524
  return ok(collection(data, "transactions", transactions.map(formatTransaction), lastKnowledgeOfServer));
1142
1525
  })
1143
1526
  );
@@ -1351,17 +1734,26 @@ registerTool(
1351
1734
 
1352
1735
  registerTool(
1353
1736
  "approve_transactions",
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: {
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: {
1355
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)."),
1356
1743
  payeeId: z.string().optional().describe("Only approve unapproved transactions for this payee"),
1357
1744
  categoryId: z.string().optional().describe("Only approve unapproved transactions in this category"),
1358
1745
  accountId: z.string().optional().describe("Only approve unapproved transactions in this account"),
1359
1746
  includeUncategorized: z.boolean().optional().describe("If true, also approve transactions with no category (default false — uncategorized are skipped for safety)"),
1360
1747
  } },
1361
- ({ budgetId, payeeId, categoryId, accountId, includeUncategorized }) =>
1748
+ ({ budgetId, expectedMatchedCount, sinceDate, untilDate, payeeId, categoryId, accountId, includeUncategorized }) =>
1362
1749
  run(async () => {
1363
1750
  const bid = resolveBudgetId(budgetId);
1364
- 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
+ });
1365
1757
  let txns = data.transactions.filter((t) => !t.deleted);
1366
1758
  if (payeeId) txns = txns.filter((t) => t.payee_id === payeeId);
1367
1759
  if (categoryId) txns = txns.filter((t) => t.category_id === categoryId);
@@ -1369,6 +1761,9 @@ registerTool(
1369
1761
  if (!includeUncategorized) {
1370
1762
  txns = txns.filter((t) => (t.category_id && t.category_name !== "Uncategorized") || t.transfer_account_id);
1371
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
+ }
1372
1767
  if (txns.length === 0) {
1373
1768
  return ok({ approved_count: 0, matched: 0, message: "No matching unapproved transactions to approve." });
1374
1769
  }
@@ -1394,17 +1789,28 @@ registerTool(
1394
1789
 
1395
1790
  registerTool(
1396
1791
  "reassign_payee_transactions",
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: {
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: {
1398
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."),
1399
1796
  fromPayeeId: z.string().describe("Payee whose transactions will be moved"),
1400
1797
  toPayeeId: z.string().describe("Destination payee that transactions will be reassigned to"),
1401
- 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)"),
1402
1800
  } },
1403
- ({ budgetId, fromPayeeId, toPayeeId, sinceDate }) =>
1801
+ ({ budgetId, expectedMatchedCount, fromPayeeId, toPayeeId, sinceDate, untilDate }) =>
1404
1802
  run(async () => {
1405
1803
  const bid = resolveBudgetId(budgetId);
1406
- 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
+ });
1407
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
+ }
1408
1814
  if (txns.length === 0) {
1409
1815
  return ok({ reassigned_count: 0, message: "No transactions found for fromPayeeId in the given range." });
1410
1816
  }
@@ -1797,7 +2203,7 @@ registerTool(
1797
2203
  } },
1798
2204
  ({ budgetId, month }) =>
1799
2205
  run(async () => {
1800
- const { data } = await api.months.getBudgetMonth(resolveBudgetId(budgetId), month);
2206
+ const { data } = await api.months.getPlanMonth(resolveBudgetId(budgetId), month);
1801
2207
  const overspent = (data.month.categories || [])
1802
2208
  .filter((c) => !c.deleted && c.balance < 0 && c.category_group_name !== "Internal Master Category")
1803
2209
  .map((c) => ({
@@ -1836,13 +2242,14 @@ registerTool(
1836
2242
  inputSchema: {},
1837
2243
  },
1838
2244
  () => ok({
1839
- server: "ynab-mcp-server",
2245
+ server: "mcp-server-for-ynab",
1840
2246
  package: "@oliverames/ynab-mcp-server",
1841
2247
  auth: ynabAuthStatus(),
1842
2248
  writes_enabled: writesEnabled(),
2249
+ writes_available: writesEnabled() && !!API_TOKEN,
1843
2250
  tools: listRegisteredYnabTools(),
1844
2251
  execute_with: "ynab_tool_execute",
1845
- write_execute_with: writesEnabled() ? "ynab_write_tool_execute" : null,
2252
+ write_execute_with: writesEnabled() && !!API_TOKEN ? "ynab_write_tool_execute" : null,
1846
2253
  })
1847
2254
  );
1848
2255
 
@@ -1884,8 +2291,9 @@ registerTool(
1884
2291
  "ynab_write_tool_execute",
1885
2292
  {
1886
2293
  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.",
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.",
1888
2295
  inputSchema: {
2296
+ confirmed: z.literal(true).describe("Required. Pass true only after the user explicitly confirms this write action."),
1889
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."),
1890
2298
  input: z.record(z.string(), z.any()).optional().describe("JSON input for the selected YNAB write tool."),
1891
2299
  },
@@ -1907,7 +2315,10 @@ registerTool(
1907
2315
  if (!tool) {
1908
2316
  return writeDisabledResult(toolName);
1909
2317
  }
1910
- return tool.handler(input);
2318
+ const confirmedInput = ["approve_transactions", "reassign_payee_transactions"].includes(toolName) && input.confirmed === undefined
2319
+ ? { ...input, confirmed: true }
2320
+ : input;
2321
+ return tool.handler(confirmedInput);
1911
2322
  }
1912
2323
  );
1913
2324