@oliverames/ynab-mcp-server 1.4.0 → 1.7.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.
Files changed (3) hide show
  1. package/README.md +14 -4
  2. package/index.js +156 -20
  3. package/package.json +2 -1
package/README.md CHANGED
@@ -1,5 +1,5 @@
1
1
  <p align="center">
2
- <img src="https://api.ynab.com/papi/logo_api_meadow.svg" alt="YNAB API" width="200">
2
+ <img src="assets/icon.png" width="80" height="80" alt="YNAB">
3
3
  </p>
4
4
 
5
5
  <h1 align="center">YNAB MCP Server</h1>
@@ -17,12 +17,14 @@
17
17
 
18
18
  <p align="center">
19
19
  <a href="https://www.npmjs.com/package/@oliverames/ynab-mcp-server"><img src="https://img.shields.io/npm/v/%40oliverames%2Fynab-mcp-server?style=flat-square&color=f5a542" alt="npm"></a>
20
+ <a href="https://github.com/oliverames/ynab-mcp-server/releases/tag/v1.4.0"><img src="https://img.shields.io/github/v/release/oliverames/ynab-mcp-server?style=flat-square&color=f5a542&label=MCPB" alt="MCPB release"></a>
20
21
  <a href="LICENSE"><img src="https://img.shields.io/badge/license-MIT-f5a542?style=flat-square" alt="License"></a>
21
22
  <a href="https://www.buymeacoffee.com/oliverames"><img src="https://img.shields.io/badge/Buy_Me_a_Coffee-support-f5a542?style=flat-square&logo=buy-me-a-coffee&logoColor=white" alt="Buy Me a Coffee"></a>
22
23
  </p>
23
24
 
24
25
  <p align="center">
25
26
  <a href="#quick-start">Quick Start</a> &bull;
27
+ <a href="#install-with-mcpb">MCPB Download</a> &bull;
26
28
  <a href="#what-you-can-do">What You Can Do</a> &bull;
27
29
  <a href="#tools-reference">All 44 Tools</a> &bull;
28
30
  <a href="#environment-variables">Configuration</a>
@@ -40,6 +42,14 @@ This server gives your AI assistant full access to YNAB's API, turning natural l
40
42
 
41
43
  ## Quick Start
42
44
 
45
+ ### Install with MCPB
46
+
47
+ For Claude Desktop and other MCPB-compatible clients, download the local bundle from the [v1.4.0 release](https://github.com/oliverames/ynab-mcp-server/releases/tag/v1.4.0):
48
+
49
+ [Download `ynab-mcp-server-1.4.0.mcpb`](https://github.com/oliverames/ynab-mcp-server/releases/download/v1.4.0/ynab-mcp-server-1.4.0.mcpb)
50
+
51
+ The bundle includes the YNAB favicon, production runtime dependencies, and setup prompts for your personal access token and optional default budget ID.
52
+
43
53
  ### 1. Get a YNAB Personal Access Token
44
54
 
45
55
  Go to [YNAB Developer Settings](https://app.ynab.com/settings/developer) and create a new personal access token.
@@ -144,7 +154,7 @@ That's it. Your AI can now talk to YNAB.
144
154
  - **Bulk operations** - `create_transactions` and `update_transactions` handle arrays in a single API call.
145
155
  - **Fetch-then-merge updates** - scheduled transaction updates (which use PUT semantics) automatically fetch the current state and merge your changes, so you only specify what changed.
146
156
  - **Fuzzy search** - `search_categories` and `search_payees` do case-insensitive partial matching across all entries.
147
- - **Approval workflow** - `review_unapproved` groups transactions into "ready to approve" (categorized, split, or transfer) and "needs attention" (uncategorized), with a built-in warning against approving uncategorized entries.
157
+ - **Approval workflow with anomaly flags** - `review_unapproved` groups transactions into "ready to approve" (categorized, split, or transfer) and "needs attention" (uncategorized), and attaches a `flags` array to each transaction surfacing anomalies: `manually_entered` (not bank-imported), `match_broken` (stale match reference), `scheduled_transaction_realized`, `new_payee`, `no_prior_amount_match` (novel amount for this payee), and `category_drift:was_X` (payee categorized differently in the prior 60 days). Group-level flags aggregate the union of all transaction flags.
148
158
  - **Nullable updates** - update tools accept `null` for clearable fields (`memo`, `payeeName`, `categoryId`, `flagColor`) to distinguish "don't change" (omit) from "clear this field" (`null`).
149
159
  - **Target behavior support** - category create/update tools expose `goalNeedsWholeAmount` for YNAB's "Set aside another" vs. "Refill up to" goal behavior.
150
160
  - **Delta request support** - high-volume list tools accept `lastKnowledgeOfServer` and return `server_knowledge` when that parameter is provided.
@@ -226,7 +236,7 @@ That's it. Your AI can now talk to YNAB.
226
236
  | Tool | Description |
227
237
  |------|-------------|
228
238
  | `get_transactions` | Get transactions with filters: by account, category, payee, month, or status (`unapproved`/`uncategorized`) |
229
- | `get_transaction` | Get a single transaction by ID (includes subtransactions) |
239
+ | `get_transaction` | Get a single transaction by ID (includes subtransactions). Auto-handles composite scheduled-transaction IDs like `uuid_YYYY-MM-DD`. |
230
240
  | `create_transaction` | Create a transaction with optional split (subtransactions must sum to total) |
231
241
  | `create_transactions` | Bulk create multiple transactions in a single API call (supports split transactions) |
232
242
  | `update_transaction` | Partial update - only specified fields change |
@@ -250,7 +260,7 @@ That's it. Your AI can now talk to YNAB.
250
260
 
251
261
  | Tool | Description |
252
262
  |------|-------------|
253
- | `review_unapproved` | Get unapproved transactions grouped by readiness: "ready to approve" (categorized, split, or transfer) vs. "needs category first" (uncategorized). Includes a warning against blind approval. |
263
+ | `review_unapproved` | Get unapproved transactions grouped by readiness: "ready to approve" (categorized, split, or transfer) vs. "needs category first" (uncategorized). Each transaction includes a `flags` array highlighting anomalies (manually_entered, match_broken, no_prior_amount_match, category_drift, new_payee, scheduled_transaction_realized) computed against 60 days of payee history. Includes a warning against blind approval. |
254
264
 
255
265
  ---
256
266
 
package/index.js CHANGED
@@ -101,6 +101,12 @@ function mapTransactionUpdate(t) {
101
101
  return out;
102
102
  }
103
103
 
104
+ // YNAB scheduled transactions that realize get composite IDs like `uuid_YYYY-MM-DD`.
105
+ // Strip the date suffix so API lookups work correctly.
106
+ function normalizeTransactionId(id) {
107
+ return id.replace(/_\d{4}-\d{2}-\d{2}$/, "");
108
+ }
109
+
104
110
  function ok(data) {
105
111
  return { content: [{ type: "text", text: JSON.stringify(data, null, 2) }] };
106
112
  }
@@ -151,7 +157,7 @@ async function ynabFetch(path, { method = "GET", body, query } = {}) {
151
157
 
152
158
  const server = new McpServer({
153
159
  name: "ynab-mcp-server",
154
- version: "1.3.0",
160
+ version: "1.6.0",
155
161
  });
156
162
 
157
163
  // ==================== User & Budgets ====================
@@ -787,7 +793,7 @@ function formatTransaction(t) {
787
793
 
788
794
  server.registerTool(
789
795
  "get_transactions",
790
- { description: "Get transactions with optional filters. Use type='unapproved' or type='uncategorized' to filter. Optionally filter by account, category, payee, or month.", inputSchema: {
796
+ { 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: {
791
797
  budgetId: z.string().optional().describe("Budget ID (uses default if not provided)"),
792
798
  sinceDate: z.string().optional().describe("Only return transactions on or after this date (YYYY-MM-DD)"),
793
799
  type: z.enum(["unapproved", "uncategorized"]).optional().describe("Filter by approval/categorization status"),
@@ -830,13 +836,13 @@ server.registerTool(
830
836
 
831
837
  server.registerTool(
832
838
  "get_transaction",
833
- { description: "Get a single transaction by ID", inputSchema: {
839
+ { description: "Get a single transaction by ID. Automatically handles composite scheduled-transaction IDs (e.g. uuid_YYYY-MM-DD).", inputSchema: {
834
840
  budgetId: z.string().optional().describe("Budget ID (uses default if not provided)"),
835
841
  transactionId: z.string().describe("Transaction ID"),
836
842
  } },
837
843
  ({ budgetId, transactionId }) =>
838
844
  run(async () => {
839
- const { data } = await api.transactions.getTransactionById(resolveBudgetId(budgetId), transactionId);
845
+ const { data } = await api.transactions.getTransactionById(resolveBudgetId(budgetId), normalizeTransactionId(transactionId));
840
846
  return ok(formatTransaction(data.transaction));
841
847
  })
842
848
  );
@@ -950,7 +956,7 @@ server.registerTool(
950
956
 
951
957
  server.registerTool(
952
958
  "update_transactions",
953
- { description: "Batch update multiple transactions. Each transaction object must include its id and the fields to update.", inputSchema: {
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: {
954
960
  budgetId: z.string().optional().describe("Budget ID (uses default if not provided)"),
955
961
  transactions: z
956
962
  .array(
@@ -1037,7 +1043,7 @@ function formatScheduledTransaction(t) {
1037
1043
 
1038
1044
  server.registerTool(
1039
1045
  "list_scheduled_transactions",
1040
- { description: "List all scheduled (recurring) transactions", inputSchema: {
1046
+ { 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: {
1041
1047
  budgetId: z.string().optional().describe("Budget ID (uses default if not provided)"),
1042
1048
  lastKnowledgeOfServer: z.number().int().nonnegative().optional().describe("Delta request server knowledge. When provided, returns { scheduled_transactions, server_knowledge }."),
1043
1049
  } },
@@ -1205,27 +1211,157 @@ server.registerTool(
1205
1211
 
1206
1212
  server.registerTool(
1207
1213
  "review_unapproved",
1208
- { description: "Get all unapproved transactions grouped by status: those already categorized (ready to approve) and those still uncategorized (need category first). Never approve uncategorized transactions without explicit user instruction.", inputSchema: { budgetId: z.string().optional().describe("Budget ID (uses default if not provided)") } },
1209
- ({ budgetId }) =>
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: {
1215
+ budgetId: z.string().optional().describe("Budget ID (uses default if not provided)"),
1216
+ 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."),
1217
+ } },
1218
+ ({ budgetId, summary }) =>
1210
1219
  run(async () => {
1211
- const { data } = await api.transactions.getTransactions(resolveBudgetId(budgetId), undefined, "unapproved");
1212
- const txns = data.transactions.map(formatTransaction);
1220
+ const bid = resolveBudgetId(budgetId);
1221
+
1222
+ // Fetch unapproved transactions
1223
+ const { data: unapprovedData } = await api.transactions.getTransactions(bid, undefined, "unapproved");
1224
+ const txns = unapprovedData.transactions.map(formatTransaction);
1225
+ const unapprovedIds = new Set(txns.map((t) => t.id));
1226
+
1227
+ // Fetch 60 days of approved history for context
1228
+ const since60 = new Date(Date.now() - 60 * 24 * 60 * 60 * 1000).toISOString().slice(0, 10);
1229
+ const { data: histData } = await api.transactions.getTransactions(bid, since60);
1230
+ const histTxns = histData.transactions.filter((t) => t.approved && !unapprovedIds.has(t.id));
1231
+
1232
+ // Build payee history lookups (using raw milliunits for history, convert to dollars for the set)
1233
+ const payeeAmounts = {}; // payeeId -> Set of dollar amounts seen
1234
+ const payeeCategories = {}; // payeeId -> Map of categoryId -> categoryName
1235
+ for (const h of histTxns) {
1236
+ if (!h.payee_id) continue;
1237
+ const pid = h.payee_id;
1238
+ const amt = dollars(h.amount);
1239
+ const cid = h.category_id;
1240
+ const cname = h.category_name;
1241
+ if (!payeeAmounts[pid]) payeeAmounts[pid] = new Set();
1242
+ payeeAmounts[pid].add(amt);
1243
+ if (cid) {
1244
+ if (!payeeCategories[pid]) payeeCategories[pid] = new Map();
1245
+ payeeCategories[pid].set(cid, cname);
1246
+ }
1247
+ }
1248
+
1249
+ // Attach flags to each unapproved transaction
1250
+ function flagTransaction(t) {
1251
+ const flags = [];
1252
+ const isTransfer = !!t.transfer_account_id;
1253
+ if (!t.import_id && !isTransfer) flags.push("manually_entered");
1254
+ if (t.matched_transaction_id && !t.import_id) flags.push("match_broken");
1255
+ if (/_\d{4}-\d{2}-\d{2}$/.test(t.id)) flags.push("scheduled_transaction_realized");
1256
+ if (t.payee_id) {
1257
+ const hasHistory = !!payeeAmounts[t.payee_id];
1258
+ if (!hasHistory) {
1259
+ flags.push("new_payee");
1260
+ } else {
1261
+ if (!payeeAmounts[t.payee_id].has(t.amount)) flags.push("no_prior_amount_match");
1262
+ if (t.category_id && payeeCategories[t.payee_id] && !payeeCategories[t.payee_id].has(t.category_id)) {
1263
+ const priorNames = [...payeeCategories[t.payee_id].values()].join(", ");
1264
+ flags.push(`category_drift:was_${priorNames}`);
1265
+ }
1266
+ }
1267
+ }
1268
+ return { ...t, flags };
1269
+ }
1270
+
1271
+ const flaggedTxns = txns.map(flagTransaction);
1272
+
1213
1273
  const isCategorized = (t) => (t.category_id && t.category_name !== "Uncategorized")
1214
- || (t.subtransactions && t.subtransactions.length > 0) // split transactions are categorized via subtransactions
1215
- || t.transfer_account_id; // transfers don't need categories
1274
+ || (t.subtransactions && t.subtransactions.length > 0)
1275
+ || t.transfer_account_id;
1216
1276
  const categorized = [], uncategorized = [];
1217
- for (const t of txns) (isCategorized(t) ? categorized : uncategorized).push(t);
1277
+ for (const t of flaggedTxns) (isCategorized(t) ? categorized : uncategorized).push(t);
1278
+
1279
+ // Group categorized transactions by payee for easier per-group review
1280
+ const byPayee = {};
1281
+ for (const t of categorized) {
1282
+ const key = t.payee_name || "Unknown Payee";
1283
+ if (!byPayee[key]) byPayee[key] = { payee: key, category_name: t.category_name, transactions: [] };
1284
+ byPayee[key].transactions.push(t);
1285
+ }
1286
+ const groups = Object.values(byPayee).map((g) => {
1287
+ // Aggregate flags across all transactions in the group (deduplicated)
1288
+ const allFlags = [...new Set(g.transactions.flatMap((t) => t.flags))];
1289
+ const base = {
1290
+ payee: g.payee,
1291
+ category_name: g.category_name,
1292
+ count: g.transactions.length,
1293
+ total: g.transactions.reduce((sum, t) => sum + t.amount, 0),
1294
+ flags: allFlags,
1295
+ };
1296
+ return summary ? base : { ...base, transactions: g.transactions };
1297
+ });
1298
+
1299
+ // Build uncategorized payload — full transactions by default, by-payee aggregates when summary:true
1300
+ const uncategorizedPayload = (() => {
1301
+ if (!summary) return uncategorized;
1302
+ const byPayeeUncat = {};
1303
+ for (const t of uncategorized) {
1304
+ const key = t.payee_name || "Unknown Payee";
1305
+ if (!byPayeeUncat[key]) byPayeeUncat[key] = { payee_name: key, count: 0, total: 0, flags: new Set() };
1306
+ byPayeeUncat[key].count += 1;
1307
+ byPayeeUncat[key].total += t.amount;
1308
+ for (const f of t.flags) byPayeeUncat[key].flags.add(f);
1309
+ }
1310
+ return Object.values(byPayeeUncat).map((g) => ({
1311
+ payee_name: g.payee_name,
1312
+ count: g.count,
1313
+ total: g.total,
1314
+ flags: [...g.flags],
1315
+ }));
1316
+ })();
1317
+
1318
+ const needsCategoryFirst = {
1319
+ count: uncategorized.length,
1320
+ warning: "Do NOT approve these without assigning a category first",
1321
+ };
1322
+ if (summary) {
1323
+ needsCategoryFirst.payees = uncategorizedPayload;
1324
+ } else {
1325
+ needsCategoryFirst.transactions = uncategorizedPayload;
1326
+ }
1327
+
1218
1328
  return ok({
1219
- total: txns.length,
1329
+ total: flaggedTxns.length,
1330
+ summary: !!summary,
1220
1331
  ready_to_approve: {
1221
1332
  count: categorized.length,
1222
- transactions: categorized,
1223
- },
1224
- needs_category_first: {
1225
- count: uncategorized.length,
1226
- warning: "Do NOT approve these without assigning a category first",
1227
- transactions: uncategorized,
1333
+ by_payee: groups,
1228
1334
  },
1335
+ needs_category_first: needsCategoryFirst,
1336
+ });
1337
+ })
1338
+ );
1339
+
1340
+ server.registerTool(
1341
+ "get_overspent_categories",
1342
+ { 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
+ budgetId: z.string().optional().describe("Budget ID (uses default if not provided)"),
1344
+ month: z.string().describe("Month in YYYY-MM-DD format (first of month)"),
1345
+ } },
1346
+ ({ budgetId, month }) =>
1347
+ run(async () => {
1348
+ const { data } = await api.months.getBudgetMonth(resolveBudgetId(budgetId), month);
1349
+ const overspent = (data.month.categories || [])
1350
+ .filter((c) => !c.deleted && c.balance < 0 && c.category_group_name !== "Internal Master Category")
1351
+ .map((c) => ({
1352
+ id: c.id,
1353
+ name: c.name,
1354
+ category_group_name: c.category_group_name,
1355
+ budgeted: dollars(c.budgeted),
1356
+ activity: dollars(c.activity),
1357
+ balance: dollars(c.balance),
1358
+ }))
1359
+ .sort((a, b) => a.balance - b.balance);
1360
+ return ok({
1361
+ month,
1362
+ overspent_count: overspent.length,
1363
+ total_overspent: overspent.reduce((sum, c) => sum + c.balance, 0),
1364
+ categories: overspent,
1229
1365
  });
1230
1366
  })
1231
1367
  );
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@oliverames/ynab-mcp-server",
3
- "version": "1.4.0",
3
+ "version": "1.7.1",
4
4
  "description": "YNAB MCP server with full API coverage",
5
5
  "type": "module",
6
6
  "main": "index.js",
@@ -12,6 +12,7 @@
12
12
  ],
13
13
  "scripts": {
14
14
  "start": "node index.js",
15
+ "pretest": "[ -d node_modules ] || npm ci --silent --no-audit --no-fund",
15
16
  "test": "node test.js"
16
17
  },
17
18
  "dependencies": {