@oliverames/ynab-mcp-server 1.6.0 → 2.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
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}`);
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}`);
28
45
  process.exit(1);
29
46
  }
30
47
 
31
- const api = new ynab.API(API_TOKEN);
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 };
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,14 +234,87 @@ 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 };
238
+ }
239
+ }
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");
130
311
  }
312
+ return new URL(`${BASE_URL}${path}`);
131
313
  }
132
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
  }
@@ -142,12 +323,19 @@ async function ynabFetch(path, { method = "GET", body, query } = {}) {
142
323
  headers: { Authorization: `Bearer ${API_TOKEN}`, "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,9 +345,87 @@ 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.0",
161
349
  });
162
350
 
351
+ const WRITE_TOOL_METADATA = {
352
+ create_account: { destructiveHint: false, idempotentHint: false },
353
+ update_month_category: { destructiveHint: false, idempotentHint: true },
354
+ update_category: { destructiveHint: false, idempotentHint: true },
355
+ create_category: { destructiveHint: false, idempotentHint: false },
356
+ create_category_group: { destructiveHint: false, idempotentHint: false },
357
+ update_category_group: { destructiveHint: false, idempotentHint: true },
358
+ update_payee: { destructiveHint: false, idempotentHint: true },
359
+ create_payee: { destructiveHint: false, idempotentHint: false },
360
+ create_transaction: { destructiveHint: false, idempotentHint: false },
361
+ create_transactions: { destructiveHint: false, idempotentHint: false },
362
+ update_transaction: { destructiveHint: false, idempotentHint: true },
363
+ delete_transaction: { destructiveHint: true, idempotentHint: true },
364
+ update_transactions: { destructiveHint: false, idempotentHint: true },
365
+ approve_transactions: { destructiveHint: false, idempotentHint: true },
366
+ reassign_payee_transactions: { destructiveHint: false, idempotentHint: true },
367
+ import_transactions: { destructiveHint: false, idempotentHint: false },
368
+ create_scheduled_transaction: { destructiveHint: false, idempotentHint: false },
369
+ update_scheduled_transaction: { destructiveHint: false, idempotentHint: true },
370
+ delete_scheduled_transaction: { destructiveHint: true, idempotentHint: true },
371
+ };
372
+
373
+ function writesEnabled() {
374
+ return process.env.YNAB_ALLOW_WRITES === "1";
375
+ }
376
+
377
+ function writeDisabledResult(name) {
378
+ return {
379
+ content: [{
380
+ type: "text",
381
+ text: `Error: ${name} is disabled. Restart the MCP server with YNAB_ALLOW_WRITES=1 to enable write tools.`,
382
+ }],
383
+ isError: true,
384
+ };
385
+ }
386
+
387
+ function withWriteGateDescription(description = "") {
388
+ if (description.includes("YNAB_ALLOW_WRITES=1")) return description;
389
+ return `${description} Requires YNAB_ALLOW_WRITES=1; write tools are not registered by default.`;
390
+ }
391
+
392
+ const registerRawTool = server.registerTool.bind(server);
393
+ server.registerTool = (name, config, handler) => {
394
+ const writeMetadata = WRITE_TOOL_METADATA[name];
395
+ if (!writeMetadata) {
396
+ return registerRawTool(name, {
397
+ ...config,
398
+ annotations: {
399
+ ...config.annotations,
400
+ readOnlyHint: true,
401
+ destructiveHint: false,
402
+ openWorldHint: true,
403
+ },
404
+ }, handler);
405
+ }
406
+
407
+ if (!writesEnabled()) {
408
+ return undefined;
409
+ }
410
+
411
+ return registerRawTool(name, {
412
+ ...config,
413
+ description: withWriteGateDescription(config.description),
414
+ annotations: {
415
+ ...config.annotations,
416
+ readOnlyHint: false,
417
+ destructiveHint: writeMetadata.destructiveHint,
418
+ idempotentHint: writeMetadata.idempotentHint,
419
+ openWorldHint: true,
420
+ },
421
+ }, (args, extra) => {
422
+ if (!writesEnabled()) {
423
+ return writeDisabledResult(name);
424
+ }
425
+ return handler(args, extra);
426
+ });
427
+ };
428
+
163
429
  // ==================== User & Budgets ====================
164
430
 
165
431
  server.registerTool(
@@ -793,7 +1059,7 @@ function formatTransaction(t) {
793
1059
 
794
1060
  server.registerTool(
795
1061
  "get_transactions",
796
- { description: "Get transactions with optional filters. Use type='unapproved' or type='uncategorized' to filter. Optionally filter by account, category, payee, or month.", inputSchema: {
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: {
797
1063
  budgetId: z.string().optional().describe("Budget ID (uses default if not provided)"),
798
1064
  sinceDate: z.string().optional().describe("Only return transactions on or after this date (YYYY-MM-DD)"),
799
1065
  type: z.enum(["unapproved", "uncategorized"]).optional().describe("Filter by approval/categorization status"),
@@ -836,14 +1102,41 @@ server.registerTool(
836
1102
 
837
1103
  server.registerTool(
838
1104
  "get_transaction",
839
- { description: "Get a single transaction by ID. Automatically handles composite scheduled-transaction IDs (e.g. uuid_YYYY-MM-DD).", inputSchema: {
1105
+ { 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
1106
  budgetId: z.string().optional().describe("Budget ID (uses default if not provided)"),
841
1107
  transactionId: z.string().describe("Transaction ID"),
842
1108
  } },
843
1109
  ({ budgetId, transactionId }) =>
844
1110
  run(async () => {
845
- const { data } = await api.transactions.getTransactionById(resolveBudgetId(budgetId), normalizeTransactionId(transactionId));
846
- return ok(formatTransaction(data.transaction));
1111
+ const bid = resolveBudgetId(budgetId);
1112
+ const normalizedId = normalizeTransactionId(transactionId);
1113
+ const isComposite = /_\d{4}-\d{2}-\d{2}$/.test(transactionId);
1114
+ try {
1115
+ const { data } = await api.transactions.getTransactionById(bid, normalizedId);
1116
+ return ok(formatTransaction(data.transaction));
1117
+ } catch (e) {
1118
+ // Only fall back for composite IDs on resource_not_found. Other errors
1119
+ // (auth, rate limit, network) and non-composite not-founds bubble up unchanged.
1120
+ if (!isComposite || e?.error?.name !== "resource_not_found") throw e;
1121
+ try {
1122
+ const { data } = await api.scheduledTransactions.getScheduledTransactionById(bid, normalizedId);
1123
+ return ok({
1124
+ resource_type: "scheduled_transaction",
1125
+ reason: "composite_id_with_no_matched_transaction",
1126
+ scheduled_transaction: formatScheduledTransaction(data.scheduled_transaction),
1127
+ requested_id: transactionId,
1128
+ });
1129
+ } catch (e2) {
1130
+ if (e2?.error?.name !== "resource_not_found") throw e2;
1131
+ throw {
1132
+ error: {
1133
+ id: "404",
1134
+ name: "resource_not_found",
1135
+ detail: `Resource not found (tried transaction ${normalizedId} and scheduled transaction ${normalizedId}; both returned not-found)`,
1136
+ },
1137
+ };
1138
+ }
1139
+ }
847
1140
  })
848
1141
  );
849
1142
 
@@ -956,7 +1249,7 @@ server.registerTool(
956
1249
 
957
1250
  server.registerTool(
958
1251
  "update_transactions",
959
- { description: "Batch update multiple transactions. Each transaction object must include its id and the fields to update.", inputSchema: {
1252
+ { 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
1253
  budgetId: z.string().optional().describe("Budget ID (uses default if not provided)"),
961
1254
  transactions: z
962
1255
  .array(
@@ -975,16 +1268,121 @@ server.registerTool(
975
1268
  })
976
1269
  )
977
1270
  .describe("Array of transaction updates"),
1271
+ 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
1272
  } },
979
- ({ budgetId, transactions: txns }) =>
1273
+ ({ budgetId, transactions: txns, returnSummary }) =>
980
1274
  run(async () => {
1275
+ const bid = resolveBudgetId(budgetId);
981
1276
  const mapped = txns.map((t) => ({ id: t.id, ...mapTransactionUpdate(t) }));
982
- const { data } = await api.transactions.updateTransactions(resolveBudgetId(budgetId), {
1277
+ const { data } = await api.transactions.updateTransactions(bid, {
983
1278
  transactions: mapped,
984
1279
  });
1280
+ const { verification, verified } = await verifyBulkTransactionUpdates(bid, txns);
1281
+ if (verification.failed.length > 0) {
1282
+ return {
1283
+ content: [{
1284
+ type: "text",
1285
+ text: `Error: Bulk transaction update verification failed after retry: ${JSON.stringify(verification.failed, null, 2)}`,
1286
+ }],
1287
+ isError: true,
1288
+ };
1289
+ }
1290
+ if (returnSummary) {
1291
+ return ok({
1292
+ updated_count: verified.length,
1293
+ approved_count: verified.filter((t) => t.approved).length,
1294
+ duplicate_import_ids: data.duplicate_import_ids,
1295
+ verification: {
1296
+ checked: verification.checked,
1297
+ retried: verification.retried.length,
1298
+ failed: verification.failed.length,
1299
+ },
1300
+ });
1301
+ }
985
1302
  return ok({
986
- updated: data.transactions?.map(formatTransaction),
1303
+ updated: verified,
987
1304
  duplicate_import_ids: data.duplicate_import_ids,
1305
+ verification,
1306
+ });
1307
+ })
1308
+ );
1309
+
1310
+ server.registerTool(
1311
+ "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: {
1313
+ budgetId: z.string().optional().describe("Budget ID (uses default if not provided)"),
1314
+ payeeId: z.string().optional().describe("Only approve unapproved transactions for this payee"),
1315
+ categoryId: z.string().optional().describe("Only approve unapproved transactions in this category"),
1316
+ accountId: z.string().optional().describe("Only approve unapproved transactions in this account"),
1317
+ includeUncategorized: z.boolean().optional().describe("If true, also approve transactions with no category (default false — uncategorized are skipped for safety)"),
1318
+ } },
1319
+ ({ budgetId, payeeId, categoryId, accountId, includeUncategorized }) =>
1320
+ run(async () => {
1321
+ const bid = resolveBudgetId(budgetId);
1322
+ const { data } = await api.transactions.getTransactions(bid, undefined, "unapproved");
1323
+ let txns = data.transactions.filter((t) => !t.deleted);
1324
+ if (payeeId) txns = txns.filter((t) => t.payee_id === payeeId);
1325
+ if (categoryId) txns = txns.filter((t) => t.category_id === categoryId);
1326
+ if (accountId) txns = txns.filter((t) => t.account_id === accountId);
1327
+ if (!includeUncategorized) {
1328
+ txns = txns.filter((t) => (t.category_id && t.category_name !== "Uncategorized") || t.transfer_account_id);
1329
+ }
1330
+ if (txns.length === 0) {
1331
+ return ok({ approved_count: 0, matched: 0, message: "No matching unapproved transactions to approve." });
1332
+ }
1333
+ const updates = txns.map((t) => ({ id: t.id, approved: true }));
1334
+ const mapped = updates.map((t) => ({ id: t.id, ...mapTransactionUpdate(t) }));
1335
+ const { data: updData } = await api.transactions.updateTransactions(bid, { transactions: mapped });
1336
+ const { verification, verified } = await verifyBulkTransactionUpdates(bid, updates);
1337
+ if (verification.failed.length > 0) {
1338
+ return {
1339
+ content: [{ type: "text", text: `Error: approval verification failed after retry: ${JSON.stringify(verification.failed, null, 2)}` }],
1340
+ isError: true,
1341
+ };
1342
+ }
1343
+ return ok({
1344
+ matched: txns.length,
1345
+ approved_count: verified.filter((t) => t.approved).length,
1346
+ filters: { payeeId: payeeId || null, categoryId: categoryId || null, accountId: accountId || null, includeUncategorized: !!includeUncategorized },
1347
+ duplicate_import_ids: updData.duplicate_import_ids,
1348
+ verification: { checked: verification.checked, retried: verification.retried.length, failed: verification.failed.length },
1349
+ });
1350
+ })
1351
+ );
1352
+
1353
+ server.registerTool(
1354
+ "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: {
1356
+ budgetId: z.string().optional().describe("Budget ID (uses default if not provided)"),
1357
+ fromPayeeId: z.string().describe("Payee whose transactions will be moved"),
1358
+ 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"),
1360
+ } },
1361
+ ({ budgetId, fromPayeeId, toPayeeId, sinceDate }) =>
1362
+ run(async () => {
1363
+ const bid = resolveBudgetId(budgetId);
1364
+ const { data } = await api.transactions.getTransactionsByPayee(bid, fromPayeeId, sinceDate);
1365
+ const txns = data.transactions.filter((t) => !t.deleted);
1366
+ if (txns.length === 0) {
1367
+ return ok({ reassigned_count: 0, message: "No transactions found for fromPayeeId in the given range." });
1368
+ }
1369
+ const updates = txns.map((t) => ({ id: t.id, payeeId: toPayeeId }));
1370
+ const mapped = updates.map((t) => ({ id: t.id, ...mapTransactionUpdate(t) }));
1371
+ const { data: updData } = await api.transactions.updateTransactions(bid, { transactions: mapped });
1372
+ const { verification, verified } = await verifyBulkTransactionUpdates(bid, updates);
1373
+ if (verification.failed.length > 0) {
1374
+ return {
1375
+ content: [{ type: "text", text: `Error: payee reassignment verification failed after retry: ${JSON.stringify(verification.failed, null, 2)}` }],
1376
+ isError: true,
1377
+ };
1378
+ }
1379
+ return ok({
1380
+ reassigned_count: verified.length,
1381
+ from_payee_id: fromPayeeId,
1382
+ to_payee_id: toPayeeId,
1383
+ duplicate_import_ids: updData.duplicate_import_ids,
1384
+ note: "Source payee is now empty but still exists; delete it in the YNAB UI (Settings → Manage Payees) if desired.",
1385
+ verification: { checked: verification.checked, retried: verification.retried.length, failed: verification.failed.length },
988
1386
  });
989
1387
  })
990
1388
  );
@@ -1211,8 +1609,12 @@ server.registerTool(
1211
1609
 
1212
1610
  server.registerTool(
1213
1611
  "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 may be stale), 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.", inputSchema: { budgetId: z.string().optional().describe("Budget ID (uses default if not provided)") } },
1215
- ({ budgetId }) =>
1612
+ { 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
+ budgetId: z.string().optional().describe("Budget ID (uses default if not provided)"),
1614
+ 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."),
1615
+ 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."),
1616
+ } },
1617
+ ({ budgetId, summary, compact }) =>
1216
1618
  run(async () => {
1217
1619
  const bid = resolveBudgetId(budgetId);
1218
1620
 
@@ -1273,6 +1675,17 @@ server.registerTool(
1273
1675
  const categorized = [], uncategorized = [];
1274
1676
  for (const t of flaggedTxns) (isCategorized(t) ? categorized : uncategorized).push(t);
1275
1677
 
1678
+ // Compact projection: only the fields needed to act on a transaction
1679
+ const slimTx = (t) => ({
1680
+ id: t.id,
1681
+ date: t.date,
1682
+ payee_name: t.payee_name,
1683
+ amount: t.amount,
1684
+ category_name: t.category_name,
1685
+ account_name: t.account_name,
1686
+ flags: t.flags,
1687
+ });
1688
+
1276
1689
  // Group categorized transactions by payee for easier per-group review
1277
1690
  const byPayee = {};
1278
1691
  for (const t of categorized) {
@@ -1283,25 +1696,53 @@ server.registerTool(
1283
1696
  const groups = Object.values(byPayee).map((g) => {
1284
1697
  // Aggregate flags across all transactions in the group (deduplicated)
1285
1698
  const allFlags = [...new Set(g.transactions.flatMap((t) => t.flags))];
1286
- return {
1287
- ...g,
1699
+ const base = {
1700
+ payee: g.payee,
1701
+ category_name: g.category_name,
1288
1702
  count: g.transactions.length,
1289
- total: g.transactions.reduce((sum, t) => sum + t.amount, 0),
1703
+ total: round2(g.transactions.reduce((sum, t) => sum + t.amount, 0)),
1290
1704
  flags: allFlags,
1291
1705
  };
1706
+ return summary ? base : { ...base, transactions: compact ? g.transactions.map(slimTx) : g.transactions };
1292
1707
  });
1293
1708
 
1709
+ // Build uncategorized payload — full transactions by default, by-payee aggregates when summary:true
1710
+ const uncategorizedPayload = (() => {
1711
+ if (!summary) return compact ? uncategorized.map(slimTx) : uncategorized;
1712
+ const byPayeeUncat = {};
1713
+ for (const t of uncategorized) {
1714
+ const key = t.payee_name || "Unknown Payee";
1715
+ if (!byPayeeUncat[key]) byPayeeUncat[key] = { payee_name: key, count: 0, total: 0, flags: new Set() };
1716
+ byPayeeUncat[key].count += 1;
1717
+ byPayeeUncat[key].total += t.amount;
1718
+ for (const f of t.flags) byPayeeUncat[key].flags.add(f);
1719
+ }
1720
+ return Object.values(byPayeeUncat).map((g) => ({
1721
+ payee_name: g.payee_name,
1722
+ count: g.count,
1723
+ total: round2(g.total),
1724
+ flags: [...g.flags],
1725
+ }));
1726
+ })();
1727
+
1728
+ const needsCategoryFirst = {
1729
+ count: uncategorized.length,
1730
+ warning: "Do NOT approve these without assigning a category first",
1731
+ };
1732
+ if (summary) {
1733
+ needsCategoryFirst.payees = uncategorizedPayload;
1734
+ } else {
1735
+ needsCategoryFirst.transactions = uncategorizedPayload;
1736
+ }
1737
+
1294
1738
  return ok({
1295
1739
  total: flaggedTxns.length,
1740
+ summary: !!summary,
1296
1741
  ready_to_approve: {
1297
1742
  count: categorized.length,
1298
1743
  by_payee: groups,
1299
1744
  },
1300
- needs_category_first: {
1301
- count: uncategorized.length,
1302
- warning: "Do NOT approve these without assigning a category first",
1303
- transactions: uncategorized,
1304
- },
1745
+ needs_category_first: needsCategoryFirst,
1305
1746
  });
1306
1747
  })
1307
1748
  );
@@ -1329,7 +1770,7 @@ server.registerTool(
1329
1770
  return ok({
1330
1771
  month,
1331
1772
  overspent_count: overspent.length,
1332
- total_overspent: overspent.reduce((sum, c) => sum + c.balance, 0),
1773
+ total_overspent: round2(overspent.reduce((sum, c) => sum + c.balance, 0)),
1333
1774
  categories: overspent,
1334
1775
  });
1335
1776
  })