@oliverames/ynab-mcp-server 1.3.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 +36 -22
- package/index.js +360 -154
- package/package.json +7 -3
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>
|
|
@@ -12,19 +12,21 @@
|
|
|
12
12
|
<p align="center">
|
|
13
13
|
<code>44 tools</code> •
|
|
14
14
|
<code>100% API coverage</code> •
|
|
15
|
-
<code>YNAB API v1.
|
|
15
|
+
<code>YNAB API v1.83</code>
|
|
16
16
|
</p>
|
|
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
|
-
<a href="#tools-reference">All
|
|
29
|
+
<a href="#tools-reference">All 44 Tools</a> •
|
|
28
30
|
<a href="#environment-variables">Configuration</a>
|
|
29
31
|
</p>
|
|
30
32
|
|
|
@@ -32,7 +34,7 @@
|
|
|
32
34
|
|
|
33
35
|
## Why This Exists
|
|
34
36
|
|
|
35
|
-
YNAB's budgeting philosophy works best when you interact with your budget frequently
|
|
37
|
+
YNAB's budgeting philosophy works best when you interact with your budget frequently - but the app interface isn't designed for quick queries or bulk operations. "How much did I spend on groceries this month?" shouldn't require navigating three screens. "Categorize all my Amazon orders from this week" shouldn't be a manual, one-by-one process.
|
|
36
38
|
|
|
37
39
|
This server gives your AI assistant full access to YNAB's API, turning natural language into budget operations. All monetary values are automatically converted between dollars and YNAB's internal milliunits format so the AI never has to think about it. Built on the [official YNAB JavaScript SDK](https://github.com/ynab/ynab-sdk-js) with direct API calls for the newest endpoints (category creation, category groups, money movements) that the SDK hasn't caught up with yet.
|
|
38
40
|
|
|
@@ -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.
|
|
@@ -121,7 +131,7 @@ That's it. Your AI can now talk to YNAB.
|
|
|
121
131
|
|
|
122
132
|
## Features
|
|
123
133
|
|
|
124
|
-
**Complete YNAB API v1.
|
|
134
|
+
**Complete YNAB API v1.83 coverage** with 44 tools:
|
|
125
135
|
|
|
126
136
|
| Resource | Tools | Capabilities |
|
|
127
137
|
|----------|-------|-------------|
|
|
@@ -138,15 +148,17 @@ That's it. Your AI can now talk to YNAB.
|
|
|
138
148
|
|
|
139
149
|
### Design Decisions
|
|
140
150
|
|
|
141
|
-
- **Dollar amounts everywhere**
|
|
142
|
-
- **Smart budget resolution**
|
|
143
|
-
- **Split transactions**
|
|
144
|
-
- **Bulk operations**
|
|
145
|
-
- **Fetch-then-merge updates**
|
|
146
|
-
- **Fuzzy search**
|
|
147
|
-
- **Approval workflow**
|
|
148
|
-
- **Nullable updates**
|
|
149
|
-
- **
|
|
151
|
+
- **Dollar amounts everywhere** - inputs and outputs are in dollars (`-12.34`), never milliunits (`-12340`). Conversion is automatic and transparent.
|
|
152
|
+
- **Smart budget resolution** - set `YNAB_BUDGET_ID` for a default, or omit it to auto-resolve to your last-used budget. Every tool accepts an optional `budgetId` override.
|
|
153
|
+
- **Split transactions** - first-class support for subtransactions in create, read, and format operations.
|
|
154
|
+
- **Bulk operations** - `create_transactions` and `update_transactions` handle arrays in a single API call.
|
|
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.
|
|
156
|
+
- **Fuzzy search** - `search_categories` and `search_payees` do case-insensitive partial matching across all 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.
|
|
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`).
|
|
159
|
+
- **Target behavior support** - category create/update tools expose `goalNeedsWholeAmount` for YNAB's "Set aside another" vs. "Refill up to" goal behavior.
|
|
160
|
+
- **Delta request support** - high-volume list tools accept `lastKnowledgeOfServer` and return `server_knowledge` when that parameter is provided.
|
|
161
|
+
- **Debt account support** - loan and debt accounts include `debt_original_balance`, `debt_interest_rates`, `debt_minimum_payments`, and `debt_escrow_amounts` with correct unit conversion (rates stay as percentages, payments convert from milliunits).
|
|
150
162
|
|
|
151
163
|
---
|
|
152
164
|
|
|
@@ -224,10 +236,10 @@ That's it. Your AI can now talk to YNAB.
|
|
|
224
236
|
| Tool | Description |
|
|
225
237
|
|------|-------------|
|
|
226
238
|
| `get_transactions` | Get transactions with filters: by account, category, payee, month, or status (`unapproved`/`uncategorized`) |
|
|
227
|
-
| `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`. |
|
|
228
240
|
| `create_transaction` | Create a transaction with optional split (subtransactions must sum to total) |
|
|
229
241
|
| `create_transactions` | Bulk create multiple transactions in a single API call (supports split transactions) |
|
|
230
|
-
| `update_transaction` | Partial update
|
|
242
|
+
| `update_transaction` | Partial update - only specified fields change |
|
|
231
243
|
| `update_transactions` | Batch update multiple transactions at once |
|
|
232
244
|
| `delete_transaction` | Delete a transaction |
|
|
233
245
|
| `import_transactions` | Trigger import from linked bank accounts |
|
|
@@ -248,7 +260,7 @@ That's it. Your AI can now talk to YNAB.
|
|
|
248
260
|
|
|
249
261
|
| Tool | Description |
|
|
250
262
|
|------|-------------|
|
|
251
|
-
| `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. |
|
|
252
264
|
|
|
253
265
|
---
|
|
254
266
|
|
|
@@ -299,7 +311,7 @@ All amounts in tool inputs and outputs are in **dollars** (e.g., `-12.34` for a
|
|
|
299
311
|
|
|
300
312
|
## Rate Limiting
|
|
301
313
|
|
|
302
|
-
The YNAB API allows **200 requests per hour** per access token, enforced on a rolling window. Each tool call typically uses one API request (except `update_scheduled_transaction` which uses two
|
|
314
|
+
The YNAB API allows **200 requests per hour** per access token, enforced on a rolling window. Each tool call typically uses one API request (except `update_scheduled_transaction` which uses two - a GET to fetch current state, then a PUT to merge changes). The server surfaces rate limit errors as standard MCP error responses.
|
|
303
315
|
|
|
304
316
|
---
|
|
305
317
|
|
|
@@ -323,13 +335,15 @@ The YNAB API allows **200 requests per hour** per access token, enforced on a ro
|
|
|
323
335
|
|
|
324
336
|
## Testing
|
|
325
337
|
|
|
326
|
-
The test suite
|
|
338
|
+
The integration test suite runs against a live YNAB budget. Most write tests create temporary transactions and delete or restore them, but category and category group creation is not reversible through the public API and is skipped unless explicitly enabled.
|
|
327
339
|
|
|
328
340
|
```bash
|
|
329
341
|
YNAB_API_TOKEN=your-token YNAB_BUDGET_ID=your-budget-id npm test
|
|
330
342
|
```
|
|
331
343
|
|
|
332
|
-
|
|
344
|
+
Use `YNAB_TEST_BUDGET_ID` to target a dedicated test budget without changing your server default. To include category and category group creation coverage, run with `YNAB_RUN_NONREVERSIBLE_TESTS=1`.
|
|
345
|
+
|
|
346
|
+
Tests cover all tool categories: reads, reversible writes, bulk operations, search, split transactions, scheduled transaction CRUD with fetch-then-merge verification, money movements, and payee locations.
|
|
333
347
|
|
|
334
348
|
---
|
|
335
349
|
|
|
@@ -344,8 +358,8 @@ YNAB_API_TOKEN=your-token npm start
|
|
|
344
358
|
|
|
345
359
|
### Dependencies
|
|
346
360
|
|
|
347
|
-
- [`@modelcontextprotocol/sdk`](https://www.npmjs.com/package/@modelcontextprotocol/sdk)
|
|
348
|
-
- [`ynab`](https://www.npmjs.com/package/ynab)
|
|
361
|
+
- [`@modelcontextprotocol/sdk`](https://www.npmjs.com/package/@modelcontextprotocol/sdk) - MCP server framework
|
|
362
|
+
- [`ynab`](https://www.npmjs.com/package/ynab) - Official YNAB JavaScript client
|
|
349
363
|
|
|
350
364
|
Zero additional dependencies. No build step. Pure ESM.
|
|
351
365
|
|
package/index.js
CHANGED
|
@@ -9,18 +9,22 @@ import * as ynab from "ynab";
|
|
|
9
9
|
// --- Init ---
|
|
10
10
|
|
|
11
11
|
let API_TOKEN = process.env.YNAB_API_TOKEN;
|
|
12
|
+
let opLookupError;
|
|
12
13
|
if (!API_TOKEN && process.env.YNAB_OP_PATH) {
|
|
13
14
|
try {
|
|
14
15
|
API_TOKEN = execFileSync(
|
|
15
16
|
"op", ["read", process.env.YNAB_OP_PATH],
|
|
16
17
|
{ encoding: "utf-8", timeout: 10000, stdio: ["pipe", "pipe", "pipe"] }
|
|
17
18
|
).trim();
|
|
18
|
-
} catch {
|
|
19
|
-
|
|
19
|
+
} catch (e) {
|
|
20
|
+
opLookupError = e.stderr?.toString().trim() || e.message || "unknown 1Password CLI error";
|
|
20
21
|
}
|
|
21
22
|
}
|
|
22
23
|
if (!API_TOKEN) {
|
|
23
|
-
|
|
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}`);
|
|
24
28
|
process.exit(1);
|
|
25
29
|
}
|
|
26
30
|
|
|
@@ -45,6 +49,16 @@ function dollarsMap(obj) {
|
|
|
45
49
|
return obj ? Object.fromEntries(Object.entries(obj).map(([k, v]) => [k, dollars(v)])) : obj;
|
|
46
50
|
}
|
|
47
51
|
|
|
52
|
+
function withCurrencyFields(out, source, fields) {
|
|
53
|
+
for (const field of fields) {
|
|
54
|
+
const formatted = `${field}_formatted`;
|
|
55
|
+
const currency = `${field}_currency`;
|
|
56
|
+
if (formatted in source) out[formatted] = source[formatted];
|
|
57
|
+
if (currency in source) out[currency] = source[currency];
|
|
58
|
+
}
|
|
59
|
+
return out;
|
|
60
|
+
}
|
|
61
|
+
|
|
48
62
|
function mapTransactionInput(t) {
|
|
49
63
|
const out = {
|
|
50
64
|
account_id: t.accountId,
|
|
@@ -71,7 +85,7 @@ function mapTransactionInput(t) {
|
|
|
71
85
|
return out;
|
|
72
86
|
}
|
|
73
87
|
|
|
74
|
-
// Sparse patch mapper for update_transaction / update_transactions
|
|
88
|
+
// Sparse patch mapper for update_transaction / update_transactions - only includes fields that were explicitly provided
|
|
75
89
|
function mapTransactionUpdate(t) {
|
|
76
90
|
const out = {};
|
|
77
91
|
if (t.accountId !== undefined) out.account_id = t.accountId;
|
|
@@ -87,10 +101,22 @@ function mapTransactionUpdate(t) {
|
|
|
87
101
|
return out;
|
|
88
102
|
}
|
|
89
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
|
+
|
|
90
110
|
function ok(data) {
|
|
91
111
|
return { content: [{ type: "text", text: JSON.stringify(data, null, 2) }] };
|
|
92
112
|
}
|
|
93
113
|
|
|
114
|
+
function collection(data, key, items, lastKnowledgeOfServer) {
|
|
115
|
+
return lastKnowledgeOfServer === undefined
|
|
116
|
+
? items
|
|
117
|
+
: { [key]: items, server_knowledge: data.server_knowledge };
|
|
118
|
+
}
|
|
119
|
+
|
|
94
120
|
async function run(fn) {
|
|
95
121
|
try {
|
|
96
122
|
return await fn();
|
|
@@ -106,14 +132,19 @@ async function run(fn) {
|
|
|
106
132
|
|
|
107
133
|
// Direct API helper for endpoints not yet in the ynab SDK
|
|
108
134
|
const BASE_URL = "https://api.ynab.com/v1";
|
|
109
|
-
async function ynabFetch(path, { method = "GET", body } = {}) {
|
|
135
|
+
async function ynabFetch(path, { method = "GET", body, query } = {}) {
|
|
136
|
+
const url = new URL(`${BASE_URL}${path}`);
|
|
137
|
+
for (const [key, value] of Object.entries(query || {})) {
|
|
138
|
+
if (value !== undefined && value !== null) url.searchParams.set(key, String(value));
|
|
139
|
+
}
|
|
110
140
|
const opts = {
|
|
111
141
|
method,
|
|
112
142
|
headers: { Authorization: `Bearer ${API_TOKEN}`, "Content-Type": "application/json" },
|
|
113
143
|
};
|
|
114
144
|
if (body) opts.body = JSON.stringify(body);
|
|
115
|
-
const res = await fetch(
|
|
116
|
-
const
|
|
145
|
+
const res = await fetch(url, opts);
|
|
146
|
+
const text = await res.text();
|
|
147
|
+
const json = text ? JSON.parse(text) : {};
|
|
117
148
|
if (!res.ok) {
|
|
118
149
|
const err = new Error(json?.error?.detail || `HTTP ${res.status}`);
|
|
119
150
|
err.error = json?.error;
|
|
@@ -126,7 +157,7 @@ async function ynabFetch(path, { method = "GET", body } = {}) {
|
|
|
126
157
|
|
|
127
158
|
const server = new McpServer({
|
|
128
159
|
name: "ynab-mcp-server",
|
|
129
|
-
version: "1.
|
|
160
|
+
version: "1.6.0",
|
|
130
161
|
});
|
|
131
162
|
|
|
132
163
|
// ==================== User & Budgets ====================
|
|
@@ -212,16 +243,20 @@ function formatAccount(a) {
|
|
|
212
243
|
deleted: a.deleted,
|
|
213
244
|
};
|
|
214
245
|
if ("note" in a) out.note = a.note;
|
|
215
|
-
return out;
|
|
246
|
+
return withCurrencyFields(out, a, ["balance", "cleared_balance", "uncleared_balance"]);
|
|
216
247
|
}
|
|
217
248
|
|
|
218
249
|
server.registerTool(
|
|
219
250
|
"list_accounts",
|
|
220
|
-
{ description: "List all accounts in a budget", inputSchema: {
|
|
221
|
-
|
|
251
|
+
{ description: "List all accounts in a budget", inputSchema: {
|
|
252
|
+
budgetId: z.string().optional().describe("Budget ID (uses default if not provided)"),
|
|
253
|
+
lastKnowledgeOfServer: z.number().int().nonnegative().optional().describe("Delta request server knowledge. When provided, returns { accounts, server_knowledge }."),
|
|
254
|
+
} },
|
|
255
|
+
({ budgetId, lastKnowledgeOfServer }) =>
|
|
222
256
|
run(async () => {
|
|
223
|
-
const { data } = await api.accounts.getAccounts(resolveBudgetId(budgetId));
|
|
224
|
-
|
|
257
|
+
const { data } = await api.accounts.getAccounts(resolveBudgetId(budgetId), lastKnowledgeOfServer);
|
|
258
|
+
const accounts = data.accounts.map(formatAccount);
|
|
259
|
+
return ok(collection(data, "accounts", accounts, lastKnowledgeOfServer));
|
|
225
260
|
})
|
|
226
261
|
);
|
|
227
262
|
|
|
@@ -258,7 +293,7 @@ server.registerTool(
|
|
|
258
293
|
// ==================== Categories ====================
|
|
259
294
|
|
|
260
295
|
function formatCategory(c) {
|
|
261
|
-
|
|
296
|
+
const out = {
|
|
262
297
|
id: c.id,
|
|
263
298
|
category_group_id: c.category_group_id,
|
|
264
299
|
category_group_name: c.category_group_name,
|
|
@@ -275,6 +310,7 @@ function formatCategory(c) {
|
|
|
275
310
|
goal_cadence_frequency: c.goal_cadence_frequency,
|
|
276
311
|
goal_creation_month: c.goal_creation_month,
|
|
277
312
|
goal_target: dollars(c.goal_target),
|
|
313
|
+
goal_target_month: c.goal_target_month,
|
|
278
314
|
goal_target_date: c.goal_target_date,
|
|
279
315
|
goal_percentage_complete: c.goal_percentage_complete,
|
|
280
316
|
goal_months_to_budget: c.goal_months_to_budget,
|
|
@@ -282,34 +318,53 @@ function formatCategory(c) {
|
|
|
282
318
|
goal_overall_funded: dollars(c.goal_overall_funded),
|
|
283
319
|
goal_overall_left: dollars(c.goal_overall_left),
|
|
284
320
|
goal_needs_whole_amount: c.goal_needs_whole_amount,
|
|
321
|
+
goal_snoozed_at: c.goal_snoozed_at,
|
|
285
322
|
deleted: c.deleted,
|
|
286
323
|
};
|
|
324
|
+
return withCurrencyFields(out, c, [
|
|
325
|
+
"budgeted",
|
|
326
|
+
"activity",
|
|
327
|
+
"balance",
|
|
328
|
+
"goal_target",
|
|
329
|
+
"goal_under_funded",
|
|
330
|
+
"goal_overall_funded",
|
|
331
|
+
"goal_overall_left",
|
|
332
|
+
]);
|
|
287
333
|
}
|
|
288
334
|
|
|
289
335
|
server.registerTool(
|
|
290
336
|
"list_categories",
|
|
291
|
-
{ description: "List all category groups and their categories", inputSchema: {
|
|
292
|
-
|
|
337
|
+
{ description: "List all category groups and their categories", inputSchema: {
|
|
338
|
+
budgetId: z.string().optional().describe("Budget ID (uses default if not provided)"),
|
|
339
|
+
lastKnowledgeOfServer: z.number().int().nonnegative().optional().describe("Delta request server knowledge. When provided, returns { category_groups, server_knowledge }."),
|
|
340
|
+
} },
|
|
341
|
+
({ budgetId, lastKnowledgeOfServer }) =>
|
|
293
342
|
run(async () => {
|
|
294
|
-
const { data } = await api.categories.getCategories(resolveBudgetId(budgetId));
|
|
295
|
-
|
|
296
|
-
data.category_groups.map((g) => ({
|
|
343
|
+
const { data } = await api.categories.getCategories(resolveBudgetId(budgetId), lastKnowledgeOfServer);
|
|
344
|
+
const categoryGroups = data.category_groups.map((g) => ({
|
|
297
345
|
id: g.id,
|
|
298
346
|
name: g.name,
|
|
299
347
|
hidden: g.hidden,
|
|
300
348
|
deleted: g.deleted,
|
|
301
|
-
categories: g.categories.map((c) =>
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
349
|
+
categories: g.categories.map((c) =>
|
|
350
|
+
withCurrencyFields(
|
|
351
|
+
{
|
|
352
|
+
id: c.id,
|
|
353
|
+
name: c.name,
|
|
354
|
+
hidden: c.hidden,
|
|
355
|
+
budgeted: dollars(c.budgeted),
|
|
356
|
+
activity: dollars(c.activity),
|
|
357
|
+
balance: dollars(c.balance),
|
|
358
|
+
goal_type: c.goal_type,
|
|
359
|
+
goal_needs_whole_amount: c.goal_needs_whole_amount,
|
|
360
|
+
deleted: c.deleted,
|
|
361
|
+
},
|
|
362
|
+
c,
|
|
363
|
+
["budgeted", "activity", "balance"]
|
|
364
|
+
)
|
|
365
|
+
),
|
|
366
|
+
}));
|
|
367
|
+
return ok(collection(data, "category_groups", categoryGroups, lastKnowledgeOfServer));
|
|
313
368
|
})
|
|
314
369
|
);
|
|
315
370
|
|
|
@@ -367,8 +422,9 @@ server.registerTool(
|
|
|
367
422
|
categoryGroupId: z.string().optional().describe("Move to a different category group"),
|
|
368
423
|
goalTarget: z.number().nullable().optional().describe("Goal target amount in dollars (only if category already has a goal)"),
|
|
369
424
|
goalTargetDate: z.string().nullable().optional().describe("Goal target date in ISO format (e.g. 2026-12-01, null to clear)"),
|
|
425
|
+
goalNeedsWholeAmount: z.boolean().nullable().optional().describe("For NEED goals, true uses 'Set aside another' behavior and false uses 'Refill up to' behavior"),
|
|
370
426
|
} },
|
|
371
|
-
({ budgetId, categoryId, name, note, categoryGroupId, goalTarget, goalTargetDate }) =>
|
|
427
|
+
({ budgetId, categoryId, name, note, categoryGroupId, goalTarget, goalTargetDate, goalNeedsWholeAmount }) =>
|
|
372
428
|
run(async () => {
|
|
373
429
|
const cat = {};
|
|
374
430
|
if (name !== undefined) cat.name = name;
|
|
@@ -376,9 +432,11 @@ server.registerTool(
|
|
|
376
432
|
if (categoryGroupId !== undefined) cat.category_group_id = categoryGroupId;
|
|
377
433
|
if (goalTarget !== undefined) cat.goal_target = goalTarget != null ? milliunits(goalTarget) : null;
|
|
378
434
|
if (goalTargetDate !== undefined) cat.goal_target_date = goalTargetDate;
|
|
435
|
+
if (goalNeedsWholeAmount !== undefined) cat.goal_needs_whole_amount = goalNeedsWholeAmount;
|
|
379
436
|
|
|
380
|
-
const
|
|
381
|
-
|
|
437
|
+
const data = await ynabFetch(`/plans/${resolveBudgetId(budgetId)}/categories/${categoryId}`, {
|
|
438
|
+
method: "PATCH",
|
|
439
|
+
body: { category: cat },
|
|
382
440
|
});
|
|
383
441
|
return ok(formatCategory(data.category));
|
|
384
442
|
})
|
|
@@ -393,15 +451,17 @@ server.registerTool(
|
|
|
393
451
|
note: z.string().optional().describe("Category note"),
|
|
394
452
|
goalTarget: z.number().optional().describe("Goal target amount in dollars (creates a 'Needed for Spending' goal)"),
|
|
395
453
|
goalTargetDate: z.string().optional().describe("Goal target date in ISO format (e.g. 2026-12-01)"),
|
|
454
|
+
goalNeedsWholeAmount: z.boolean().optional().describe("For NEED goals, true uses 'Set aside another' behavior and false uses 'Refill up to' behavior"),
|
|
396
455
|
} },
|
|
397
|
-
({ budgetId, categoryGroupId, name, note, goalTarget, goalTargetDate }) =>
|
|
456
|
+
({ budgetId, categoryGroupId, name, note, goalTarget, goalTargetDate, goalNeedsWholeAmount }) =>
|
|
398
457
|
run(async () => {
|
|
399
458
|
const bid = resolveBudgetId(budgetId);
|
|
400
459
|
const cat = { category_group_id: categoryGroupId, name };
|
|
401
460
|
if (note !== undefined) cat.note = note;
|
|
402
461
|
if (goalTarget !== undefined) cat.goal_target = milliunits(goalTarget);
|
|
403
462
|
if (goalTargetDate !== undefined) cat.goal_target_date = goalTargetDate;
|
|
404
|
-
|
|
463
|
+
if (goalNeedsWholeAmount !== undefined) cat.goal_needs_whole_amount = goalNeedsWholeAmount;
|
|
464
|
+
const data = await ynabFetch(`/plans/${bid}/categories`, {
|
|
405
465
|
method: "POST",
|
|
406
466
|
body: { category: cat },
|
|
407
467
|
});
|
|
@@ -417,7 +477,7 @@ server.registerTool(
|
|
|
417
477
|
} },
|
|
418
478
|
({ budgetId, name }) =>
|
|
419
479
|
run(async () => {
|
|
420
|
-
const data = await ynabFetch(`/
|
|
480
|
+
const data = await ynabFetch(`/plans/${resolveBudgetId(budgetId)}/category_groups`, {
|
|
421
481
|
method: "POST",
|
|
422
482
|
body: { category_group: { name } },
|
|
423
483
|
});
|
|
@@ -434,7 +494,7 @@ server.registerTool(
|
|
|
434
494
|
} },
|
|
435
495
|
({ budgetId, categoryGroupId, name }) =>
|
|
436
496
|
run(async () => {
|
|
437
|
-
const data = await ynabFetch(`/
|
|
497
|
+
const data = await ynabFetch(`/plans/${resolveBudgetId(budgetId)}/category_groups/${categoryGroupId}`, {
|
|
438
498
|
method: "PATCH",
|
|
439
499
|
body: { category_group: { name } },
|
|
440
500
|
});
|
|
@@ -446,11 +506,15 @@ server.registerTool(
|
|
|
446
506
|
|
|
447
507
|
server.registerTool(
|
|
448
508
|
"list_payees",
|
|
449
|
-
{ description: "List all payees", inputSchema: {
|
|
450
|
-
|
|
509
|
+
{ description: "List all payees", inputSchema: {
|
|
510
|
+
budgetId: z.string().optional().describe("Budget ID (uses default if not provided)"),
|
|
511
|
+
lastKnowledgeOfServer: z.number().int().nonnegative().optional().describe("Delta request server knowledge. When provided, returns { payees, server_knowledge }."),
|
|
512
|
+
} },
|
|
513
|
+
({ budgetId, lastKnowledgeOfServer }) =>
|
|
451
514
|
run(async () => {
|
|
452
|
-
const { data } = await api.payees.getPayees(resolveBudgetId(budgetId));
|
|
453
|
-
|
|
515
|
+
const { data } = await api.payees.getPayees(resolveBudgetId(budgetId), lastKnowledgeOfServer);
|
|
516
|
+
const payees = data.payees.map((p) => ({ id: p.id, name: p.name, transfer_account_id: p.transfer_account_id, deleted: p.deleted }));
|
|
517
|
+
return ok(collection(data, "payees", payees, lastKnowledgeOfServer));
|
|
454
518
|
})
|
|
455
519
|
);
|
|
456
520
|
|
|
@@ -491,7 +555,7 @@ server.registerTool(
|
|
|
491
555
|
} },
|
|
492
556
|
({ budgetId, name }) =>
|
|
493
557
|
run(async () => {
|
|
494
|
-
const data = await ynabFetch(`/
|
|
558
|
+
const data = await ynabFetch(`/plans/${resolveBudgetId(budgetId)}/payees`, {
|
|
495
559
|
method: "POST",
|
|
496
560
|
body: { payee: { name } },
|
|
497
561
|
});
|
|
@@ -541,22 +605,30 @@ server.registerTool(
|
|
|
541
605
|
|
|
542
606
|
server.registerTool(
|
|
543
607
|
"list_months",
|
|
544
|
-
{ description: "List all budget months", inputSchema: {
|
|
545
|
-
|
|
608
|
+
{ description: "List all budget months", inputSchema: {
|
|
609
|
+
budgetId: z.string().optional().describe("Budget ID (uses default if not provided)"),
|
|
610
|
+
lastKnowledgeOfServer: z.number().int().nonnegative().optional().describe("Delta request server knowledge. When provided, returns { months, server_knowledge }."),
|
|
611
|
+
} },
|
|
612
|
+
({ budgetId, lastKnowledgeOfServer }) =>
|
|
546
613
|
run(async () => {
|
|
547
|
-
const { data } = await api.months.getBudgetMonths(resolveBudgetId(budgetId));
|
|
548
|
-
|
|
549
|
-
|
|
550
|
-
|
|
551
|
-
|
|
552
|
-
|
|
553
|
-
|
|
554
|
-
|
|
555
|
-
|
|
556
|
-
|
|
557
|
-
|
|
558
|
-
|
|
559
|
-
|
|
614
|
+
const { data } = await api.months.getBudgetMonths(resolveBudgetId(budgetId), lastKnowledgeOfServer);
|
|
615
|
+
const months = data.months.map((m) =>
|
|
616
|
+
withCurrencyFields(
|
|
617
|
+
{
|
|
618
|
+
month: m.month,
|
|
619
|
+
note: m.note,
|
|
620
|
+
income: dollars(m.income),
|
|
621
|
+
budgeted: dollars(m.budgeted),
|
|
622
|
+
activity: dollars(m.activity),
|
|
623
|
+
to_be_budgeted: dollars(m.to_be_budgeted),
|
|
624
|
+
age_of_money: m.age_of_money,
|
|
625
|
+
deleted: m.deleted,
|
|
626
|
+
},
|
|
627
|
+
m,
|
|
628
|
+
["income", "budgeted", "activity", "to_be_budgeted"]
|
|
629
|
+
)
|
|
630
|
+
);
|
|
631
|
+
return ok(collection(data, "months", months, lastKnowledgeOfServer));
|
|
560
632
|
})
|
|
561
633
|
);
|
|
562
634
|
|
|
@@ -570,7 +642,7 @@ server.registerTool(
|
|
|
570
642
|
run(async () => {
|
|
571
643
|
const { data } = await api.months.getBudgetMonth(resolveBudgetId(budgetId), month);
|
|
572
644
|
const m = data.month;
|
|
573
|
-
|
|
645
|
+
const out = {
|
|
574
646
|
month: m.month,
|
|
575
647
|
note: m.note,
|
|
576
648
|
income: dollars(m.income),
|
|
@@ -579,27 +651,38 @@ server.registerTool(
|
|
|
579
651
|
to_be_budgeted: dollars(m.to_be_budgeted),
|
|
580
652
|
age_of_money: m.age_of_money,
|
|
581
653
|
deleted: m.deleted,
|
|
582
|
-
categories: m.categories?.map((c) =>
|
|
583
|
-
|
|
584
|
-
|
|
585
|
-
|
|
586
|
-
|
|
587
|
-
|
|
588
|
-
|
|
589
|
-
|
|
590
|
-
|
|
591
|
-
|
|
592
|
-
|
|
593
|
-
|
|
594
|
-
|
|
595
|
-
|
|
654
|
+
categories: m.categories?.map((c) =>
|
|
655
|
+
withCurrencyFields(
|
|
656
|
+
{
|
|
657
|
+
id: c.id,
|
|
658
|
+
name: c.name,
|
|
659
|
+
hidden: c.hidden,
|
|
660
|
+
category_group_name: c.category_group_name,
|
|
661
|
+
budgeted: dollars(c.budgeted),
|
|
662
|
+
activity: dollars(c.activity),
|
|
663
|
+
balance: dollars(c.balance),
|
|
664
|
+
goal_type: c.goal_type,
|
|
665
|
+
goal_needs_whole_amount: c.goal_needs_whole_amount,
|
|
666
|
+
goal_target: dollars(c.goal_target),
|
|
667
|
+
goal_target_month: c.goal_target_month,
|
|
668
|
+
goal_target_date: c.goal_target_date,
|
|
669
|
+
goal_under_funded: dollars(c.goal_under_funded),
|
|
670
|
+
goal_snoozed_at: c.goal_snoozed_at,
|
|
671
|
+
deleted: c.deleted,
|
|
672
|
+
},
|
|
673
|
+
c,
|
|
674
|
+
["budgeted", "activity", "balance", "goal_target", "goal_under_funded"]
|
|
675
|
+
)
|
|
676
|
+
),
|
|
677
|
+
};
|
|
678
|
+
return ok(withCurrencyFields(out, m, ["income", "budgeted", "activity", "to_be_budgeted"]));
|
|
596
679
|
})
|
|
597
680
|
);
|
|
598
681
|
|
|
599
682
|
// ==================== Money Movements ====================
|
|
600
683
|
|
|
601
684
|
function formatMoneyMovement(m) {
|
|
602
|
-
return {
|
|
685
|
+
return withCurrencyFields({
|
|
603
686
|
id: m.id,
|
|
604
687
|
month: m.month,
|
|
605
688
|
moved_at: m.moved_at,
|
|
@@ -610,7 +693,7 @@ function formatMoneyMovement(m) {
|
|
|
610
693
|
to_category_id: m.to_category_id,
|
|
611
694
|
amount: dollars(m.amount),
|
|
612
695
|
deleted: m.deleted,
|
|
613
|
-
};
|
|
696
|
+
}, m, ["amount"]);
|
|
614
697
|
}
|
|
615
698
|
|
|
616
699
|
server.registerTool(
|
|
@@ -618,7 +701,7 @@ server.registerTool(
|
|
|
618
701
|
{ description: "List all money movements (budget re-allocations between categories)", inputSchema: { budgetId: z.string().optional().describe("Budget ID (uses default if not provided)") } },
|
|
619
702
|
({ budgetId }) =>
|
|
620
703
|
run(async () => {
|
|
621
|
-
const data = await ynabFetch(`/
|
|
704
|
+
const data = await ynabFetch(`/plans/${resolveBudgetId(budgetId)}/money_movements`);
|
|
622
705
|
return ok(data.money_movements.map(formatMoneyMovement));
|
|
623
706
|
})
|
|
624
707
|
);
|
|
@@ -631,7 +714,7 @@ server.registerTool(
|
|
|
631
714
|
} },
|
|
632
715
|
({ budgetId, month }) =>
|
|
633
716
|
run(async () => {
|
|
634
|
-
const data = await ynabFetch(`/
|
|
717
|
+
const data = await ynabFetch(`/plans/${resolveBudgetId(budgetId)}/months/${month}/money_movements`);
|
|
635
718
|
return ok(data.money_movements.map(formatMoneyMovement));
|
|
636
719
|
})
|
|
637
720
|
);
|
|
@@ -641,7 +724,7 @@ server.registerTool(
|
|
|
641
724
|
{ description: "List all money movement groups (batches of related money movements)", inputSchema: { budgetId: z.string().optional().describe("Budget ID (uses default if not provided)") } },
|
|
642
725
|
({ budgetId }) =>
|
|
643
726
|
run(async () => {
|
|
644
|
-
const data = await ynabFetch(`/
|
|
727
|
+
const data = await ynabFetch(`/plans/${resolveBudgetId(budgetId)}/money_movement_groups`);
|
|
645
728
|
return ok(data.money_movement_groups);
|
|
646
729
|
})
|
|
647
730
|
);
|
|
@@ -654,7 +737,7 @@ server.registerTool(
|
|
|
654
737
|
} },
|
|
655
738
|
({ budgetId, month }) =>
|
|
656
739
|
run(async () => {
|
|
657
|
-
const data = await ynabFetch(`/
|
|
740
|
+
const data = await ynabFetch(`/plans/${resolveBudgetId(budgetId)}/months/${month}/money_movement_groups`);
|
|
658
741
|
return ok(data.money_movement_groups);
|
|
659
742
|
})
|
|
660
743
|
);
|
|
@@ -662,43 +745,50 @@ server.registerTool(
|
|
|
662
745
|
// ==================== Transactions ====================
|
|
663
746
|
|
|
664
747
|
function formatTransaction(t) {
|
|
665
|
-
|
|
748
|
+
const out = {
|
|
666
749
|
id: t.id,
|
|
667
750
|
date: t.date,
|
|
668
751
|
amount: dollars(t.amount),
|
|
669
|
-
memo: t.memo,
|
|
752
|
+
memo: t.memo ?? null,
|
|
670
753
|
cleared: t.cleared,
|
|
671
754
|
approved: t.approved,
|
|
672
|
-
flag_color: t.flag_color,
|
|
673
|
-
flag_name: t.flag_name,
|
|
755
|
+
flag_color: t.flag_color ?? null,
|
|
756
|
+
flag_name: t.flag_name ?? null,
|
|
674
757
|
account_id: t.account_id,
|
|
675
758
|
account_name: t.account_name,
|
|
676
|
-
payee_id: t.payee_id,
|
|
677
|
-
payee_name: t.payee_name,
|
|
678
|
-
category_id: t.category_id,
|
|
679
|
-
category_name: t.category_name,
|
|
680
|
-
transfer_account_id: t.transfer_account_id,
|
|
681
|
-
transfer_transaction_id: t.transfer_transaction_id,
|
|
682
|
-
matched_transaction_id: t.matched_transaction_id,
|
|
683
|
-
import_id: t.import_id,
|
|
684
|
-
import_payee_name: t.import_payee_name,
|
|
685
|
-
import_payee_name_original: t.import_payee_name_original,
|
|
686
|
-
debt_transaction_type: t.debt_transaction_type,
|
|
759
|
+
payee_id: t.payee_id ?? null,
|
|
760
|
+
payee_name: t.payee_name ?? null,
|
|
761
|
+
category_id: t.category_id ?? null,
|
|
762
|
+
category_name: t.category_name ?? null,
|
|
763
|
+
transfer_account_id: t.transfer_account_id ?? null,
|
|
764
|
+
transfer_transaction_id: t.transfer_transaction_id ?? null,
|
|
765
|
+
matched_transaction_id: t.matched_transaction_id ?? null,
|
|
766
|
+
import_id: t.import_id ?? null,
|
|
767
|
+
import_payee_name: t.import_payee_name ?? null,
|
|
768
|
+
import_payee_name_original: t.import_payee_name_original ?? null,
|
|
769
|
+
debt_transaction_type: t.debt_transaction_type ?? null,
|
|
687
770
|
deleted: t.deleted,
|
|
688
|
-
subtransactions: t.subtransactions?.map((s) =>
|
|
689
|
-
|
|
690
|
-
|
|
691
|
-
|
|
692
|
-
|
|
693
|
-
|
|
694
|
-
|
|
695
|
-
|
|
696
|
-
|
|
697
|
-
|
|
698
|
-
|
|
699
|
-
|
|
700
|
-
|
|
771
|
+
subtransactions: t.subtransactions?.map((s) =>
|
|
772
|
+
withCurrencyFields(
|
|
773
|
+
{
|
|
774
|
+
id: s.id,
|
|
775
|
+
transaction_id: s.transaction_id,
|
|
776
|
+
amount: dollars(s.amount),
|
|
777
|
+
memo: s.memo ?? null,
|
|
778
|
+
payee_id: s.payee_id ?? null,
|
|
779
|
+
payee_name: s.payee_name ?? null,
|
|
780
|
+
category_id: s.category_id ?? null,
|
|
781
|
+
category_name: s.category_name ?? null,
|
|
782
|
+
transfer_account_id: s.transfer_account_id ?? null,
|
|
783
|
+
transfer_transaction_id: s.transfer_transaction_id ?? null,
|
|
784
|
+
deleted: s.deleted,
|
|
785
|
+
},
|
|
786
|
+
s,
|
|
787
|
+
["amount"]
|
|
788
|
+
)
|
|
789
|
+
),
|
|
701
790
|
};
|
|
791
|
+
return withCurrencyFields(out, t, ["amount"]);
|
|
702
792
|
}
|
|
703
793
|
|
|
704
794
|
server.registerTool(
|
|
@@ -711,49 +801,55 @@ server.registerTool(
|
|
|
711
801
|
categoryId: z.string().optional().describe("Filter by category ID"),
|
|
712
802
|
payeeId: z.string().optional().describe("Filter by payee ID"),
|
|
713
803
|
month: z.string().optional().describe("Filter by month (YYYY-MM-DD, first of month)"),
|
|
804
|
+
lastKnowledgeOfServer: z.number().int().nonnegative().optional().describe("Delta request server knowledge. When provided, returns { transactions, server_knowledge }."),
|
|
714
805
|
} },
|
|
715
|
-
({ budgetId, sinceDate, type, accountId, categoryId, payeeId, month }) =>
|
|
806
|
+
({ budgetId, sinceDate, type, accountId, categoryId, payeeId, month, lastKnowledgeOfServer }) =>
|
|
716
807
|
run(async () => {
|
|
717
808
|
const bid = resolveBudgetId(budgetId);
|
|
718
809
|
let transactions;
|
|
810
|
+
let data;
|
|
811
|
+
const resourceFilters = [accountId, categoryId, payeeId, month].filter((value) => value !== undefined && value !== null && value !== "");
|
|
812
|
+
if (resourceFilters.length > 1) {
|
|
813
|
+
throw new Error("Provide only one of accountId, categoryId, payeeId, or month.");
|
|
814
|
+
}
|
|
719
815
|
|
|
720
816
|
if (accountId) {
|
|
721
|
-
|
|
817
|
+
({ data } = await api.transactions.getTransactionsByAccount(bid, accountId, sinceDate, type, lastKnowledgeOfServer));
|
|
722
818
|
transactions = data.transactions;
|
|
723
819
|
} else if (categoryId) {
|
|
724
|
-
|
|
820
|
+
({ data } = await api.transactions.getTransactionsByCategory(bid, categoryId, sinceDate, type, lastKnowledgeOfServer));
|
|
725
821
|
transactions = data.transactions;
|
|
726
822
|
} else if (payeeId) {
|
|
727
|
-
|
|
823
|
+
({ data } = await api.transactions.getTransactionsByPayee(bid, payeeId, sinceDate, type, lastKnowledgeOfServer));
|
|
728
824
|
transactions = data.transactions;
|
|
729
825
|
} else if (month) {
|
|
730
|
-
|
|
826
|
+
({ data } = await api.transactions.getTransactionsByMonth(bid, month, sinceDate, type, lastKnowledgeOfServer));
|
|
731
827
|
transactions = data.transactions;
|
|
732
828
|
} else {
|
|
733
|
-
|
|
829
|
+
({ data } = await api.transactions.getTransactions(bid, sinceDate, type, lastKnowledgeOfServer));
|
|
734
830
|
transactions = data.transactions;
|
|
735
831
|
}
|
|
736
832
|
|
|
737
|
-
return ok(transactions.map(formatTransaction));
|
|
833
|
+
return ok(collection(data, "transactions", transactions.map(formatTransaction), lastKnowledgeOfServer));
|
|
738
834
|
})
|
|
739
835
|
);
|
|
740
836
|
|
|
741
837
|
server.registerTool(
|
|
742
838
|
"get_transaction",
|
|
743
|
-
{ 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: {
|
|
744
840
|
budgetId: z.string().optional().describe("Budget ID (uses default if not provided)"),
|
|
745
841
|
transactionId: z.string().describe("Transaction ID"),
|
|
746
842
|
} },
|
|
747
843
|
({ budgetId, transactionId }) =>
|
|
748
844
|
run(async () => {
|
|
749
|
-
const { data } = await api.transactions.getTransactionById(resolveBudgetId(budgetId), transactionId);
|
|
845
|
+
const { data } = await api.transactions.getTransactionById(resolveBudgetId(budgetId), normalizeTransactionId(transactionId));
|
|
750
846
|
return ok(formatTransaction(data.transaction));
|
|
751
847
|
})
|
|
752
848
|
);
|
|
753
849
|
|
|
754
850
|
server.registerTool(
|
|
755
851
|
"create_transaction",
|
|
756
|
-
{ description: "Create a new transaction. Amounts are in dollars (positive for inflows, negative for outflows). Note: future-dated transactions cannot be created here
|
|
852
|
+
{ description: "Create a new transaction. Amounts are in dollars (positive for inflows, negative for outflows). Note: future-dated transactions cannot be created here - use create_scheduled_transaction instead.", inputSchema: {
|
|
757
853
|
budgetId: z.string().optional().describe("Budget ID (uses default if not provided)"),
|
|
758
854
|
accountId: z.string().describe("Account ID"),
|
|
759
855
|
date: z.string().describe("Transaction date (YYYY-MM-DD)"),
|
|
@@ -785,7 +881,7 @@ server.registerTool(
|
|
|
785
881
|
|
|
786
882
|
server.registerTool(
|
|
787
883
|
"create_transactions",
|
|
788
|
-
{ description: "Create multiple transactions at once. Amounts are in dollars. Returns created transactions and any duplicate import IDs. Future-dated transactions are not supported
|
|
884
|
+
{ description: "Create multiple transactions at once. Amounts are in dollars. Returns created transactions and any duplicate import IDs. Future-dated transactions are not supported - use create_scheduled_transaction instead.", inputSchema: {
|
|
789
885
|
budgetId: z.string().optional().describe("Budget ID (uses default if not provided)"),
|
|
790
886
|
transactions: z.array(z.object({
|
|
791
887
|
accountId: z.string().describe("Account ID"),
|
|
@@ -906,45 +1002,56 @@ server.registerTool(
|
|
|
906
1002
|
// ==================== Scheduled Transactions ====================
|
|
907
1003
|
|
|
908
1004
|
function formatScheduledTransaction(t) {
|
|
909
|
-
|
|
1005
|
+
const out = {
|
|
910
1006
|
id: t.id,
|
|
911
1007
|
date_first: t.date_first,
|
|
912
1008
|
date_next: t.date_next,
|
|
913
1009
|
frequency: t.frequency,
|
|
914
1010
|
amount: dollars(t.amount),
|
|
915
|
-
memo: t.memo,
|
|
916
|
-
flag_color: t.flag_color,
|
|
917
|
-
flag_name: t.flag_name,
|
|
1011
|
+
memo: t.memo ?? null,
|
|
1012
|
+
flag_color: t.flag_color ?? null,
|
|
1013
|
+
flag_name: t.flag_name ?? null,
|
|
918
1014
|
account_id: t.account_id,
|
|
919
1015
|
account_name: t.account_name,
|
|
920
|
-
payee_id: t.payee_id,
|
|
921
|
-
payee_name: t.payee_name,
|
|
922
|
-
category_id: t.category_id,
|
|
923
|
-
category_name: t.category_name,
|
|
924
|
-
transfer_account_id: t.transfer_account_id,
|
|
1016
|
+
payee_id: t.payee_id ?? null,
|
|
1017
|
+
payee_name: t.payee_name ?? null,
|
|
1018
|
+
category_id: t.category_id ?? null,
|
|
1019
|
+
category_name: t.category_name ?? null,
|
|
1020
|
+
transfer_account_id: t.transfer_account_id ?? null,
|
|
925
1021
|
deleted: t.deleted,
|
|
926
|
-
subtransactions: t.subtransactions?.map((s) =>
|
|
927
|
-
|
|
928
|
-
|
|
929
|
-
|
|
930
|
-
|
|
931
|
-
|
|
932
|
-
|
|
933
|
-
|
|
934
|
-
|
|
935
|
-
|
|
936
|
-
|
|
937
|
-
|
|
1022
|
+
subtransactions: t.subtransactions?.map((s) =>
|
|
1023
|
+
withCurrencyFields(
|
|
1024
|
+
{
|
|
1025
|
+
id: s.id,
|
|
1026
|
+
scheduled_transaction_id: s.scheduled_transaction_id,
|
|
1027
|
+
amount: dollars(s.amount),
|
|
1028
|
+
memo: s.memo ?? null,
|
|
1029
|
+
payee_id: s.payee_id ?? null,
|
|
1030
|
+
payee_name: s.payee_name ?? null,
|
|
1031
|
+
category_id: s.category_id ?? null,
|
|
1032
|
+
category_name: s.category_name ?? null,
|
|
1033
|
+
transfer_account_id: s.transfer_account_id ?? null,
|
|
1034
|
+
deleted: s.deleted,
|
|
1035
|
+
},
|
|
1036
|
+
s,
|
|
1037
|
+
["amount"]
|
|
1038
|
+
)
|
|
1039
|
+
),
|
|
938
1040
|
};
|
|
1041
|
+
return withCurrencyFields(out, t, ["amount"]);
|
|
939
1042
|
}
|
|
940
1043
|
|
|
941
1044
|
server.registerTool(
|
|
942
1045
|
"list_scheduled_transactions",
|
|
943
|
-
{ description: "List all scheduled (recurring) transactions
|
|
944
|
-
|
|
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: {
|
|
1047
|
+
budgetId: z.string().optional().describe("Budget ID (uses default if not provided)"),
|
|
1048
|
+
lastKnowledgeOfServer: z.number().int().nonnegative().optional().describe("Delta request server knowledge. When provided, returns { scheduled_transactions, server_knowledge }."),
|
|
1049
|
+
} },
|
|
1050
|
+
({ budgetId, lastKnowledgeOfServer }) =>
|
|
945
1051
|
run(async () => {
|
|
946
|
-
const { data } = await api.scheduledTransactions.getScheduledTransactions(resolveBudgetId(budgetId));
|
|
947
|
-
|
|
1052
|
+
const { data } = await api.scheduledTransactions.getScheduledTransactions(resolveBudgetId(budgetId), lastKnowledgeOfServer);
|
|
1053
|
+
const scheduledTransactions = data.scheduled_transactions.map(formatScheduledTransaction);
|
|
1054
|
+
return ok(collection(data, "scheduled_transactions", scheduledTransactions, lastKnowledgeOfServer));
|
|
948
1055
|
})
|
|
949
1056
|
);
|
|
950
1057
|
|
|
@@ -1012,7 +1119,7 @@ server.registerTool(
|
|
|
1012
1119
|
({ budgetId, scheduledTransactionId, accountId, date, frequency, amount, payeeId, payeeName, categoryId, memo, flagColor }) =>
|
|
1013
1120
|
run(async () => {
|
|
1014
1121
|
const bid = resolveBudgetId(budgetId);
|
|
1015
|
-
// PUT replaces the full resource
|
|
1122
|
+
// PUT replaces the full resource - fetch current values to merge with updates
|
|
1016
1123
|
const { data: current } = await api.scheduledTransactions.getScheduledTransactionById(bid, scheduledTransactionId);
|
|
1017
1124
|
const existing = current.scheduled_transaction;
|
|
1018
1125
|
|
|
@@ -1068,14 +1175,14 @@ server.registerTool(
|
|
|
1068
1175
|
for (const c of g.categories) {
|
|
1069
1176
|
if (c.hidden) continue;
|
|
1070
1177
|
if (c.name.toLowerCase().includes(q)) {
|
|
1071
|
-
matches.push({
|
|
1178
|
+
matches.push(withCurrencyFields({
|
|
1072
1179
|
id: c.id,
|
|
1073
1180
|
name: c.name,
|
|
1074
1181
|
group: g.name,
|
|
1075
1182
|
budgeted: dollars(c.budgeted),
|
|
1076
1183
|
activity: dollars(c.activity),
|
|
1077
1184
|
balance: dollars(c.balance),
|
|
1078
|
-
});
|
|
1185
|
+
}, c, ["budgeted", "activity", "balance"]));
|
|
1079
1186
|
}
|
|
1080
1187
|
}
|
|
1081
1188
|
}
|
|
@@ -1104,21 +1211,91 @@ server.registerTool(
|
|
|
1104
1211
|
|
|
1105
1212
|
server.registerTool(
|
|
1106
1213
|
"review_unapproved",
|
|
1107
|
-
{ 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)") } },
|
|
1108
1215
|
({ budgetId }) =>
|
|
1109
1216
|
run(async () => {
|
|
1110
|
-
const
|
|
1111
|
-
|
|
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
|
+
|
|
1112
1270
|
const isCategorized = (t) => (t.category_id && t.category_name !== "Uncategorized")
|
|
1113
|
-
|| (t.subtransactions && t.subtransactions.length > 0)
|
|
1114
|
-
|| t.transfer_account_id;
|
|
1271
|
+
|| (t.subtransactions && t.subtransactions.length > 0)
|
|
1272
|
+
|| t.transfer_account_id;
|
|
1115
1273
|
const categorized = [], uncategorized = [];
|
|
1116
|
-
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
|
+
|
|
1117
1294
|
return ok({
|
|
1118
|
-
total:
|
|
1295
|
+
total: flaggedTxns.length,
|
|
1119
1296
|
ready_to_approve: {
|
|
1120
1297
|
count: categorized.length,
|
|
1121
|
-
|
|
1298
|
+
by_payee: groups,
|
|
1122
1299
|
},
|
|
1123
1300
|
needs_category_first: {
|
|
1124
1301
|
count: uncategorized.length,
|
|
@@ -1129,6 +1306,35 @@ server.registerTool(
|
|
|
1129
1306
|
})
|
|
1130
1307
|
);
|
|
1131
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
|
+
|
|
1132
1338
|
// --- Start ---
|
|
1133
1339
|
|
|
1134
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.
|
|
3
|
+
"version": "1.6.0",
|
|
4
4
|
"description": "YNAB MCP server with full API coverage",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "index.js",
|
|
@@ -15,7 +15,11 @@
|
|
|
15
15
|
"test": "node test.js"
|
|
16
16
|
},
|
|
17
17
|
"dependencies": {
|
|
18
|
-
"@modelcontextprotocol/sdk": "^1.
|
|
19
|
-
"ynab": "^2.5.0"
|
|
18
|
+
"@modelcontextprotocol/sdk": "^1.29.0",
|
|
19
|
+
"ynab": "^2.5.0",
|
|
20
|
+
"zod": "^4.3.6"
|
|
21
|
+
},
|
|
22
|
+
"engines": {
|
|
23
|
+
"node": ">=18"
|
|
20
24
|
}
|
|
21
25
|
}
|