@oliverames/ynab-mcp-server 1.7.1 → 2.1.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/index.js CHANGED
@@ -1,6 +1,7 @@
1
1
  #!/usr/bin/env node
2
2
 
3
3
  import { execFileSync } from "node:child_process";
4
+ import { readFileSync } from "node:fs";
4
5
  import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
5
6
  import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
6
7
  import { z } from "zod";
@@ -8,8 +9,24 @@ import * as ynab from "ynab";
8
9
 
9
10
  // --- Init ---
10
11
 
12
+ const BASE_URL = "https://api.ynab.com/v1";
13
+ const YNAB_API_HOST = "api.ynab.com";
14
+ const MAX_TOKEN_FILE_BYTES = 4096;
15
+ const MAX_RESPONSE_BYTES = Number.parseInt(process.env.YNAB_MAX_RESPONSE_BYTES || "8388608", 10);
16
+
11
17
  let API_TOKEN = process.env.YNAB_API_TOKEN;
12
- let opLookupError;
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
+ }
13
30
  if (!API_TOKEN && process.env.YNAB_OP_PATH) {
14
31
  try {
15
32
  API_TOKEN = execFileSync(
@@ -17,18 +34,20 @@ if (!API_TOKEN && process.env.YNAB_OP_PATH) {
17
34
  { encoding: "utf-8", timeout: 10000, stdio: ["pipe", "pipe", "pipe"] }
18
35
  ).trim();
19
36
  } catch (e) {
20
- opLookupError = e.stderr?.toString().trim() || e.message || "unknown 1Password CLI error";
37
+ tokenLookupError = `Could not read YNAB_OP_PATH via 1Password CLI: ${e.stderr?.toString().trim() || e.message || "unknown 1Password CLI error"}`;
21
38
  }
22
39
  }
23
40
  if (!API_TOKEN) {
24
- const opMessage = process.env.YNAB_OP_PATH
25
- ? ` Could not read YNAB_OP_PATH via 1Password CLI: ${opLookupError}.`
26
- : " Set YNAB_OP_PATH to enable 1Password CLI fallback (e.g. op://Vault/Item/credential).";
27
- console.error(`YNAB_API_TOKEN environment variable is required.${opMessage}`);
28
- process.exit(1);
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} Starting in discovery-only mode.`);
29
45
  }
30
46
 
31
- const api = new ynab.API(API_TOKEN);
47
+ const ynabRateLimit = createYnabRateLimiter();
48
+ const effectiveApiToken = API_TOKEN || "missing-token-for-tool-discovery";
49
+ const api = new ynab.API(effectiveApiToken, BASE_URL);
50
+ api._configuration.config = { accessToken: effectiveApiToken, basePath: BASE_URL, fetchApi: secureFetch };
32
51
  const DEFAULT_BUDGET_ID = process.env.YNAB_BUDGET_ID;
33
52
 
34
53
  // --- Helpers ---
@@ -45,6 +64,12 @@ function milliunits(dollars) {
45
64
  return Math.round(dollars * 1000);
46
65
  }
47
66
 
67
+ // Round a dollar sum to cents, killing IEEE-754 artifacts from summing floats
68
+ // (e.g. a group total like -53.730000000000004 produced by reduce/+= over amounts).
69
+ function round2(n) {
70
+ return n == null ? n : Math.round(n * 100) / 100;
71
+ }
72
+
48
73
  function dollarsMap(obj) {
49
74
  return obj ? Object.fromEntries(Object.entries(obj).map(([k, v]) => [k, dollars(v)])) : obj;
50
75
  }
@@ -101,6 +126,89 @@ function mapTransactionUpdate(t) {
101
126
  return out;
102
127
  }
103
128
 
129
+ const TRANSACTION_UPDATE_VERIFICATION_FIELDS = [
130
+ ["accountId", "account_id"],
131
+ ["date", "date"],
132
+ ["amount", "amount"],
133
+ ["payeeId", "payee_id"],
134
+ ["payeeName", "payee_name"],
135
+ ["categoryId", "category_id"],
136
+ ["memo", "memo"],
137
+ ["cleared", "cleared"],
138
+ ["approved", "approved"],
139
+ ["flagColor", "flag_color"],
140
+ ];
141
+
142
+ function hasOwn(obj, key) {
143
+ return Object.prototype.hasOwnProperty.call(obj, key);
144
+ }
145
+
146
+ function updateFieldMatches(expected, actual) {
147
+ if (typeof expected === "number" && typeof actual === "number") {
148
+ return Math.abs(expected - actual) < 0.0001;
149
+ }
150
+ return Object.is(expected, actual);
151
+ }
152
+
153
+ function transactionUpdateMismatches(requested, actual) {
154
+ const mismatches = [];
155
+ for (const [inputField, outputField] of TRANSACTION_UPDATE_VERIFICATION_FIELDS) {
156
+ if (!hasOwn(requested, inputField)) continue;
157
+ const expected = requested[inputField] ?? null;
158
+ const actualValue = actual[outputField] ?? null;
159
+ if (!updateFieldMatches(expected, actualValue)) {
160
+ mismatches.push({
161
+ field: inputField,
162
+ expected,
163
+ actual: actualValue,
164
+ });
165
+ }
166
+ }
167
+ return mismatches;
168
+ }
169
+
170
+ async function getFormattedTransaction(budgetId, transactionId) {
171
+ const { data } = await api.transactions.getTransactionById(budgetId, normalizeTransactionId(transactionId));
172
+ return formatTransaction(data.transaction);
173
+ }
174
+
175
+ async function verifyBulkTransactionUpdates(budgetId, requestedUpdates) {
176
+ const verification = {
177
+ checked: requestedUpdates.length,
178
+ retried: [],
179
+ failed: [],
180
+ };
181
+ const verified = [];
182
+
183
+ for (const requested of requestedUpdates) {
184
+ let refetched = await getFormattedTransaction(budgetId, requested.id);
185
+ let mismatches = transactionUpdateMismatches(requested, refetched);
186
+
187
+ if (mismatches.length > 0) {
188
+ verification.retried.push({
189
+ id: requested.id,
190
+ mismatches,
191
+ });
192
+ const { data } = await api.transactions.updateTransaction(budgetId, requested.id, {
193
+ transaction: mapTransactionUpdate(requested),
194
+ });
195
+ refetched = formatTransaction(data.transaction);
196
+ mismatches = transactionUpdateMismatches(requested, refetched);
197
+ }
198
+
199
+ if (mismatches.length > 0) {
200
+ verification.failed.push({
201
+ id: requested.id,
202
+ mismatches,
203
+ });
204
+ }
205
+
206
+ verified.push(refetched);
207
+ }
208
+
209
+ return { verification, verified };
210
+ }
211
+
104
212
  // YNAB scheduled transactions that realize get composite IDs like `uuid_YYYY-MM-DD`.
105
213
  // Strip the date suffix so API lookups work correctly.
106
214
  function normalizeTransactionId(id) {
@@ -126,28 +234,108 @@ async function run(fn) {
126
234
  const msg = detail
127
235
  ? (name ? `${name}: ${detail}` : detail)
128
236
  : (e?.message || String(e));
129
- return { content: [{ type: "text", text: `Error: ${msg}` }], isError: true };
237
+ return { content: [{ type: "text", text: `Error: ${sanitizeErrorMessage(msg)}` }], isError: true };
130
238
  }
131
239
  }
132
240
 
241
+ function sanitizeErrorMessage(value) {
242
+ let message = String(value ?? "");
243
+ if (API_TOKEN) {
244
+ message = message.split(API_TOKEN).join("[REDACTED_TOKEN]");
245
+ }
246
+ return message
247
+ .replace(/Bearer\s+[A-Za-z0-9._~+/=-]+/gi, "Bearer [REDACTED_TOKEN]")
248
+ .replace(/Authorization:\s*[^\r\n]+/gi, "Authorization: [REDACTED_TOKEN]");
249
+ }
250
+
251
+ function createYnabRateLimiter() {
252
+ const requestsPerHour = Number.parseFloat(process.env.YNAB_RATE_LIMIT_PER_HOUR || "190");
253
+ if (!Number.isFinite(requestsPerHour) || requestsPerHour <= 0) {
254
+ return async () => {};
255
+ }
256
+
257
+ const burst = Math.max(1, Number.parseInt(process.env.YNAB_RATE_LIMIT_BURST || "10", 10));
258
+ const refillMs = 3600000 / requestsPerHour;
259
+ let tokens = burst;
260
+ let updatedAt = Date.now();
261
+
262
+ return async function waitForYnabRateLimit() {
263
+ while (true) {
264
+ const now = Date.now();
265
+ const elapsed = now - updatedAt;
266
+ tokens = Math.min(burst, tokens + elapsed / refillMs);
267
+ updatedAt = now;
268
+
269
+ if (tokens >= 1) {
270
+ tokens -= 1;
271
+ return;
272
+ }
273
+
274
+ const waitMs = Math.ceil((1 - tokens) * refillMs);
275
+ await new Promise((resolve) => setTimeout(resolve, waitMs));
276
+ }
277
+ };
278
+ }
279
+
280
+ function assertYnabApiUrl(url) {
281
+ if (url.protocol !== "https:" || url.hostname.toLowerCase() !== YNAB_API_HOST || (url.port && url.port !== "443")) {
282
+ throw new Error(`Refusing YNAB API request to non-YNAB host: ${url.origin}`);
283
+ }
284
+ }
285
+
286
+ async function secureFetch(input, init = {}) {
287
+ const url = input instanceof URL ? input : new URL(typeof input === "string" ? input : input.url);
288
+ assertYnabApiUrl(url);
289
+ await ynabRateLimit();
290
+
291
+ const timeoutMs = Number.parseInt(process.env.YNAB_HTTP_TIMEOUT_MS || "30000", 10);
292
+ const controller = !init.signal && timeoutMs > 0 ? new AbortController() : null;
293
+ const timeout = controller
294
+ ? setTimeout(() => controller.abort(new Error(`YNAB request timed out after ${timeoutMs}ms`)), timeoutMs)
295
+ : null;
296
+
297
+ try {
298
+ return await fetch(url, {
299
+ ...init,
300
+ redirect: "manual",
301
+ signal: controller?.signal || init.signal,
302
+ });
303
+ } finally {
304
+ if (timeout) clearTimeout(timeout);
305
+ }
306
+ }
307
+
308
+ function buildYnabUrl(path) {
309
+ if (!path.startsWith("/") || path.startsWith("//") || /^[a-z][a-z0-9+.-]*:/i.test(path) || /[\r\n]/.test(path)) {
310
+ throw new Error("Refusing unsafe YNAB API path");
311
+ }
312
+ return new URL(`${BASE_URL}${path}`);
313
+ }
314
+
133
315
  // Direct API helper for endpoints not yet in the ynab SDK
134
- const BASE_URL = "https://api.ynab.com/v1";
135
316
  async function ynabFetch(path, { method = "GET", body, query } = {}) {
136
- const url = new URL(`${BASE_URL}${path}`);
317
+ const url = buildYnabUrl(path);
137
318
  for (const [key, value] of Object.entries(query || {})) {
138
319
  if (value !== undefined && value !== null) url.searchParams.set(key, String(value));
139
320
  }
140
321
  const opts = {
141
322
  method,
142
- headers: { Authorization: `Bearer ${API_TOKEN}`, "Content-Type": "application/json" },
323
+ headers: { Authorization: `Bearer ${effectiveApiToken}`, "Content-Type": "application/json" },
143
324
  };
144
325
  if (body) opts.body = JSON.stringify(body);
145
- const res = await fetch(url, opts);
326
+ const res = await secureFetch(url, opts);
327
+ const contentLength = Number.parseInt(res.headers.get("content-length") || "0", 10);
328
+ if (Number.isFinite(contentLength) && contentLength > MAX_RESPONSE_BYTES) {
329
+ throw new Error(`YNAB response exceeded ${MAX_RESPONSE_BYTES} bytes`);
330
+ }
146
331
  const text = await res.text();
332
+ if (Buffer.byteLength(text, "utf8") > MAX_RESPONSE_BYTES) {
333
+ throw new Error(`YNAB response exceeded ${MAX_RESPONSE_BYTES} bytes`);
334
+ }
147
335
  const json = text ? JSON.parse(text) : {};
148
336
  if (!res.ok) {
149
- const err = new Error(json?.error?.detail || `HTTP ${res.status}`);
150
- err.error = json?.error;
337
+ const err = new Error(sanitizeErrorMessage(json?.error?.detail || `HTTP ${res.status}`));
338
+ err.error = json?.error ? { ...json.error, detail: sanitizeErrorMessage(json.error.detail) } : undefined;
151
339
  throw err;
152
340
  }
153
341
  return json.data;
@@ -157,12 +345,132 @@ async function ynabFetch(path, { method = "GET", body, query } = {}) {
157
345
 
158
346
  const server = new McpServer({
159
347
  name: "ynab-mcp-server",
160
- version: "1.6.0",
348
+ version: "2.1.1",
161
349
  });
162
350
 
351
+ const registeredTools = new Map();
352
+ const toolCatalog = new Map();
353
+
354
+ function registerTool(name, config, handler) {
355
+ const registration = server.registerTool(name, config, handler);
356
+ toolCatalog.set(name, { config });
357
+ if (registration !== undefined) {
358
+ registeredTools.set(name, { config, handler });
359
+ }
360
+ return registration;
361
+ }
362
+
363
+ function listRegisteredYnabTools() {
364
+ return [...toolCatalog.entries()]
365
+ .filter(([name]) => !name.startsWith("ynab_"))
366
+ .map(([name, { config }]) => {
367
+ const writeMetadata = WRITE_TOOL_METADATA[name];
368
+ const isWrite = !!writeMetadata;
369
+ return {
370
+ name,
371
+ title: config?.title ?? name,
372
+ description: isWrite ? withWriteGateDescription(config?.description ?? "") : config?.description ?? "",
373
+ has_input_schema: !!config?.inputSchema,
374
+ is_write: isWrite,
375
+ registered: registeredTools.has(name),
376
+ status: isWrite && !writesEnabled() ? "hidden_requires_YNAB_ALLOW_WRITES_1" : "available",
377
+ };
378
+ });
379
+ }
380
+
381
+ const WRITE_TOOL_METADATA = {
382
+ create_account: { destructiveHint: false, idempotentHint: false },
383
+ update_month_category: { destructiveHint: false, idempotentHint: true },
384
+ update_category: { destructiveHint: false, idempotentHint: true },
385
+ create_category: { destructiveHint: false, idempotentHint: false },
386
+ create_category_group: { destructiveHint: false, idempotentHint: false },
387
+ update_category_group: { destructiveHint: false, idempotentHint: true },
388
+ update_payee: { destructiveHint: false, idempotentHint: true },
389
+ create_payee: { destructiveHint: false, idempotentHint: false },
390
+ create_transaction: { destructiveHint: false, idempotentHint: false },
391
+ create_transactions: { destructiveHint: false, idempotentHint: false },
392
+ update_transaction: { destructiveHint: false, idempotentHint: true },
393
+ delete_transaction: { destructiveHint: true, idempotentHint: true },
394
+ update_transactions: { destructiveHint: false, idempotentHint: true },
395
+ approve_transactions: { destructiveHint: false, idempotentHint: true },
396
+ reassign_payee_transactions: { destructiveHint: false, idempotentHint: true },
397
+ ynab_write_tool_execute: { destructiveHint: false, idempotentHint: false },
398
+ import_transactions: { destructiveHint: false, idempotentHint: false },
399
+ create_scheduled_transaction: { destructiveHint: false, idempotentHint: false },
400
+ update_scheduled_transaction: { destructiveHint: false, idempotentHint: true },
401
+ delete_scheduled_transaction: { destructiveHint: true, idempotentHint: true },
402
+ };
403
+
404
+ function writesEnabled() {
405
+ return process.env.YNAB_ALLOW_WRITES === "1";
406
+ }
407
+
408
+ function ynabAuthStatus() {
409
+ return {
410
+ authenticated: !!API_TOKEN,
411
+ default_budget_id_configured: !!DEFAULT_BUDGET_ID,
412
+ writes_enabled: writesEnabled(),
413
+ message: API_TOKEN
414
+ ? "YNAB MCP server has an API token configured."
415
+ : "YNAB MCP server is running in discovery-only mode. Set YNAB_API_TOKEN, YNAB_API_TOKEN_FILE, or YNAB_OP_PATH, then restart the MCP server before calling API tools.",
416
+ };
417
+ }
418
+
419
+ function writeDisabledResult(name) {
420
+ return {
421
+ content: [{
422
+ type: "text",
423
+ text: `Error: ${name} is disabled. Restart the MCP server with YNAB_ALLOW_WRITES=1 to enable write tools.`,
424
+ }],
425
+ isError: true,
426
+ };
427
+ }
428
+
429
+ function withWriteGateDescription(description = "") {
430
+ if (description.includes("YNAB_ALLOW_WRITES=1")) return description;
431
+ return `${description} Requires YNAB_ALLOW_WRITES=1; write tools are not registered by default.`;
432
+ }
433
+
434
+ const registerRawTool = server.registerTool.bind(server);
435
+ server.registerTool = (name, config, handler) => {
436
+ const writeMetadata = WRITE_TOOL_METADATA[name];
437
+ if (!writeMetadata) {
438
+ return registerRawTool(name, {
439
+ ...config,
440
+ annotations: {
441
+ ...config.annotations,
442
+ readOnlyHint: true,
443
+ destructiveHint: false,
444
+ openWorldHint: true,
445
+ },
446
+ }, handler);
447
+ }
448
+
449
+ if (!writesEnabled()) {
450
+ return undefined;
451
+ }
452
+
453
+ return registerRawTool(name, {
454
+ ...config,
455
+ description: withWriteGateDescription(config.description),
456
+ annotations: {
457
+ ...config.annotations,
458
+ readOnlyHint: false,
459
+ destructiveHint: writeMetadata.destructiveHint,
460
+ idempotentHint: writeMetadata.idempotentHint,
461
+ openWorldHint: true,
462
+ },
463
+ }, (args, extra) => {
464
+ if (!writesEnabled()) {
465
+ return writeDisabledResult(name);
466
+ }
467
+ return handler(args, extra);
468
+ });
469
+ };
470
+
163
471
  // ==================== User & Budgets ====================
164
472
 
165
- server.registerTool(
473
+ registerTool(
166
474
  "get_user",
167
475
  { description: "Get the authenticated user" },
168
476
  () =>
@@ -172,7 +480,7 @@ server.registerTool(
172
480
  })
173
481
  );
174
482
 
175
- server.registerTool(
483
+ registerTool(
176
484
  "list_budgets",
177
485
  { description: "List all budgets. Use a budget ID from the results in other tools, or omit budgetId to use the last-used budget." },
178
486
  () =>
@@ -188,7 +496,7 @@ server.registerTool(
188
496
  })
189
497
  );
190
498
 
191
- server.registerTool(
499
+ registerTool(
192
500
  "get_budget",
193
501
  { description: "Get a budget summary including name, currency format, and account/category/payee counts", inputSchema: { budgetId: z.string().optional().describe("Budget ID (uses default if not provided)") } },
194
502
  ({ budgetId }) =>
@@ -210,7 +518,7 @@ server.registerTool(
210
518
  })
211
519
  );
212
520
 
213
- server.registerTool(
521
+ registerTool(
214
522
  "get_budget_settings",
215
523
  { description: "Get budget settings (currency format, date format)", inputSchema: { budgetId: z.string().optional().describe("Budget ID (uses default if not provided)") } },
216
524
  ({ budgetId }) =>
@@ -246,7 +554,7 @@ function formatAccount(a) {
246
554
  return withCurrencyFields(out, a, ["balance", "cleared_balance", "uncleared_balance"]);
247
555
  }
248
556
 
249
- server.registerTool(
557
+ registerTool(
250
558
  "list_accounts",
251
559
  { description: "List all accounts in a budget", inputSchema: {
252
560
  budgetId: z.string().optional().describe("Budget ID (uses default if not provided)"),
@@ -260,7 +568,7 @@ server.registerTool(
260
568
  })
261
569
  );
262
570
 
263
- server.registerTool(
571
+ registerTool(
264
572
  "get_account",
265
573
  { description: "Get details for a specific account", inputSchema: {
266
574
  budgetId: z.string().optional().describe("Budget ID (uses default if not provided)"),
@@ -273,7 +581,7 @@ server.registerTool(
273
581
  })
274
582
  );
275
583
 
276
- server.registerTool(
584
+ registerTool(
277
585
  "create_account",
278
586
  { description: "Create a new account", inputSchema: {
279
587
  budgetId: z.string().optional().describe("Budget ID (uses default if not provided)"),
@@ -332,7 +640,7 @@ function formatCategory(c) {
332
640
  ]);
333
641
  }
334
642
 
335
- server.registerTool(
643
+ registerTool(
336
644
  "list_categories",
337
645
  { description: "List all category groups and their categories", inputSchema: {
338
646
  budgetId: z.string().optional().describe("Budget ID (uses default if not provided)"),
@@ -368,7 +676,7 @@ server.registerTool(
368
676
  })
369
677
  );
370
678
 
371
- server.registerTool(
679
+ registerTool(
372
680
  "get_category",
373
681
  { description: "Get a specific category", inputSchema: {
374
682
  budgetId: z.string().optional().describe("Budget ID (uses default if not provided)"),
@@ -381,7 +689,7 @@ server.registerTool(
381
689
  })
382
690
  );
383
691
 
384
- server.registerTool(
692
+ registerTool(
385
693
  "get_month_category",
386
694
  { description: "Get category budget for a specific month", inputSchema: {
387
695
  budgetId: z.string().optional().describe("Budget ID (uses default if not provided)"),
@@ -395,7 +703,7 @@ server.registerTool(
395
703
  })
396
704
  );
397
705
 
398
- server.registerTool(
706
+ registerTool(
399
707
  "update_month_category",
400
708
  { description: "Set the budgeted amount for a category in a specific month", inputSchema: {
401
709
  budgetId: z.string().optional().describe("Budget ID (uses default if not provided)"),
@@ -412,7 +720,7 @@ server.registerTool(
412
720
  })
413
721
  );
414
722
 
415
- server.registerTool(
723
+ registerTool(
416
724
  "update_category",
417
725
  { description: "Update a category's name, note, goal target, or move it to a different group", inputSchema: {
418
726
  budgetId: z.string().optional().describe("Budget ID (uses default if not provided)"),
@@ -442,7 +750,7 @@ server.registerTool(
442
750
  })
443
751
  );
444
752
 
445
- server.registerTool(
753
+ registerTool(
446
754
  "create_category",
447
755
  { description: "Create a new category in a category group", inputSchema: {
448
756
  budgetId: z.string().optional().describe("Budget ID (uses default if not provided)"),
@@ -469,7 +777,7 @@ server.registerTool(
469
777
  })
470
778
  );
471
779
 
472
- server.registerTool(
780
+ registerTool(
473
781
  "create_category_group",
474
782
  { description: "Create a new category group", inputSchema: {
475
783
  budgetId: z.string().optional().describe("Budget ID (uses default if not provided)"),
@@ -485,7 +793,7 @@ server.registerTool(
485
793
  })
486
794
  );
487
795
 
488
- server.registerTool(
796
+ registerTool(
489
797
  "update_category_group",
490
798
  { description: "Rename a category group", inputSchema: {
491
799
  budgetId: z.string().optional().describe("Budget ID (uses default if not provided)"),
@@ -504,7 +812,7 @@ server.registerTool(
504
812
 
505
813
  // ==================== Payees ====================
506
814
 
507
- server.registerTool(
815
+ registerTool(
508
816
  "list_payees",
509
817
  { description: "List all payees", inputSchema: {
510
818
  budgetId: z.string().optional().describe("Budget ID (uses default if not provided)"),
@@ -518,7 +826,7 @@ server.registerTool(
518
826
  })
519
827
  );
520
828
 
521
- server.registerTool(
829
+ registerTool(
522
830
  "get_payee",
523
831
  { description: "Get a specific payee", inputSchema: {
524
832
  budgetId: z.string().optional().describe("Budget ID (uses default if not provided)"),
@@ -531,7 +839,7 @@ server.registerTool(
531
839
  })
532
840
  );
533
841
 
534
- server.registerTool(
842
+ registerTool(
535
843
  "update_payee",
536
844
  { description: "Rename a payee", inputSchema: {
537
845
  budgetId: z.string().optional().describe("Budget ID (uses default if not provided)"),
@@ -547,7 +855,7 @@ server.registerTool(
547
855
  })
548
856
  );
549
857
 
550
- server.registerTool(
858
+ registerTool(
551
859
  "create_payee",
552
860
  { description: "Create a new payee", inputSchema: {
553
861
  budgetId: z.string().optional().describe("Budget ID (uses default if not provided)"),
@@ -565,7 +873,7 @@ server.registerTool(
565
873
 
566
874
  // ==================== Payee Locations ====================
567
875
 
568
- server.registerTool(
876
+ registerTool(
569
877
  "list_payee_locations",
570
878
  { description: "List all payee locations (GPS coordinates where transactions occurred)", inputSchema: { budgetId: z.string().optional().describe("Budget ID (uses default if not provided)") } },
571
879
  ({ budgetId }) =>
@@ -575,7 +883,7 @@ server.registerTool(
575
883
  })
576
884
  );
577
885
 
578
- server.registerTool(
886
+ registerTool(
579
887
  "get_payee_location",
580
888
  { description: "Get a specific payee location", inputSchema: {
581
889
  budgetId: z.string().optional().describe("Budget ID (uses default if not provided)"),
@@ -588,7 +896,7 @@ server.registerTool(
588
896
  })
589
897
  );
590
898
 
591
- server.registerTool(
899
+ registerTool(
592
900
  "get_payee_locations_by_payee",
593
901
  { description: "Get all locations for a specific payee", inputSchema: {
594
902
  budgetId: z.string().optional().describe("Budget ID (uses default if not provided)"),
@@ -603,7 +911,7 @@ server.registerTool(
603
911
 
604
912
  // ==================== Months ====================
605
913
 
606
- server.registerTool(
914
+ registerTool(
607
915
  "list_months",
608
916
  { description: "List all budget months", inputSchema: {
609
917
  budgetId: z.string().optional().describe("Budget ID (uses default if not provided)"),
@@ -632,7 +940,7 @@ server.registerTool(
632
940
  })
633
941
  );
634
942
 
635
- server.registerTool(
943
+ registerTool(
636
944
  "get_month",
637
945
  { description: "Get budget month detail with per-category breakdown", inputSchema: {
638
946
  budgetId: z.string().optional().describe("Budget ID (uses default if not provided)"),
@@ -696,7 +1004,7 @@ function formatMoneyMovement(m) {
696
1004
  }, m, ["amount"]);
697
1005
  }
698
1006
 
699
- server.registerTool(
1007
+ registerTool(
700
1008
  "list_money_movements",
701
1009
  { description: "List all money movements (budget re-allocations between categories)", inputSchema: { budgetId: z.string().optional().describe("Budget ID (uses default if not provided)") } },
702
1010
  ({ budgetId }) =>
@@ -706,7 +1014,7 @@ server.registerTool(
706
1014
  })
707
1015
  );
708
1016
 
709
- server.registerTool(
1017
+ registerTool(
710
1018
  "get_money_movements_by_month",
711
1019
  { description: "Get money movements for a specific month", inputSchema: {
712
1020
  budgetId: z.string().optional().describe("Budget ID (uses default if not provided)"),
@@ -719,7 +1027,7 @@ server.registerTool(
719
1027
  })
720
1028
  );
721
1029
 
722
- server.registerTool(
1030
+ registerTool(
723
1031
  "list_money_movement_groups",
724
1032
  { description: "List all money movement groups (batches of related money movements)", inputSchema: { budgetId: z.string().optional().describe("Budget ID (uses default if not provided)") } },
725
1033
  ({ budgetId }) =>
@@ -729,7 +1037,7 @@ server.registerTool(
729
1037
  })
730
1038
  );
731
1039
 
732
- server.registerTool(
1040
+ registerTool(
733
1041
  "get_money_movement_groups_by_month",
734
1042
  { description: "Get money movement groups for a specific month", inputSchema: {
735
1043
  budgetId: z.string().optional().describe("Budget ID (uses default if not provided)"),
@@ -791,7 +1099,7 @@ function formatTransaction(t) {
791
1099
  return withCurrencyFields(out, t, ["amount"]);
792
1100
  }
793
1101
 
794
- server.registerTool(
1102
+ registerTool(
795
1103
  "get_transactions",
796
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: {
797
1105
  budgetId: z.string().optional().describe("Budget ID (uses default if not provided)"),
@@ -834,20 +1142,47 @@ server.registerTool(
834
1142
  })
835
1143
  );
836
1144
 
837
- server.registerTool(
1145
+ registerTool(
838
1146
  "get_transaction",
839
- { description: "Get a single transaction by ID. Automatically handles composite scheduled-transaction IDs (e.g. uuid_YYYY-MM-DD).", inputSchema: {
1147
+ { description: "Get a single transaction by ID. Automatically handles composite scheduled-transaction IDs (e.g. uuid_YYYY-MM-DD): the date suffix is stripped before the lookup. If a composite ID's underlying matched transaction has been deleted, falls back to returning the active scheduled-transaction template wrapped in a marker shape { resource_type: 'scheduled_transaction', reason: 'composite_id_with_no_matched_transaction', scheduled_transaction, requested_id } so callers can distinguish the two return shapes. Non-composite IDs preserve strict behavior: a 404 still surfaces as resource_not_found.", inputSchema: {
840
1148
  budgetId: z.string().optional().describe("Budget ID (uses default if not provided)"),
841
1149
  transactionId: z.string().describe("Transaction ID"),
842
1150
  } },
843
1151
  ({ budgetId, transactionId }) =>
844
1152
  run(async () => {
845
- const { data } = await api.transactions.getTransactionById(resolveBudgetId(budgetId), normalizeTransactionId(transactionId));
846
- return ok(formatTransaction(data.transaction));
1153
+ const bid = resolveBudgetId(budgetId);
1154
+ const normalizedId = normalizeTransactionId(transactionId);
1155
+ const isComposite = /_\d{4}-\d{2}-\d{2}$/.test(transactionId);
1156
+ try {
1157
+ const { data } = await api.transactions.getTransactionById(bid, normalizedId);
1158
+ return ok(formatTransaction(data.transaction));
1159
+ } catch (e) {
1160
+ // Only fall back for composite IDs on resource_not_found. Other errors
1161
+ // (auth, rate limit, network) and non-composite not-founds bubble up unchanged.
1162
+ if (!isComposite || e?.error?.name !== "resource_not_found") throw e;
1163
+ try {
1164
+ const { data } = await api.scheduledTransactions.getScheduledTransactionById(bid, normalizedId);
1165
+ return ok({
1166
+ resource_type: "scheduled_transaction",
1167
+ reason: "composite_id_with_no_matched_transaction",
1168
+ scheduled_transaction: formatScheduledTransaction(data.scheduled_transaction),
1169
+ requested_id: transactionId,
1170
+ });
1171
+ } catch (e2) {
1172
+ if (e2?.error?.name !== "resource_not_found") throw e2;
1173
+ throw {
1174
+ error: {
1175
+ id: "404",
1176
+ name: "resource_not_found",
1177
+ detail: `Resource not found (tried transaction ${normalizedId} and scheduled transaction ${normalizedId}; both returned not-found)`,
1178
+ },
1179
+ };
1180
+ }
1181
+ }
847
1182
  })
848
1183
  );
849
1184
 
850
- server.registerTool(
1185
+ registerTool(
851
1186
  "create_transaction",
852
1187
  { description: "Create a new transaction. Amounts are in dollars (positive for inflows, negative for outflows). Note: future-dated transactions cannot be created here - use create_scheduled_transaction instead.", inputSchema: {
853
1188
  budgetId: z.string().optional().describe("Budget ID (uses default if not provided)"),
@@ -879,7 +1214,7 @@ server.registerTool(
879
1214
  })
880
1215
  );
881
1216
 
882
- server.registerTool(
1217
+ registerTool(
883
1218
  "create_transactions",
884
1219
  { description: "Create multiple transactions at once. Amounts are in dollars. Returns created transactions and any duplicate import IDs. Future-dated transactions are not supported - use create_scheduled_transaction instead.", inputSchema: {
885
1220
  budgetId: z.string().optional().describe("Budget ID (uses default if not provided)"),
@@ -916,7 +1251,7 @@ server.registerTool(
916
1251
  })
917
1252
  );
918
1253
 
919
- server.registerTool(
1254
+ registerTool(
920
1255
  "update_transaction",
921
1256
  { description: "Update an existing transaction. Only provided fields are changed. Amounts in dollars.", inputSchema: {
922
1257
  budgetId: z.string().optional().describe("Budget ID (uses default if not provided)"),
@@ -941,7 +1276,7 @@ server.registerTool(
941
1276
  })
942
1277
  );
943
1278
 
944
- server.registerTool(
1279
+ registerTool(
945
1280
  "delete_transaction",
946
1281
  { description: "Delete a transaction", inputSchema: {
947
1282
  budgetId: z.string().optional().describe("Budget ID (uses default if not provided)"),
@@ -954,9 +1289,9 @@ server.registerTool(
954
1289
  })
955
1290
  );
956
1291
 
957
- server.registerTool(
1292
+ registerTool(
958
1293
  "update_transactions",
959
- { 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 in one round trip, include both 'categoryId' and 'approved: true' in the same entry. The response returns the full updated transaction objects, which can exceed 50KB for batches over ~50 transactions; if the response overflows, the update still succeeded verify by counting 'approved: true' occurrences in the saved result file.", inputSchema: {
1294
+ { description: "Batch update multiple transactions. Each transaction object must include its id and the fields to update. IMPORTANT: only use transaction IDs extracted from get_transactions / review_unapproved results — never compose IDs by hand (fabricated IDs return 'transaction does not exist in this budget' errors). For combined category+approval changes, include both 'categoryId' and 'approved: true' in the same entry. This tool refetches each transaction after the bulk update, verifies requested fields actually persisted, and retries mismatches once through single-transaction updates. Never trust review_unapproved counts alone after approving transactions; use this response's verification block or get_transaction to confirm fields.", inputSchema: {
960
1295
  budgetId: z.string().optional().describe("Budget ID (uses default if not provided)"),
961
1296
  transactions: z
962
1297
  .array(
@@ -975,21 +1310,126 @@ server.registerTool(
975
1310
  })
976
1311
  )
977
1312
  .describe("Array of transaction updates"),
1313
+ returnSummary: z.boolean().optional().describe("If true, return compact counts (updated_count, approved_count, and verification counts) instead of the full updated-transaction objects. Use for large batches (~50+) whose full response would exceed the inline tool-result limit; the write is performed identically either way."),
978
1314
  } },
979
- ({ budgetId, transactions: txns }) =>
1315
+ ({ budgetId, transactions: txns, returnSummary }) =>
980
1316
  run(async () => {
1317
+ const bid = resolveBudgetId(budgetId);
981
1318
  const mapped = txns.map((t) => ({ id: t.id, ...mapTransactionUpdate(t) }));
982
- const { data } = await api.transactions.updateTransactions(resolveBudgetId(budgetId), {
1319
+ const { data } = await api.transactions.updateTransactions(bid, {
983
1320
  transactions: mapped,
984
1321
  });
1322
+ const { verification, verified } = await verifyBulkTransactionUpdates(bid, txns);
1323
+ if (verification.failed.length > 0) {
1324
+ return {
1325
+ content: [{
1326
+ type: "text",
1327
+ text: `Error: Bulk transaction update verification failed after retry: ${JSON.stringify(verification.failed, null, 2)}`,
1328
+ }],
1329
+ isError: true,
1330
+ };
1331
+ }
1332
+ if (returnSummary) {
1333
+ return ok({
1334
+ updated_count: verified.length,
1335
+ approved_count: verified.filter((t) => t.approved).length,
1336
+ duplicate_import_ids: data.duplicate_import_ids,
1337
+ verification: {
1338
+ checked: verification.checked,
1339
+ retried: verification.retried.length,
1340
+ failed: verification.failed.length,
1341
+ },
1342
+ });
1343
+ }
985
1344
  return ok({
986
- updated: data.transactions?.map(formatTransaction),
1345
+ updated: verified,
987
1346
  duplicate_import_ids: data.duplicate_import_ids,
1347
+ verification,
1348
+ });
1349
+ })
1350
+ );
1351
+
1352
+ registerTool(
1353
+ "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: {
1355
+ budgetId: z.string().optional().describe("Budget ID (uses default if not provided)"),
1356
+ payeeId: z.string().optional().describe("Only approve unapproved transactions for this payee"),
1357
+ categoryId: z.string().optional().describe("Only approve unapproved transactions in this category"),
1358
+ accountId: z.string().optional().describe("Only approve unapproved transactions in this account"),
1359
+ includeUncategorized: z.boolean().optional().describe("If true, also approve transactions with no category (default false — uncategorized are skipped for safety)"),
1360
+ } },
1361
+ ({ budgetId, payeeId, categoryId, accountId, includeUncategorized }) =>
1362
+ run(async () => {
1363
+ const bid = resolveBudgetId(budgetId);
1364
+ const { data } = await api.transactions.getTransactions(bid, undefined, "unapproved");
1365
+ let txns = data.transactions.filter((t) => !t.deleted);
1366
+ if (payeeId) txns = txns.filter((t) => t.payee_id === payeeId);
1367
+ if (categoryId) txns = txns.filter((t) => t.category_id === categoryId);
1368
+ if (accountId) txns = txns.filter((t) => t.account_id === accountId);
1369
+ if (!includeUncategorized) {
1370
+ txns = txns.filter((t) => (t.category_id && t.category_name !== "Uncategorized") || t.transfer_account_id);
1371
+ }
1372
+ if (txns.length === 0) {
1373
+ return ok({ approved_count: 0, matched: 0, message: "No matching unapproved transactions to approve." });
1374
+ }
1375
+ const updates = txns.map((t) => ({ id: t.id, approved: true }));
1376
+ const mapped = updates.map((t) => ({ id: t.id, ...mapTransactionUpdate(t) }));
1377
+ const { data: updData } = await api.transactions.updateTransactions(bid, { transactions: mapped });
1378
+ const { verification, verified } = await verifyBulkTransactionUpdates(bid, updates);
1379
+ if (verification.failed.length > 0) {
1380
+ return {
1381
+ content: [{ type: "text", text: `Error: approval verification failed after retry: ${JSON.stringify(verification.failed, null, 2)}` }],
1382
+ isError: true,
1383
+ };
1384
+ }
1385
+ return ok({
1386
+ matched: txns.length,
1387
+ approved_count: verified.filter((t) => t.approved).length,
1388
+ filters: { payeeId: payeeId || null, categoryId: categoryId || null, accountId: accountId || null, includeUncategorized: !!includeUncategorized },
1389
+ duplicate_import_ids: updData.duplicate_import_ids,
1390
+ verification: { checked: verification.checked, retried: verification.retried.length, failed: verification.failed.length },
988
1391
  });
989
1392
  })
990
1393
  );
991
1394
 
992
- server.registerTool(
1395
+ registerTool(
1396
+ "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: {
1398
+ budgetId: z.string().optional().describe("Budget ID (uses default if not provided)"),
1399
+ fromPayeeId: z.string().describe("Payee whose transactions will be moved"),
1400
+ 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"),
1402
+ } },
1403
+ ({ budgetId, fromPayeeId, toPayeeId, sinceDate }) =>
1404
+ run(async () => {
1405
+ const bid = resolveBudgetId(budgetId);
1406
+ const { data } = await api.transactions.getTransactionsByPayee(bid, fromPayeeId, sinceDate);
1407
+ const txns = data.transactions.filter((t) => !t.deleted);
1408
+ if (txns.length === 0) {
1409
+ return ok({ reassigned_count: 0, message: "No transactions found for fromPayeeId in the given range." });
1410
+ }
1411
+ const updates = txns.map((t) => ({ id: t.id, payeeId: toPayeeId }));
1412
+ const mapped = updates.map((t) => ({ id: t.id, ...mapTransactionUpdate(t) }));
1413
+ const { data: updData } = await api.transactions.updateTransactions(bid, { transactions: mapped });
1414
+ const { verification, verified } = await verifyBulkTransactionUpdates(bid, updates);
1415
+ if (verification.failed.length > 0) {
1416
+ return {
1417
+ content: [{ type: "text", text: `Error: payee reassignment verification failed after retry: ${JSON.stringify(verification.failed, null, 2)}` }],
1418
+ isError: true,
1419
+ };
1420
+ }
1421
+ return ok({
1422
+ reassigned_count: verified.length,
1423
+ from_payee_id: fromPayeeId,
1424
+ to_payee_id: toPayeeId,
1425
+ duplicate_import_ids: updData.duplicate_import_ids,
1426
+ note: "Source payee is now empty but still exists; delete it in the YNAB UI (Settings → Manage Payees) if desired.",
1427
+ verification: { checked: verification.checked, retried: verification.retried.length, failed: verification.failed.length },
1428
+ });
1429
+ })
1430
+ );
1431
+
1432
+ registerTool(
993
1433
  "import_transactions",
994
1434
  { description: "Trigger import of linked account transactions", inputSchema: { budgetId: z.string().optional().describe("Budget ID (uses default if not provided)") } },
995
1435
  ({ budgetId }) =>
@@ -1041,7 +1481,7 @@ function formatScheduledTransaction(t) {
1041
1481
  return withCurrencyFields(out, t, ["amount"]);
1042
1482
  }
1043
1483
 
1044
- server.registerTool(
1484
+ registerTool(
1045
1485
  "list_scheduled_transactions",
1046
1486
  { description: "List all scheduled (recurring) transactions. NOTE: only manually-created recurring entries appear here — auto-imported recurring charges (subscriptions, utilities, insurance) are NOT included. Use prior-month transaction history to identify recurring charge timing instead.", inputSchema: {
1047
1487
  budgetId: z.string().optional().describe("Budget ID (uses default if not provided)"),
@@ -1055,7 +1495,7 @@ server.registerTool(
1055
1495
  })
1056
1496
  );
1057
1497
 
1058
- server.registerTool(
1498
+ registerTool(
1059
1499
  "get_scheduled_transaction",
1060
1500
  { description: "Get a specific scheduled transaction", inputSchema: {
1061
1501
  budgetId: z.string().optional().describe("Budget ID (uses default if not provided)"),
@@ -1068,7 +1508,7 @@ server.registerTool(
1068
1508
  })
1069
1509
  );
1070
1510
 
1071
- server.registerTool(
1511
+ registerTool(
1072
1512
  "create_scheduled_transaction",
1073
1513
  { description: "Create a new scheduled (recurring) transaction", inputSchema: {
1074
1514
  budgetId: z.string().optional().describe("Budget ID (uses default if not provided)"),
@@ -1101,7 +1541,7 @@ server.registerTool(
1101
1541
  })
1102
1542
  );
1103
1543
 
1104
- server.registerTool(
1544
+ registerTool(
1105
1545
  "update_scheduled_transaction",
1106
1546
  { description: "Update an existing scheduled transaction. Only provided fields are changed. Amounts in dollars.", inputSchema: {
1107
1547
  budgetId: z.string().optional().describe("Budget ID (uses default if not provided)"),
@@ -1144,7 +1584,7 @@ server.registerTool(
1144
1584
  })
1145
1585
  );
1146
1586
 
1147
- server.registerTool(
1587
+ registerTool(
1148
1588
  "delete_scheduled_transaction",
1149
1589
  { description: "Delete a scheduled transaction", inputSchema: {
1150
1590
  budgetId: z.string().optional().describe("Budget ID (uses default if not provided)"),
@@ -1159,7 +1599,7 @@ server.registerTool(
1159
1599
 
1160
1600
  // ==================== Convenience Tools ====================
1161
1601
 
1162
- server.registerTool(
1602
+ registerTool(
1163
1603
  "search_categories",
1164
1604
  { description: "Search categories by partial name match (case-insensitive). Useful for finding category IDs when you only know part of the name.", inputSchema: {
1165
1605
  budgetId: z.string().optional().describe("Budget ID (uses default if not provided)"),
@@ -1191,7 +1631,7 @@ server.registerTool(
1191
1631
  })
1192
1632
  );
1193
1633
 
1194
- server.registerTool(
1634
+ registerTool(
1195
1635
  "search_payees",
1196
1636
  { description: "Search payees by partial name match (case-insensitive). Useful for finding payee IDs.", inputSchema: {
1197
1637
  budgetId: z.string().optional().describe("Budget ID (uses default if not provided)"),
@@ -1209,13 +1649,14 @@ server.registerTool(
1209
1649
  })
1210
1650
  );
1211
1651
 
1212
- server.registerTool(
1652
+ registerTool(
1213
1653
  "review_unapproved",
1214
- { 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 — CANNOT be fixed via this API, requires YNAB web/iOS 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 to get counts + by-payee aggregates without per-transaction detail.", inputSchema: {
1654
+ { description: "Get all unapproved transactions grouped by status: those already categorized (ready to approve) and those still uncategorized (need category first). Each transaction includes a 'flags' array: manually_entered (not bank-imported), match_broken (matched reference is stale — the `matched_transaction_id` field is read-only via this API; YNAB web/iOS UI is required to clear that link. The transaction itself remains fully mutable: you CAN approve, recategorize, and edit memo via update_transaction. The broken match persists as a cosmetic flag until the user resolves it in the UI.), scheduled_transaction_realized, new_payee (no transaction history for this payee), no_prior_amount_match (novel amount for this payee), category_drift:was_X (payee categorized differently before). Never approve uncategorized transactions without explicit user instruction. For large budgets the full response can exceed 100KB; pass summary:true for counts + by-payee aggregates only, or compact:true to keep per-transaction rows (with IDs) while dropping bulky fields so the response fits inline.", inputSchema: {
1215
1655
  budgetId: z.string().optional().describe("Budget ID (uses default if not provided)"),
1216
1656
  summary: z.boolean().optional().describe("If true, omit per-transaction details from the response and return only counts + by-payee aggregates (for both ready_to_approve and needs_category_first). Use this when the full unapproved queue is large; drill into specifics with get_transactions afterwards."),
1657
+ compact: z.boolean().optional().describe("If true (and summary is not set), keep per-transaction detail but return only the fields needed to act — id, date, payee_name, amount, category_name, account_name, flags — dropping bulky fields (import strings, subtransactions, matched/import ids) that push the full response past the inline size limit. Use when you need transaction IDs to approve or recategorize but the full queue would overflow."),
1217
1658
  } },
1218
- ({ budgetId, summary }) =>
1659
+ ({ budgetId, summary, compact }) =>
1219
1660
  run(async () => {
1220
1661
  const bid = resolveBudgetId(budgetId);
1221
1662
 
@@ -1276,6 +1717,17 @@ server.registerTool(
1276
1717
  const categorized = [], uncategorized = [];
1277
1718
  for (const t of flaggedTxns) (isCategorized(t) ? categorized : uncategorized).push(t);
1278
1719
 
1720
+ // Compact projection: only the fields needed to act on a transaction
1721
+ const slimTx = (t) => ({
1722
+ id: t.id,
1723
+ date: t.date,
1724
+ payee_name: t.payee_name,
1725
+ amount: t.amount,
1726
+ category_name: t.category_name,
1727
+ account_name: t.account_name,
1728
+ flags: t.flags,
1729
+ });
1730
+
1279
1731
  // Group categorized transactions by payee for easier per-group review
1280
1732
  const byPayee = {};
1281
1733
  for (const t of categorized) {
@@ -1290,15 +1742,15 @@ server.registerTool(
1290
1742
  payee: g.payee,
1291
1743
  category_name: g.category_name,
1292
1744
  count: g.transactions.length,
1293
- total: g.transactions.reduce((sum, t) => sum + t.amount, 0),
1745
+ total: round2(g.transactions.reduce((sum, t) => sum + t.amount, 0)),
1294
1746
  flags: allFlags,
1295
1747
  };
1296
- return summary ? base : { ...base, transactions: g.transactions };
1748
+ return summary ? base : { ...base, transactions: compact ? g.transactions.map(slimTx) : g.transactions };
1297
1749
  });
1298
1750
 
1299
1751
  // Build uncategorized payload — full transactions by default, by-payee aggregates when summary:true
1300
1752
  const uncategorizedPayload = (() => {
1301
- if (!summary) return uncategorized;
1753
+ if (!summary) return compact ? uncategorized.map(slimTx) : uncategorized;
1302
1754
  const byPayeeUncat = {};
1303
1755
  for (const t of uncategorized) {
1304
1756
  const key = t.payee_name || "Unknown Payee";
@@ -1310,7 +1762,7 @@ server.registerTool(
1310
1762
  return Object.values(byPayeeUncat).map((g) => ({
1311
1763
  payee_name: g.payee_name,
1312
1764
  count: g.count,
1313
- total: g.total,
1765
+ total: round2(g.total),
1314
1766
  flags: [...g.flags],
1315
1767
  }));
1316
1768
  })();
@@ -1337,7 +1789,7 @@ server.registerTool(
1337
1789
  })
1338
1790
  );
1339
1791
 
1340
- server.registerTool(
1792
+ registerTool(
1341
1793
  "get_overspent_categories",
1342
1794
  { description: "Get all categories with a negative balance for a given month. Use this to find prior-month overspends that are silently reducing the current month's Ready to Assign.", inputSchema: {
1343
1795
  budgetId: z.string().optional().describe("Budget ID (uses default if not provided)"),
@@ -1360,12 +1812,105 @@ server.registerTool(
1360
1812
  return ok({
1361
1813
  month,
1362
1814
  overspent_count: overspent.length,
1363
- total_overspent: overspent.reduce((sum, c) => sum + c.balance, 0),
1815
+ total_overspent: round2(overspent.reduce((sum, c) => sum + c.balance, 0)),
1364
1816
  categories: overspent,
1365
1817
  });
1366
1818
  })
1367
1819
  );
1368
1820
 
1821
+ registerTool(
1822
+ "ynab_auth_status",
1823
+ {
1824
+ title: "YNAB Auth Status",
1825
+ description: "Check whether the YNAB MCP server has credentials configured and whether write tools are enabled.",
1826
+ inputSchema: {},
1827
+ },
1828
+ () => ok(ynabAuthStatus())
1829
+ );
1830
+
1831
+ registerTool(
1832
+ "ynab_tool_index",
1833
+ {
1834
+ title: "YNAB Tool Index",
1835
+ description: "Discover the YNAB MCP server tools. Use this when you need YNAB budgets, accounts, categories, payees, transactions, scheduled transactions, unapproved transaction review, approval, or budget cleanup tools.",
1836
+ inputSchema: {},
1837
+ },
1838
+ () => ok({
1839
+ server: "ynab-mcp-server",
1840
+ package: "@oliverames/ynab-mcp-server",
1841
+ auth: ynabAuthStatus(),
1842
+ writes_enabled: writesEnabled(),
1843
+ tools: listRegisteredYnabTools(),
1844
+ execute_with: "ynab_tool_execute",
1845
+ write_execute_with: writesEnabled() ? "ynab_write_tool_execute" : null,
1846
+ })
1847
+ );
1848
+
1849
+ registerTool(
1850
+ "ynab_tool_execute",
1851
+ {
1852
+ title: "Execute YNAB Tool",
1853
+ description: "Execute an existing read-only YNAB MCP tool by name. Use ynab_tool_index first to discover YNAB tool names, then pass the selected tool_name and its JSON input. Write-capable tools must be called directly or through ynab_write_tool_execute when YNAB_ALLOW_WRITES=1.",
1854
+ inputSchema: {
1855
+ tool_name: z.string().describe("Existing read-only YNAB tool name, such as review_unapproved, get_transactions, list_categories, search_categories, or search_payees."),
1856
+ input: z.record(z.string(), z.any()).optional().describe("JSON input for the selected YNAB tool. Omit or pass an empty object for tools that take no input."),
1857
+ },
1858
+ },
1859
+ async ({ tool_name: toolName, input = {} }) => {
1860
+ if (toolName.startsWith("ynab_")) {
1861
+ return {
1862
+ isError: true,
1863
+ content: [{ type: "text", text: "Refusing to execute YNAB discovery helper tools recursively." }],
1864
+ };
1865
+ }
1866
+ if (WRITE_TOOL_METADATA[toolName]) {
1867
+ return {
1868
+ isError: true,
1869
+ content: [{ type: "text", text: `${toolName} is a write-capable YNAB tool. Set YNAB_ALLOW_WRITES=1 and call it directly, or use ynab_write_tool_execute.` }],
1870
+ };
1871
+ }
1872
+ const tool = registeredTools.get(toolName);
1873
+ if (!tool) {
1874
+ return {
1875
+ isError: true,
1876
+ content: [{ type: "text", text: `Unknown YNAB tool: ${toolName}` }],
1877
+ };
1878
+ }
1879
+ return tool.handler(input);
1880
+ }
1881
+ );
1882
+
1883
+ registerTool(
1884
+ "ynab_write_tool_execute",
1885
+ {
1886
+ title: "Execute YNAB Write Tool",
1887
+ description: "Execute an existing write-capable YNAB MCP tool by name. This tool is registered only when YNAB_ALLOW_WRITES=1 and should be used only after explicit user confirmation.",
1888
+ inputSchema: {
1889
+ tool_name: z.string().describe("Existing write-capable YNAB tool name, such as update_transaction, update_transactions, approve_transactions, create_transaction, or delete_transaction."),
1890
+ input: z.record(z.string(), z.any()).optional().describe("JSON input for the selected YNAB write tool."),
1891
+ },
1892
+ },
1893
+ async ({ tool_name: toolName, input = {} }) => {
1894
+ if (toolName.startsWith("ynab_")) {
1895
+ return {
1896
+ isError: true,
1897
+ content: [{ type: "text", text: "Refusing to execute YNAB discovery helper tools recursively." }],
1898
+ };
1899
+ }
1900
+ if (!WRITE_TOOL_METADATA[toolName]) {
1901
+ return {
1902
+ isError: true,
1903
+ content: [{ type: "text", text: `${toolName} is not a write-capable YNAB tool. Use ynab_tool_execute for read-only tools.` }],
1904
+ };
1905
+ }
1906
+ const tool = registeredTools.get(toolName);
1907
+ if (!tool) {
1908
+ return writeDisabledResult(toolName);
1909
+ }
1910
+ return tool.handler(input);
1911
+ }
1912
+ );
1913
+
1369
1914
  // --- Start ---
1370
1915
 
1371
1916
  process.on("uncaughtException", (err) => {