@oliverames/ynab-mcp-server 1.7.1 → 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);
130
305
  }
131
306
  }
132
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
  }
@@ -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(
@@ -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. 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: {
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,11 +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 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: {
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: {
1215
1613
  budgetId: z.string().optional().describe("Budget ID (uses default if not provided)"),
1216
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."),
1217
1616
  } },
1218
- ({ budgetId, summary }) =>
1617
+ ({ budgetId, summary, compact }) =>
1219
1618
  run(async () => {
1220
1619
  const bid = resolveBudgetId(budgetId);
1221
1620
 
@@ -1276,6 +1675,17 @@ server.registerTool(
1276
1675
  const categorized = [], uncategorized = [];
1277
1676
  for (const t of flaggedTxns) (isCategorized(t) ? categorized : uncategorized).push(t);
1278
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
+
1279
1689
  // Group categorized transactions by payee for easier per-group review
1280
1690
  const byPayee = {};
1281
1691
  for (const t of categorized) {
@@ -1290,15 +1700,15 @@ server.registerTool(
1290
1700
  payee: g.payee,
1291
1701
  category_name: g.category_name,
1292
1702
  count: g.transactions.length,
1293
- total: g.transactions.reduce((sum, t) => sum + t.amount, 0),
1703
+ total: round2(g.transactions.reduce((sum, t) => sum + t.amount, 0)),
1294
1704
  flags: allFlags,
1295
1705
  };
1296
- return summary ? base : { ...base, transactions: g.transactions };
1706
+ return summary ? base : { ...base, transactions: compact ? g.transactions.map(slimTx) : g.transactions };
1297
1707
  });
1298
1708
 
1299
1709
  // Build uncategorized payload — full transactions by default, by-payee aggregates when summary:true
1300
1710
  const uncategorizedPayload = (() => {
1301
- if (!summary) return uncategorized;
1711
+ if (!summary) return compact ? uncategorized.map(slimTx) : uncategorized;
1302
1712
  const byPayeeUncat = {};
1303
1713
  for (const t of uncategorized) {
1304
1714
  const key = t.payee_name || "Unknown Payee";
@@ -1310,7 +1720,7 @@ server.registerTool(
1310
1720
  return Object.values(byPayeeUncat).map((g) => ({
1311
1721
  payee_name: g.payee_name,
1312
1722
  count: g.count,
1313
- total: g.total,
1723
+ total: round2(g.total),
1314
1724
  flags: [...g.flags],
1315
1725
  }));
1316
1726
  })();
@@ -1360,7 +1770,7 @@ server.registerTool(
1360
1770
  return ok({
1361
1771
  month,
1362
1772
  overspent_count: overspent.length,
1363
- total_overspent: overspent.reduce((sum, c) => sum + c.balance, 0),
1773
+ total_overspent: round2(overspent.reduce((sum, c) => sum + c.balance, 0)),
1364
1774
  categories: overspent,
1365
1775
  });
1366
1776
  })
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@oliverames/ynab-mcp-server",
3
- "version": "1.7.1",
3
+ "version": "2.1.0",
4
4
  "description": "YNAB MCP server with full API coverage",
5
5
  "type": "module",
6
6
  "main": "index.js",
@@ -8,12 +8,22 @@
8
8
  "ynab-mcp-server": "index.js"
9
9
  },
10
10
  "files": [
11
- "index.js"
11
+ "index.js",
12
+ "scripts/",
13
+ "assets/icon.png",
14
+ "docs/hosted-oauth-connector.md"
12
15
  ],
13
16
  "scripts": {
14
17
  "start": "node index.js",
15
- "pretest": "[ -d node_modules ] || npm ci --silent --no-audit --no-fund",
16
- "test": "node test.js"
18
+ "pretest": "node --input-type=module -e \"await import('@modelcontextprotocol/sdk/client/index.js')\" >/dev/null 2>&1 || npm ci --silent --no-audit --no-fund",
19
+ "test": "node test.js",
20
+ "smoke:list-tools": "node scripts/smoke-list-tools.mjs",
21
+ "smoke:review-unapproved": "node scripts/smoke-review-unapproved.mjs",
22
+ "smoke:batch-verify": "node scripts/smoke-batch-verify.mjs",
23
+ "release:check": "node scripts/check-release-consistency.mjs",
24
+ "release:check:registry": "node scripts/check-release-consistency.mjs --registry",
25
+ "build:mcpb": "node scripts/build-mcpb.mjs",
26
+ "test:safety": "node scripts/test-safety-model.mjs"
17
27
  },
18
28
  "dependencies": {
19
29
  "@modelcontextprotocol/sdk": "^1.29.0",