@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.
- package/README.md +14 -4
- package/index.js +117 -12
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
<p align="center">
|
|
2
|
-
<img src="
|
|
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> •
|
|
27
|
+
<a href="#install-with-mcpb">MCPB Download</a> •
|
|
26
28
|
<a href="#what-you-can-do">What You Can Do</a> •
|
|
27
29
|
<a href="#tools-reference">All 44 Tools</a> •
|
|
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),
|
|
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.
|
|
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
|
|
1212
|
-
|
|
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)
|
|
1215
|
-
|| t.transfer_account_id;
|
|
1271
|
+
|| (t.subtransactions && t.subtransactions.length > 0)
|
|
1272
|
+
|| t.transfer_account_id;
|
|
1216
1273
|
const categorized = [], uncategorized = [];
|
|
1217
|
-
for (const t of
|
|
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:
|
|
1295
|
+
total: flaggedTxns.length,
|
|
1220
1296
|
ready_to_approve: {
|
|
1221
1297
|
count: categorized.length,
|
|
1222
|
-
|
|
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) => {
|