@oliverames/ynab-mcp-server 1.4.0 → 1.6.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.
Files changed (3) hide show
  1. package/README.md +14 -4
  2. package/index.js +117 -12
  3. package/package.json +1 -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 ====================
@@ -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
  );
@@ -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,21 +1211,91 @@ 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)") } },
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)") } },
1209
1215
  ({ budgetId }) =>
1210
1216
  run(async () => {
1211
- const { data } = await api.transactions.getTransactions(resolveBudgetId(budgetId), undefined, "unapproved");
1212
- const txns = data.transactions.map(formatTransaction);
1217
+ const bid = resolveBudgetId(budgetId);
1218
+
1219
+ // Fetch unapproved transactions
1220
+ const { data: unapprovedData } = await api.transactions.getTransactions(bid, undefined, "unapproved");
1221
+ const txns = unapprovedData.transactions.map(formatTransaction);
1222
+ const unapprovedIds = new Set(txns.map((t) => t.id));
1223
+
1224
+ // Fetch 60 days of approved history for context
1225
+ const since60 = new Date(Date.now() - 60 * 24 * 60 * 60 * 1000).toISOString().slice(0, 10);
1226
+ const { data: histData } = await api.transactions.getTransactions(bid, since60);
1227
+ const histTxns = histData.transactions.filter((t) => t.approved && !unapprovedIds.has(t.id));
1228
+
1229
+ // Build payee history lookups (using raw milliunits for history, convert to dollars for the set)
1230
+ const payeeAmounts = {}; // payeeId -> Set of dollar amounts seen
1231
+ const payeeCategories = {}; // payeeId -> Map of categoryId -> categoryName
1232
+ for (const h of histTxns) {
1233
+ if (!h.payee_id) continue;
1234
+ const pid = h.payee_id;
1235
+ const amt = dollars(h.amount);
1236
+ const cid = h.category_id;
1237
+ const cname = h.category_name;
1238
+ if (!payeeAmounts[pid]) payeeAmounts[pid] = new Set();
1239
+ payeeAmounts[pid].add(amt);
1240
+ if (cid) {
1241
+ if (!payeeCategories[pid]) payeeCategories[pid] = new Map();
1242
+ payeeCategories[pid].set(cid, cname);
1243
+ }
1244
+ }
1245
+
1246
+ // Attach flags to each unapproved transaction
1247
+ function flagTransaction(t) {
1248
+ const flags = [];
1249
+ const isTransfer = !!t.transfer_account_id;
1250
+ if (!t.import_id && !isTransfer) flags.push("manually_entered");
1251
+ if (t.matched_transaction_id && !t.import_id) flags.push("match_broken");
1252
+ if (/_\d{4}-\d{2}-\d{2}$/.test(t.id)) flags.push("scheduled_transaction_realized");
1253
+ if (t.payee_id) {
1254
+ const hasHistory = !!payeeAmounts[t.payee_id];
1255
+ if (!hasHistory) {
1256
+ flags.push("new_payee");
1257
+ } else {
1258
+ if (!payeeAmounts[t.payee_id].has(t.amount)) flags.push("no_prior_amount_match");
1259
+ if (t.category_id && payeeCategories[t.payee_id] && !payeeCategories[t.payee_id].has(t.category_id)) {
1260
+ const priorNames = [...payeeCategories[t.payee_id].values()].join(", ");
1261
+ flags.push(`category_drift:was_${priorNames}`);
1262
+ }
1263
+ }
1264
+ }
1265
+ return { ...t, flags };
1266
+ }
1267
+
1268
+ const flaggedTxns = txns.map(flagTransaction);
1269
+
1213
1270
  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
1271
+ || (t.subtransactions && t.subtransactions.length > 0)
1272
+ || t.transfer_account_id;
1216
1273
  const categorized = [], uncategorized = [];
1217
- for (const t of txns) (isCategorized(t) ? categorized : uncategorized).push(t);
1274
+ for (const t of flaggedTxns) (isCategorized(t) ? categorized : uncategorized).push(t);
1275
+
1276
+ // Group categorized transactions by payee for easier per-group review
1277
+ const byPayee = {};
1278
+ for (const t of categorized) {
1279
+ const key = t.payee_name || "Unknown Payee";
1280
+ if (!byPayee[key]) byPayee[key] = { payee: key, category_name: t.category_name, transactions: [] };
1281
+ byPayee[key].transactions.push(t);
1282
+ }
1283
+ const groups = Object.values(byPayee).map((g) => {
1284
+ // Aggregate flags across all transactions in the group (deduplicated)
1285
+ const allFlags = [...new Set(g.transactions.flatMap((t) => t.flags))];
1286
+ return {
1287
+ ...g,
1288
+ count: g.transactions.length,
1289
+ total: g.transactions.reduce((sum, t) => sum + t.amount, 0),
1290
+ flags: allFlags,
1291
+ };
1292
+ });
1293
+
1218
1294
  return ok({
1219
- total: txns.length,
1295
+ total: flaggedTxns.length,
1220
1296
  ready_to_approve: {
1221
1297
  count: categorized.length,
1222
- transactions: categorized,
1298
+ by_payee: groups,
1223
1299
  },
1224
1300
  needs_category_first: {
1225
1301
  count: uncategorized.length,
@@ -1230,6 +1306,35 @@ server.registerTool(
1230
1306
  })
1231
1307
  );
1232
1308
 
1309
+ server.registerTool(
1310
+ "get_overspent_categories",
1311
+ { 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: {
1312
+ budgetId: z.string().optional().describe("Budget ID (uses default if not provided)"),
1313
+ month: z.string().describe("Month in YYYY-MM-DD format (first of month)"),
1314
+ } },
1315
+ ({ budgetId, month }) =>
1316
+ run(async () => {
1317
+ const { data } = await api.months.getBudgetMonth(resolveBudgetId(budgetId), month);
1318
+ const overspent = (data.month.categories || [])
1319
+ .filter((c) => !c.deleted && c.balance < 0 && c.category_group_name !== "Internal Master Category")
1320
+ .map((c) => ({
1321
+ id: c.id,
1322
+ name: c.name,
1323
+ category_group_name: c.category_group_name,
1324
+ budgeted: dollars(c.budgeted),
1325
+ activity: dollars(c.activity),
1326
+ balance: dollars(c.balance),
1327
+ }))
1328
+ .sort((a, b) => a.balance - b.balance);
1329
+ return ok({
1330
+ month,
1331
+ overspent_count: overspent.length,
1332
+ total_overspent: overspent.reduce((sum, c) => sum + c.balance, 0),
1333
+ categories: overspent,
1334
+ });
1335
+ })
1336
+ );
1337
+
1233
1338
  // --- Start ---
1234
1339
 
1235
1340
  process.on("uncaughtException", (err) => {
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.6.0",
4
4
  "description": "YNAB MCP server with full API coverage",
5
5
  "type": "module",
6
6
  "main": "index.js",