@oliverames/ynab-mcp-server 1.0.0 → 1.2.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 +318 -0
  2. package/index.js +430 -51
  3. package/package.json +21 -33
package/README.md ADDED
@@ -0,0 +1,318 @@
1
+ <p align="center">
2
+ <img src="https://api.ynab.com/papi/logo_api_meadow.svg" alt="YNAB API" width="200">
3
+ </p>
4
+
5
+ <h1 align="center">YNAB MCP Server</h1>
6
+
7
+ <p align="center">
8
+ <strong>The complete Model Context Protocol server for YNAB</strong><br>
9
+ <em>Give your AI assistant full access to your budget</em>
10
+ </p>
11
+
12
+ <p align="center">
13
+ <a href="https://www.npmjs.com/package/@oliverames/ynab-mcp-server"><img src="https://img.shields.io/npm/v/@oliverames/ynab-mcp-server" alt="npm version"></a>
14
+ <a href="https://modelcontextprotocol.io"><img src="https://img.shields.io/badge/MCP-compatible-blue" alt="MCP compatible"></a>
15
+ <a href="https://api.ynab.com"><img src="https://img.shields.io/badge/YNAB%20API-v1.79-green" alt="YNAB API v1.79"></a>
16
+ <a href="https://opensource.org/licenses/MIT"><img src="https://img.shields.io/badge/license-MIT-brightgreen" alt="License: MIT"></a>
17
+ </p>
18
+
19
+ ---
20
+
21
+ **43 tools. 100% API coverage. Zero configuration.**
22
+
23
+ Connect any MCP-compatible AI assistant — Claude, GPT, or your own agents — to your YNAB budget. Read transactions, categorize spending, manage accounts, review scheduled payments, track money movements, and more. All monetary values are automatically converted between dollars and YNAB's internal milliunits format so the AI never has to think about it.
24
+
25
+ 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.
26
+
27
+ ---
28
+
29
+ ## Quick Start
30
+
31
+ ### 1. Get a YNAB Personal Access Token
32
+
33
+ Go to [YNAB Developer Settings](https://app.ynab.com/settings/developer) and create a new personal access token.
34
+
35
+ ### 2. Configure your MCP client
36
+
37
+ **Claude Desktop** (`claude_desktop_config.json`):
38
+
39
+ ```json
40
+ {
41
+ "mcpServers": {
42
+ "ynab": {
43
+ "command": "npx",
44
+ "args": ["-y", "@oliverames/ynab-mcp-server"],
45
+ "env": {
46
+ "YNAB_API_TOKEN": "your-token-here"
47
+ }
48
+ }
49
+ }
50
+ }
51
+ ```
52
+
53
+ **Claude Code** (`.mcp.json`):
54
+
55
+ ```json
56
+ {
57
+ "mcpServers": {
58
+ "ynab": {
59
+ "command": "npx",
60
+ "args": ["-y", "@oliverames/ynab-mcp-server"],
61
+ "env": {
62
+ "YNAB_API_TOKEN": "your-token-here"
63
+ }
64
+ }
65
+ }
66
+ }
67
+ ```
68
+
69
+ Or install globally and point to the binary directly:
70
+
71
+ ```bash
72
+ npm install -g @oliverames/ynab-mcp-server
73
+ ```
74
+
75
+ ```json
76
+ {
77
+ "mcpServers": {
78
+ "ynab": {
79
+ "command": "ynab-mcp-server",
80
+ "env": {
81
+ "YNAB_API_TOKEN": "your-token-here",
82
+ "YNAB_BUDGET_ID": "optional-default-budget-id"
83
+ }
84
+ }
85
+ }
86
+ }
87
+ ```
88
+
89
+ That's it. Your AI can now talk to YNAB.
90
+
91
+ ---
92
+
93
+ ## What You Can Do
94
+
95
+ | Ask your AI... | What happens under the hood |
96
+ |---|---|
97
+ | "How much did I spend on groceries this month?" | `search_categories` → `get_month_category` |
98
+ | "Show me all unapproved transactions" | `review_unapproved` groups by readiness |
99
+ | "Log a $50 Costco trip under groceries" | `search_payees` → `search_categories` → `create_transaction` |
100
+ | "Set up monthly $1,500 rent on the 1st" | `create_scheduled_transaction` with `monthly` frequency |
101
+ | "Move $200 from emergency fund to dining" | `search_categories` → `update_month_category` (x2) |
102
+ | "Categorize all my Amazon orders from this week" | `get_transactions` (filtered) → `update_transactions` (batch) |
103
+ | "Create a 'Side Projects' spending category" | `search_categories` (find group) → `create_category` |
104
+ | "How has my budget been re-allocated this month?" | `get_money_movements_by_month` |
105
+ | "What recurring payments do I have?" | `list_scheduled_transactions` |
106
+ | "Import my latest bank transactions" | `import_transactions` triggers linked account sync |
107
+
108
+ ---
109
+
110
+ ## Features
111
+
112
+ **Complete YNAB API v1.79 coverage** with 43 tools:
113
+
114
+ | Resource | Tools | Capabilities |
115
+ |----------|-------|-------------|
116
+ | **Budgets** | 4 | List, view details, settings |
117
+ | **Accounts** | 3 | List, view, create |
118
+ | **Categories** | 9 | Full CRUD, groups, search, goals, monthly budgets |
119
+ | **Payees** | 4 | List, view, rename, search |
120
+ | **Payee Locations** | 3 | GPS coordinates for mobile transactions |
121
+ | **Months** | 2 | Monthly summaries with per-category breakdown |
122
+ | **Money Movements** | 4 | Budget re-allocation tracking |
123
+ | **Transactions** | 8 | Full CRUD, bulk ops, split transactions, multi-filter |
124
+ | **Scheduled Transactions** | 5 | Full CRUD for recurring transactions |
125
+ | **Convenience** | 1 | Unapproved transaction review workflow |
126
+
127
+ ### Design Decisions
128
+
129
+ - **Dollar amounts everywhere** — inputs and outputs are in dollars (`-12.34`), never milliunits (`-12340`). Conversion is automatic and transparent.
130
+ - **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.
131
+ - **Split transactions** — first-class support for subtransactions in create, read, and format operations.
132
+ - **Bulk operations** — `create_transactions` and `update_transactions` handle arrays in a single API call.
133
+ - **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.
134
+ - **Fuzzy search** — `search_categories` and `search_payees` do case-insensitive partial matching across all entries.
135
+ - **Approval workflow** — `review_unapproved` groups transactions into "ready to approve" (has category) and "needs attention" (uncategorized), with a built-in warning against approving uncategorized entries.
136
+
137
+ ---
138
+
139
+ ## Tools Reference
140
+
141
+ ### User & Budgets
142
+
143
+ | Tool | Description |
144
+ |------|-------------|
145
+ | `get_user` | Get the authenticated user |
146
+ | `list_budgets` | List all budgets with IDs, names, and date ranges |
147
+ | `get_budget` | Get budget summary (name, currency, account/category/payee counts) |
148
+ | `get_budget_settings` | Get currency and date format settings |
149
+
150
+ ### Accounts
151
+
152
+ | Tool | Description |
153
+ |------|-------------|
154
+ | `list_accounts` | List all accounts with balances in dollars |
155
+ | `get_account` | Get details for a specific account |
156
+ | `create_account` | Create a new account (checking, savings, creditCard, mortgage, etc.) |
157
+
158
+ **Supported account types:** `checking`, `savings`, `cash`, `creditCard`, `lineOfCredit`, `otherAsset`, `otherLiability`, `mortgage`, `autoLoan`, `studentLoan`, `personalLoan`, `medicalDebt`, `otherDebt`
159
+
160
+ ### Categories & Category Groups
161
+
162
+ | Tool | Description |
163
+ |------|-------------|
164
+ | `list_categories` | List all category groups and their categories with budgeted/activity/balance |
165
+ | `get_category` | Get full category details including goal progress and cadence |
166
+ | `get_month_category` | Get category budget for a specific month |
167
+ | `update_month_category` | Set the budgeted amount for a category in a month |
168
+ | `update_category` | Update name, note, goal target, or move to a different group |
169
+ | `create_category` | Create a new category in an existing group (with optional goal) |
170
+ | `create_category_group` | Create a new category group |
171
+ | `update_category_group` | Rename a category group |
172
+ | `search_categories` | Case-insensitive partial name search (e.g., "groc" finds "Groceries") |
173
+
174
+ ### Payees
175
+
176
+ | Tool | Description |
177
+ |------|-------------|
178
+ | `list_payees` | List all payees with transfer account mappings |
179
+ | `get_payee` | Get payee details |
180
+ | `update_payee` | Rename a payee |
181
+ | `search_payees` | Case-insensitive partial name search |
182
+
183
+ ### Payee Locations
184
+
185
+ | Tool | Description |
186
+ |------|-------------|
187
+ | `list_payee_locations` | List all payee locations (GPS coordinates from mobile app) |
188
+ | `get_payee_location` | Get a specific payee location |
189
+ | `get_payee_locations_by_payee` | Get all locations for a specific payee |
190
+
191
+ ### Months
192
+
193
+ | Tool | Description |
194
+ |------|-------------|
195
+ | `list_months` | List budget months with income, budgeted, activity, to-be-budgeted, age of money |
196
+ | `get_month` | Get month detail with per-category budget/activity/balance breakdown |
197
+
198
+ ### Money Movements
199
+
200
+ | Tool | Description |
201
+ |------|-------------|
202
+ | `list_money_movements` | List all money movements (budget re-allocations between categories) |
203
+ | `get_money_movements_by_month` | Get money movements for a specific month |
204
+ | `list_money_movement_groups` | List all money movement groups (batched re-allocations) |
205
+ | `get_money_movement_groups_by_month` | Get money movement groups for a specific month |
206
+
207
+ ### Transactions
208
+
209
+ | Tool | Description |
210
+ |------|-------------|
211
+ | `get_transactions` | Get transactions with filters: by account, category, payee, month, or status (`unapproved`/`uncategorized`) |
212
+ | `get_transaction` | Get a single transaction by ID (includes subtransactions) |
213
+ | `create_transaction` | Create a transaction with optional split (subtransactions must sum to total) |
214
+ | `create_transactions` | Bulk create multiple transactions in a single API call |
215
+ | `update_transaction` | Partial update — only specified fields change |
216
+ | `update_transactions` | Batch update multiple transactions at once |
217
+ | `delete_transaction` | Delete a transaction |
218
+ | `import_transactions` | Trigger import from linked bank accounts |
219
+
220
+ ### Scheduled Transactions
221
+
222
+ | Tool | Description |
223
+ |------|-------------|
224
+ | `list_scheduled_transactions` | List all recurring transactions |
225
+ | `get_scheduled_transaction` | Get a specific scheduled transaction |
226
+ | `create_scheduled_transaction` | Create a recurring transaction with frequency |
227
+ | `update_scheduled_transaction` | Update (fetch-then-merge preserves unchanged fields) |
228
+ | `delete_scheduled_transaction` | Delete a scheduled transaction |
229
+
230
+ **Supported frequencies:** `never`, `daily`, `weekly`, `everyOtherWeek`, `twiceAMonth`, `every4Weeks`, `monthly`, `everyOtherMonth`, `every3Months`, `every4Months`, `twiceAYear`, `yearly`, `everyOtherYear`
231
+
232
+ ### Convenience
233
+
234
+ | Tool | Description |
235
+ |------|-------------|
236
+ | `review_unapproved` | Get unapproved transactions grouped by readiness: "ready to approve" (categorized) vs. "needs category first" (uncategorized). Includes a warning against blind approval. |
237
+
238
+ ---
239
+
240
+ ## Environment Variables
241
+
242
+ | Variable | Required | Description |
243
+ |----------|----------|-------------|
244
+ | `YNAB_API_TOKEN` | Yes | [Personal access token](https://app.ynab.com/settings/developer) from YNAB Developer Settings |
245
+ | `YNAB_BUDGET_ID` | No | Default budget ID. If omitted, uses `"last-used"` (your most recently accessed budget). Run `list_budgets` to find IDs. |
246
+
247
+ ---
248
+
249
+ ## Amount Handling
250
+
251
+ All amounts in tool inputs and outputs are in **dollars** (e.g., `-12.34` for a $12.34 outflow). The server converts to/from YNAB's internal milliunits format automatically.
252
+
253
+ | Direction | Sign | Example |
254
+ |-----------|------|---------|
255
+ | Outflow (spending) | Negative | `-50.00` |
256
+ | Inflow (income) | Positive | `2500.00` |
257
+ | Transfer out | Negative | `-1000.00` |
258
+ | Transfer in | Positive | `1000.00` |
259
+
260
+ ---
261
+
262
+ ## Rate Limiting
263
+
264
+ 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.
265
+
266
+ ---
267
+
268
+ ## Architecture
269
+
270
+ ```
271
+ ┌─────────────────────┐ ┌──────────────────┐ ┌──────────────┐
272
+ │ AI Assistant │────▶│ YNAB MCP Server │────▶│ YNAB API │
273
+ │ (Claude, GPT, etc) │◀────│ (this package) │◀────│ api.ynab.com│
274
+ └─────────────────────┘ └──────────────────┘ └──────────────┘
275
+ MCP stdio transport HTTPS/REST
276
+ ```
277
+
278
+ - **Transport:** stdio (standard MCP server pattern)
279
+ - **Auth:** Bearer token via `YNAB_API_TOKEN` environment variable
280
+ - **SDK:** Official [`ynab`](https://www.npmjs.com/package/ynab) v2.5+ for core endpoints, direct `fetch` for newer API features
281
+ - **Validation:** All parameters validated with [Zod](https://zod.dev) schemas
282
+ - **Error handling:** API errors are caught, formatted, and returned as MCP error responses with detail messages
283
+
284
+ ---
285
+
286
+ ## Testing
287
+
288
+ The test suite (43 tests) runs against a live YNAB budget. It creates test data and cleans up after itself:
289
+
290
+ ```bash
291
+ YNAB_API_TOKEN=your-token YNAB_BUDGET_ID=your-budget-id npm test
292
+ ```
293
+
294
+ Tests cover all tool categories: reads, writes, bulk operations, search, split transactions, scheduled transaction CRUD with fetch-then-merge verification, category/group creation, money movements, and payee locations.
295
+
296
+ ---
297
+
298
+ ## Development
299
+
300
+ ```bash
301
+ git clone https://github.com/oliverames/ynab-mcp-server.git
302
+ cd ynab-mcp-server
303
+ npm install
304
+ YNAB_API_TOKEN=your-token npm start
305
+ ```
306
+
307
+ ### Dependencies
308
+
309
+ - [`@modelcontextprotocol/sdk`](https://www.npmjs.com/package/@modelcontextprotocol/sdk) — MCP server framework
310
+ - [`ynab`](https://www.npmjs.com/package/ynab) — Official YNAB JavaScript client
311
+
312
+ Zero additional dependencies. No build step. Pure ESM.
313
+
314
+ ---
315
+
316
+ ## License
317
+
318
+ MIT
package/index.js CHANGED
@@ -19,8 +19,7 @@ const DEFAULT_BUDGET_ID = process.env.YNAB_BUDGET_ID;
19
19
  // --- Helpers ---
20
20
 
21
21
  function resolveBudgetId(input) {
22
- const id = input || DEFAULT_BUDGET_ID;
23
- if (!id) throw new Error("budgetId is required (pass it or set YNAB_BUDGET_ID env var)");
22
+ const id = input || DEFAULT_BUDGET_ID || "last-used";
24
23
  return id;
25
24
  }
26
25
 
@@ -40,16 +39,38 @@ async function run(fn) {
40
39
  try {
41
40
  return await fn();
42
41
  } catch (e) {
43
- const msg = e?.error?.detail || e?.message || String(e);
44
- return { content: [{ type: "text", text: `Error: ${msg}` }] };
42
+ const detail = e?.error?.detail;
43
+ const name = e?.error?.name;
44
+ const msg = detail
45
+ ? (name ? `${name}: ${detail}` : detail)
46
+ : (e?.message || String(e));
47
+ return { content: [{ type: "text", text: `Error: ${msg}` }], isError: true };
45
48
  }
46
49
  }
47
50
 
51
+ // Direct API helper for endpoints not yet in the ynab SDK
52
+ const BASE_URL = "https://api.ynab.com/v1";
53
+ async function ynabFetch(path, { method = "GET", body } = {}) {
54
+ const opts = {
55
+ method,
56
+ headers: { Authorization: `Bearer ${API_TOKEN}`, "Content-Type": "application/json" },
57
+ };
58
+ if (body) opts.body = JSON.stringify(body);
59
+ const res = await fetch(`${BASE_URL}${path}`, opts);
60
+ const json = await res.json();
61
+ if (!res.ok) {
62
+ const err = new Error(json?.error?.detail || `HTTP ${res.status}`);
63
+ err.error = json?.error;
64
+ throw err;
65
+ }
66
+ return json.data;
67
+ }
68
+
48
69
  // --- Server ---
49
70
 
50
71
  const server = new McpServer({
51
72
  name: "ynab-mcp-server",
52
- version: "1.0.0",
73
+ version: "1.2.0",
53
74
  });
54
75
 
55
76
  // ==================== User & Budgets ====================
@@ -61,16 +82,16 @@ server.tool("get_user", "Get the authenticated user", {}, () =>
61
82
  })
62
83
  );
63
84
 
64
- server.tool("list_budgets", "List all budgets", {}, () =>
85
+ server.tool("list_budgets", "List all budgets. Use a budget ID from the results in other tools, or omit budgetId to use the last-used budget.", {}, () =>
65
86
  run(async () => {
66
87
  const { data } = await api.budgets.getBudgets();
67
- return ok(data.budgets.map((b) => ({ id: b.id, name: b.name, last_modified_on: b.last_modified_on })));
88
+ return ok(data.budgets.map((b) => ({ id: b.id, name: b.name, last_modified_on: b.last_modified_on, first_month: b.first_month, last_month: b.last_month })));
68
89
  })
69
90
  );
70
91
 
71
92
  server.tool(
72
93
  "get_budget",
73
- "Get full budget details including accounts, categories, and payees",
94
+ "Get a budget summary including name, currency format, and account/category/payee counts",
74
95
  { budgetId: z.string().optional().describe("Budget ID (uses default if not provided)") },
75
96
  ({ budgetId }) =>
76
97
  run(async () => {
@@ -79,6 +100,10 @@ server.tool(
79
100
  return ok({
80
101
  id: b.id,
81
102
  name: b.name,
103
+ last_modified_on: b.last_modified_on,
104
+ first_month: b.first_month,
105
+ last_month: b.last_month,
106
+ date_format: b.date_format,
82
107
  currency_format: b.currency_format,
83
108
  accounts: b.accounts?.length,
84
109
  categories: b.categories?.length,
@@ -100,6 +125,26 @@ server.tool(
100
125
 
101
126
  // ==================== Accounts ====================
102
127
 
128
+ function formatAccount(a) {
129
+ const out = {
130
+ id: a.id,
131
+ name: a.name,
132
+ type: a.type,
133
+ on_budget: a.on_budget,
134
+ closed: a.closed,
135
+ balance: dollars(a.balance),
136
+ cleared_balance: dollars(a.cleared_balance),
137
+ uncleared_balance: dollars(a.uncleared_balance),
138
+ transfer_payee_id: a.transfer_payee_id,
139
+ direct_import_linked: a.direct_import_linked,
140
+ direct_import_in_error: a.direct_import_in_error,
141
+ last_reconciled_at: a.last_reconciled_at,
142
+ deleted: a.deleted,
143
+ };
144
+ if ("note" in a) out.note = a.note;
145
+ return out;
146
+ }
147
+
103
148
  server.tool(
104
149
  "list_accounts",
105
150
  "List all accounts in a budget",
@@ -107,18 +152,7 @@ server.tool(
107
152
  ({ budgetId }) =>
108
153
  run(async () => {
109
154
  const { data } = await api.accounts.getAccounts(resolveBudgetId(budgetId));
110
- return ok(
111
- data.accounts.map((a) => ({
112
- id: a.id,
113
- name: a.name,
114
- type: a.type,
115
- on_budget: a.on_budget,
116
- closed: a.closed,
117
- balance: dollars(a.balance),
118
- cleared_balance: dollars(a.cleared_balance),
119
- uncleared_balance: dollars(a.uncleared_balance),
120
- }))
121
- );
155
+ return ok(data.accounts.map(formatAccount));
122
156
  })
123
157
  );
124
158
 
@@ -132,8 +166,7 @@ server.tool(
132
166
  ({ budgetId, accountId }) =>
133
167
  run(async () => {
134
168
  const { data } = await api.accounts.getAccountById(resolveBudgetId(budgetId), accountId);
135
- const a = data.account;
136
- return ok({ ...a, balance: dollars(a.balance), cleared_balance: dollars(a.cleared_balance), uncleared_balance: dollars(a.uncleared_balance) });
169
+ return ok(formatAccount(data.account));
137
170
  })
138
171
  );
139
172
 
@@ -151,12 +184,39 @@ server.tool(
151
184
  const { data } = await api.accounts.createAccount(resolveBudgetId(budgetId), {
152
185
  account: { name, type, balance: milliunits(bal) },
153
186
  });
154
- return ok(data.account);
187
+ return ok(formatAccount(data.account));
155
188
  })
156
189
  );
157
190
 
158
191
  // ==================== Categories ====================
159
192
 
193
+ function formatCategory(c) {
194
+ return {
195
+ id: c.id,
196
+ category_group_id: c.category_group_id,
197
+ category_group_name: c.category_group_name,
198
+ name: c.name,
199
+ hidden: c.hidden,
200
+ note: c.note,
201
+ budgeted: dollars(c.budgeted),
202
+ activity: dollars(c.activity),
203
+ balance: dollars(c.balance),
204
+ goal_type: c.goal_type,
205
+ goal_day: c.goal_day,
206
+ goal_cadence: c.goal_cadence,
207
+ goal_cadence_frequency: c.goal_cadence_frequency,
208
+ goal_creation_month: c.goal_creation_month,
209
+ goal_target: dollars(c.goal_target),
210
+ goal_target_date: c.goal_target_date,
211
+ goal_percentage_complete: c.goal_percentage_complete,
212
+ goal_months_to_budget: c.goal_months_to_budget,
213
+ goal_under_funded: dollars(c.goal_under_funded),
214
+ goal_overall_funded: dollars(c.goal_overall_funded),
215
+ goal_overall_left: dollars(c.goal_overall_left),
216
+ goal_needs_whole_amount: c.goal_needs_whole_amount,
217
+ };
218
+ }
219
+
160
220
  server.tool(
161
221
  "list_categories",
162
222
  "List all category groups and their categories",
@@ -176,6 +236,7 @@ server.tool(
176
236
  budgeted: dollars(c.budgeted),
177
237
  activity: dollars(c.activity),
178
238
  balance: dollars(c.balance),
239
+ goal_type: c.goal_type,
179
240
  })),
180
241
  }))
181
242
  );
@@ -192,8 +253,7 @@ server.tool(
192
253
  ({ budgetId, categoryId }) =>
193
254
  run(async () => {
194
255
  const { data } = await api.categories.getCategoryById(resolveBudgetId(budgetId), categoryId);
195
- const c = data.category;
196
- return ok({ ...c, budgeted: dollars(c.budgeted), activity: dollars(c.activity), balance: dollars(c.balance), goal_target: dollars(c.goal_target) });
256
+ return ok(formatCategory(data.category));
197
257
  })
198
258
  );
199
259
 
@@ -208,8 +268,7 @@ server.tool(
208
268
  ({ budgetId, month, categoryId }) =>
209
269
  run(async () => {
210
270
  const { data } = await api.categories.getMonthCategoryById(resolveBudgetId(budgetId), month, categoryId);
211
- const c = data.category;
212
- return ok({ ...c, budgeted: dollars(c.budgeted), activity: dollars(c.activity), balance: dollars(c.balance) });
271
+ return ok(formatCategory(data.category));
213
272
  })
214
273
  );
215
274
 
@@ -227,8 +286,94 @@ server.tool(
227
286
  const { data } = await api.categories.updateMonthCategory(resolveBudgetId(budgetId), month, categoryId, {
228
287
  category: { budgeted: milliunits(budgeted) },
229
288
  });
230
- const c = data.category;
231
- return ok({ ...c, budgeted: dollars(c.budgeted), activity: dollars(c.activity), balance: dollars(c.balance) });
289
+ return ok(formatCategory(data.category));
290
+ })
291
+ );
292
+
293
+ server.tool(
294
+ "update_category",
295
+ "Update a category's name, note, goal target, or move it to a different group",
296
+ {
297
+ budgetId: z.string().optional().describe("Budget ID (uses default if not provided)"),
298
+ categoryId: z.string().describe("Category ID"),
299
+ name: z.string().optional().describe("New category name"),
300
+ note: z.string().nullable().optional().describe("Category note (null to clear)"),
301
+ categoryGroupId: z.string().optional().describe("Move to a different category group"),
302
+ goalTarget: z.number().nullable().optional().describe("Goal target amount in dollars (only if category already has a goal)"),
303
+ },
304
+ ({ budgetId, categoryId, name, note, categoryGroupId, goalTarget }) =>
305
+ run(async () => {
306
+ const cat = {};
307
+ if (name !== undefined) cat.name = name;
308
+ if (note !== undefined) cat.note = note;
309
+ if (categoryGroupId !== undefined) cat.category_group_id = categoryGroupId;
310
+ if (goalTarget !== undefined) cat.goal_target = goalTarget != null ? milliunits(goalTarget) : null;
311
+
312
+ const { data } = await api.categories.updateCategory(resolveBudgetId(budgetId), categoryId, {
313
+ category: cat,
314
+ });
315
+ return ok(formatCategory(data.category));
316
+ })
317
+ );
318
+
319
+ server.tool(
320
+ "create_category",
321
+ "Create a new category in a category group",
322
+ {
323
+ budgetId: z.string().optional().describe("Budget ID (uses default if not provided)"),
324
+ categoryGroupId: z.string().describe("Category group ID to create the category in"),
325
+ name: z.string().describe("Category name"),
326
+ note: z.string().optional().describe("Category note"),
327
+ goalTarget: z.number().optional().describe("Goal target amount in dollars (creates a 'Needed for Spending' goal)"),
328
+ goalTargetDate: z.string().optional().describe("Goal target date in ISO format (e.g. 2026-12-01)"),
329
+ },
330
+ ({ budgetId, categoryGroupId, name, note, goalTarget, goalTargetDate }) =>
331
+ run(async () => {
332
+ const bid = resolveBudgetId(budgetId);
333
+ const cat = { category_group_id: categoryGroupId, name };
334
+ if (note !== undefined) cat.note = note;
335
+ if (goalTarget !== undefined) cat.goal_target = milliunits(goalTarget);
336
+ if (goalTargetDate !== undefined) cat.goal_target_date = goalTargetDate;
337
+ const data = await ynabFetch(`/budgets/${bid}/categories`, {
338
+ method: "POST",
339
+ body: { category: cat },
340
+ });
341
+ return ok(formatCategory(data.category));
342
+ })
343
+ );
344
+
345
+ server.tool(
346
+ "create_category_group",
347
+ "Create a new category group",
348
+ {
349
+ budgetId: z.string().optional().describe("Budget ID (uses default if not provided)"),
350
+ name: z.string().describe("Category group name (max 50 characters)"),
351
+ },
352
+ ({ budgetId, name }) =>
353
+ run(async () => {
354
+ const data = await ynabFetch(`/budgets/${resolveBudgetId(budgetId)}/category_groups`, {
355
+ method: "POST",
356
+ body: { category_group: { name } },
357
+ });
358
+ return ok(data.category_group);
359
+ })
360
+ );
361
+
362
+ server.tool(
363
+ "update_category_group",
364
+ "Rename a category group",
365
+ {
366
+ budgetId: z.string().optional().describe("Budget ID (uses default if not provided)"),
367
+ categoryGroupId: z.string().describe("Category group ID"),
368
+ name: z.string().describe("New category group name (max 50 characters)"),
369
+ },
370
+ ({ budgetId, categoryGroupId, name }) =>
371
+ run(async () => {
372
+ const data = await ynabFetch(`/budgets/${resolveBudgetId(budgetId)}/category_groups/${categoryGroupId}`, {
373
+ method: "PATCH",
374
+ body: { category_group: { name } },
375
+ });
376
+ return ok(data.category_group);
232
377
  })
233
378
  );
234
379
 
@@ -276,6 +421,47 @@ server.tool(
276
421
  })
277
422
  );
278
423
 
424
+ // ==================== Payee Locations ====================
425
+
426
+ server.tool(
427
+ "list_payee_locations",
428
+ "List all payee locations (GPS coordinates where transactions occurred)",
429
+ { budgetId: z.string().optional().describe("Budget ID (uses default if not provided)") },
430
+ ({ budgetId }) =>
431
+ run(async () => {
432
+ const { data } = await api.payeeLocations.getPayeeLocations(resolveBudgetId(budgetId));
433
+ return ok(data.payee_locations);
434
+ })
435
+ );
436
+
437
+ server.tool(
438
+ "get_payee_location",
439
+ "Get a specific payee location",
440
+ {
441
+ budgetId: z.string().optional().describe("Budget ID (uses default if not provided)"),
442
+ payeeLocationId: z.string().describe("Payee location ID"),
443
+ },
444
+ ({ budgetId, payeeLocationId }) =>
445
+ run(async () => {
446
+ const { data } = await api.payeeLocations.getPayeeLocationById(resolveBudgetId(budgetId), payeeLocationId);
447
+ return ok(data.payee_location);
448
+ })
449
+ );
450
+
451
+ server.tool(
452
+ "get_payee_locations_by_payee",
453
+ "Get all locations for a specific payee",
454
+ {
455
+ budgetId: z.string().optional().describe("Budget ID (uses default if not provided)"),
456
+ payeeId: z.string().describe("Payee ID"),
457
+ },
458
+ ({ budgetId, payeeId }) =>
459
+ run(async () => {
460
+ const { data } = await api.payeeLocations.getPayeeLocationsByPayee(resolveBudgetId(budgetId), payeeId);
461
+ return ok(data.payee_locations);
462
+ })
463
+ );
464
+
279
465
  // ==================== Months ====================
280
466
 
281
467
  server.tool(
@@ -292,6 +478,7 @@ server.tool(
292
478
  budgeted: dollars(m.budgeted),
293
479
  activity: dollars(m.activity),
294
480
  to_be_budgeted: dollars(m.to_be_budgeted),
481
+ age_of_money: m.age_of_money,
295
482
  }))
296
483
  );
297
484
  })
@@ -314,17 +501,88 @@ server.tool(
314
501
  budgeted: dollars(m.budgeted),
315
502
  activity: dollars(m.activity),
316
503
  to_be_budgeted: dollars(m.to_be_budgeted),
504
+ age_of_money: m.age_of_money,
317
505
  categories: m.categories?.map((c) => ({
318
506
  id: c.id,
319
507
  name: c.name,
508
+ category_group_name: c.category_group_name,
320
509
  budgeted: dollars(c.budgeted),
321
510
  activity: dollars(c.activity),
322
511
  balance: dollars(c.balance),
512
+ goal_type: c.goal_type,
513
+ goal_target: dollars(c.goal_target),
514
+ goal_under_funded: dollars(c.goal_under_funded),
323
515
  })),
324
516
  });
325
517
  })
326
518
  );
327
519
 
520
+ // ==================== Money Movements ====================
521
+
522
+ function formatMoneyMovement(m) {
523
+ return {
524
+ id: m.id,
525
+ month: m.month,
526
+ moved_at: m.moved_at,
527
+ note: m.note,
528
+ money_movement_group_id: m.money_movement_group_id,
529
+ performed_by_user_id: m.performed_by_user_id,
530
+ from_category_id: m.from_category_id,
531
+ to_category_id: m.to_category_id,
532
+ amount: dollars(m.amount),
533
+ };
534
+ }
535
+
536
+ server.tool(
537
+ "list_money_movements",
538
+ "List all money movements (budget re-allocations between categories)",
539
+ { budgetId: z.string().optional().describe("Budget ID (uses default if not provided)") },
540
+ ({ budgetId }) =>
541
+ run(async () => {
542
+ const data = await ynabFetch(`/budgets/${resolveBudgetId(budgetId)}/money_movements`);
543
+ return ok(data.money_movements.map(formatMoneyMovement));
544
+ })
545
+ );
546
+
547
+ server.tool(
548
+ "get_money_movements_by_month",
549
+ "Get money movements for a specific month",
550
+ {
551
+ budgetId: z.string().optional().describe("Budget ID (uses default if not provided)"),
552
+ month: z.string().describe("Month in YYYY-MM-DD format (first of month), or 'current'"),
553
+ },
554
+ ({ budgetId, month }) =>
555
+ run(async () => {
556
+ const data = await ynabFetch(`/budgets/${resolveBudgetId(budgetId)}/months/${month}/money_movements`);
557
+ return ok(data.money_movements.map(formatMoneyMovement));
558
+ })
559
+ );
560
+
561
+ server.tool(
562
+ "list_money_movement_groups",
563
+ "List all money movement groups (batches of related money movements)",
564
+ { budgetId: z.string().optional().describe("Budget ID (uses default if not provided)") },
565
+ ({ budgetId }) =>
566
+ run(async () => {
567
+ const data = await ynabFetch(`/budgets/${resolveBudgetId(budgetId)}/money_movement_groups`);
568
+ return ok(data.money_movement_groups);
569
+ })
570
+ );
571
+
572
+ server.tool(
573
+ "get_money_movement_groups_by_month",
574
+ "Get money movement groups for a specific month",
575
+ {
576
+ budgetId: z.string().optional().describe("Budget ID (uses default if not provided)"),
577
+ month: z.string().describe("Month in YYYY-MM-DD format (first of month), or 'current'"),
578
+ },
579
+ ({ budgetId, month }) =>
580
+ run(async () => {
581
+ const data = await ynabFetch(`/budgets/${resolveBudgetId(budgetId)}/months/${month}/money_movement_groups`);
582
+ return ok(data.money_movement_groups);
583
+ })
584
+ );
585
+
328
586
  // ==================== Transactions ====================
329
587
 
330
588
  function formatTransaction(t) {
@@ -344,6 +602,9 @@ function formatTransaction(t) {
344
602
  category_id: t.category_id,
345
603
  category_name: t.category_name,
346
604
  transfer_account_id: t.transfer_account_id,
605
+ import_id: t.import_id,
606
+ import_payee_name: t.import_payee_name,
607
+ debt_transaction_type: t.debt_transaction_type,
347
608
  subtransactions: t.subtransactions?.map((s) => ({
348
609
  id: s.id,
349
610
  amount: dollars(s.amount),
@@ -383,12 +644,8 @@ server.tool(
383
644
  const { data } = await api.transactions.getTransactionsByPayee(bid, payeeId, sinceDate, type);
384
645
  transactions = data.transactions;
385
646
  } else if (month) {
386
- // getTransactionsByMonth doesn't exist in SDK use sinceDate filter
387
- const startDate = month;
388
- const [y, m] = month.split("-").map(Number);
389
- const endDate = new Date(y, m, 0).toISOString().slice(0, 10); // last day of month
390
- const { data } = await api.transactions.getTransactions(bid, startDate, type);
391
- transactions = data.transactions.filter((t) => t.date <= endDate);
647
+ const { data } = await api.transactions.getTransactionsByMonth(bid, month, sinceDate, type);
648
+ transactions = data.transactions;
392
649
  } else {
393
650
  const { data } = await api.transactions.getTransactions(bid, sinceDate, type);
394
651
  transactions = data.transactions;
@@ -414,7 +671,7 @@ server.tool(
414
671
 
415
672
  server.tool(
416
673
  "create_transaction",
417
- "Create a new transaction. Amounts are in dollars (positive for inflows, negative for outflows).",
674
+ "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.",
418
675
  {
419
676
  budgetId: z.string().optional().describe("Budget ID (uses default if not provided)"),
420
677
  accountId: z.string().describe("Account ID"),
@@ -427,27 +684,90 @@ server.tool(
427
684
  cleared: z.enum(["cleared", "uncleared", "reconciled"]).optional().describe("Cleared status"),
428
685
  approved: z.boolean().optional().describe("Whether transaction is approved"),
429
686
  flagColor: z.enum(["red", "orange", "yellow", "green", "blue", "purple"]).optional().describe("Flag color"),
687
+ importId: z.string().optional().describe("Unique import ID for deduplication (max 36 chars). If omitted and the transaction is later imported, duplicates may be created."),
688
+ subtransactions: z.array(z.object({
689
+ amount: z.number().describe("Subtransaction amount in dollars"),
690
+ categoryId: z.string().optional().describe("Category ID"),
691
+ payeeId: z.string().optional().describe("Payee ID"),
692
+ payeeName: z.string().optional().describe("Payee name"),
693
+ memo: z.string().optional().describe("Memo"),
694
+ })).optional().describe("Split transaction into subtransactions. The subtransaction amounts must sum to the total transaction amount."),
430
695
  },
431
- ({ budgetId, accountId, date, amount, payeeId, payeeName, categoryId, memo, cleared, approved, flagColor }) =>
696
+ ({ budgetId, accountId, date, amount, payeeId, payeeName, categoryId, memo, cleared, approved, flagColor, importId, subtransactions }) =>
432
697
  run(async () => {
698
+ const txn = {
699
+ account_id: accountId,
700
+ date,
701
+ amount: milliunits(amount),
702
+ payee_id: payeeId,
703
+ payee_name: payeeName,
704
+ category_id: categoryId,
705
+ memo,
706
+ cleared,
707
+ approved,
708
+ flag_color: flagColor,
709
+ import_id: importId,
710
+ };
711
+ if (subtransactions) {
712
+ txn.subtransactions = subtransactions.map((s) => ({
713
+ amount: milliunits(s.amount),
714
+ category_id: s.categoryId,
715
+ payee_id: s.payeeId,
716
+ payee_name: s.payeeName,
717
+ memo: s.memo,
718
+ }));
719
+ }
433
720
  const { data } = await api.transactions.createTransaction(resolveBudgetId(budgetId), {
434
- transaction: {
435
- account_id: accountId,
436
- date,
437
- amount: milliunits(amount),
438
- payee_id: payeeId,
439
- payee_name: payeeName,
440
- category_id: categoryId,
441
- memo,
442
- cleared,
443
- approved,
444
- flag_color: flagColor,
445
- },
721
+ transaction: txn,
446
722
  });
447
723
  return ok(formatTransaction(data.transaction));
448
724
  })
449
725
  );
450
726
 
727
+ server.tool(
728
+ "create_transactions",
729
+ "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.",
730
+ {
731
+ budgetId: z.string().optional().describe("Budget ID (uses default if not provided)"),
732
+ transactions: z.array(z.object({
733
+ accountId: z.string().describe("Account ID"),
734
+ date: z.string().describe("Transaction date (YYYY-MM-DD)"),
735
+ amount: z.number().describe("Amount in dollars (negative for outflows, positive for inflows)"),
736
+ payeeId: z.string().optional().describe("Payee ID"),
737
+ payeeName: z.string().optional().describe("Payee name (creates new payee if no payeeId)"),
738
+ categoryId: z.string().optional().describe("Category ID"),
739
+ memo: z.string().optional().describe("Transaction memo"),
740
+ cleared: z.enum(["cleared", "uncleared", "reconciled"]).optional().describe("Cleared status"),
741
+ approved: z.boolean().optional().describe("Whether transaction is approved"),
742
+ flagColor: z.enum(["red", "orange", "yellow", "green", "blue", "purple"]).optional().describe("Flag color"),
743
+ importId: z.string().optional().describe("Unique import ID for deduplication (max 36 chars)"),
744
+ })).describe("Array of transactions to create"),
745
+ },
746
+ ({ budgetId, transactions: txns }) =>
747
+ run(async () => {
748
+ const mapped = txns.map((t) => ({
749
+ account_id: t.accountId,
750
+ date: t.date,
751
+ amount: milliunits(t.amount),
752
+ payee_id: t.payeeId,
753
+ payee_name: t.payeeName,
754
+ category_id: t.categoryId,
755
+ memo: t.memo,
756
+ cleared: t.cleared,
757
+ approved: t.approved,
758
+ flag_color: t.flagColor,
759
+ import_id: t.importId,
760
+ }));
761
+ const { data } = await api.transactions.createTransactions(resolveBudgetId(budgetId), {
762
+ transactions: mapped,
763
+ });
764
+ return ok({
765
+ created: data.transactions?.map(formatTransaction),
766
+ duplicate_import_ids: data.duplicate_import_ids,
767
+ });
768
+ })
769
+ );
770
+
451
771
  server.tool(
452
772
  "update_transaction",
453
773
  "Update an existing transaction. Only provided fields are changed. Amounts in dollars.",
@@ -496,7 +816,7 @@ server.tool(
496
816
  ({ budgetId, transactionId }) =>
497
817
  run(async () => {
498
818
  const { data } = await api.transactions.deleteTransaction(resolveBudgetId(budgetId), transactionId);
499
- return ok(data.transaction);
819
+ return ok(formatTransaction(data.transaction));
500
820
  })
501
821
  );
502
822
 
@@ -569,6 +889,16 @@ function formatScheduledTransaction(t) {
569
889
  payee_name: t.payee_name,
570
890
  category_id: t.category_id,
571
891
  category_name: t.category_name,
892
+ transfer_account_id: t.transfer_account_id,
893
+ subtransactions: t.subtransactions?.map((s) => ({
894
+ id: s.id,
895
+ amount: dollars(s.amount),
896
+ memo: s.memo,
897
+ payee_id: s.payee_id,
898
+ payee_name: s.payee_name,
899
+ category_id: s.category_id,
900
+ category_name: s.category_name,
901
+ })),
572
902
  };
573
903
  }
574
904
 
@@ -631,6 +961,50 @@ server.tool(
631
961
  })
632
962
  );
633
963
 
964
+ server.tool(
965
+ "update_scheduled_transaction",
966
+ "Update an existing scheduled transaction. Only provided fields are changed. Amounts in dollars.",
967
+ {
968
+ budgetId: z.string().optional().describe("Budget ID (uses default if not provided)"),
969
+ scheduledTransactionId: z.string().describe("Scheduled transaction ID"),
970
+ accountId: z.string().optional().describe("Account ID"),
971
+ date: z.string().optional().describe("Next occurrence date (YYYY-MM-DD)"),
972
+ frequency: z.enum(["never", "daily", "weekly", "everyOtherWeek", "twiceAMonth", "every4Weeks", "monthly", "everyOtherMonth", "every3Months", "every4Months", "twiceAYear", "yearly", "everyOtherYear"]).optional().describe("Recurrence frequency"),
973
+ amount: z.number().optional().describe("Amount in dollars (negative for outflows)"),
974
+ payeeId: z.string().nullable().optional().describe("Payee ID"),
975
+ payeeName: z.string().nullable().optional().describe("Payee name"),
976
+ categoryId: z.string().nullable().optional().describe("Category ID"),
977
+ memo: z.string().nullable().optional().describe("Memo"),
978
+ flagColor: z.enum(["red", "orange", "yellow", "green", "blue", "purple"]).nullable().optional().describe("Flag color"),
979
+ },
980
+ ({ budgetId, scheduledTransactionId, accountId, date, frequency, amount, payeeId, payeeName, categoryId, memo, flagColor }) =>
981
+ run(async () => {
982
+ const bid = resolveBudgetId(budgetId);
983
+ // PUT replaces the full resource — fetch current values to merge with updates
984
+ const { data: current } = await api.scheduledTransactions.getScheduledTransactionById(bid, scheduledTransactionId);
985
+ const existing = current.scheduled_transaction;
986
+
987
+ const st = {
988
+ account_id: accountId ?? existing.account_id,
989
+ date: date ?? existing.date_next,
990
+ frequency: frequency ?? existing.frequency,
991
+ amount: amount !== undefined ? milliunits(amount) : existing.amount,
992
+ payee_id: payeeId !== undefined ? payeeId : existing.payee_id,
993
+ payee_name: payeeName !== undefined ? payeeName : existing.payee_name,
994
+ category_id: categoryId !== undefined ? categoryId : existing.category_id,
995
+ memo: memo !== undefined ? memo : existing.memo,
996
+ flag_color: flagColor !== undefined ? flagColor : existing.flag_color,
997
+ };
998
+
999
+ const { data } = await api.scheduledTransactions.updateScheduledTransaction(
1000
+ bid,
1001
+ scheduledTransactionId,
1002
+ { scheduled_transaction: st }
1003
+ );
1004
+ return ok(formatScheduledTransaction(data.scheduled_transaction));
1005
+ })
1006
+ );
1007
+
634
1008
  server.tool(
635
1009
  "delete_scheduled_transaction",
636
1010
  "Delete a scheduled transaction",
@@ -641,7 +1015,7 @@ server.tool(
641
1015
  ({ budgetId, scheduledTransactionId }) =>
642
1016
  run(async () => {
643
1017
  const { data } = await api.scheduledTransactions.deleteScheduledTransaction(resolveBudgetId(budgetId), scheduledTransactionId);
644
- return ok(data.scheduled_transaction);
1018
+ return ok(formatScheduledTransaction(data.scheduled_transaction));
645
1019
  })
646
1020
  );
647
1021
 
@@ -726,5 +1100,10 @@ server.tool(
726
1100
 
727
1101
  // --- Start ---
728
1102
 
1103
+ process.on("uncaughtException", (err) => {
1104
+ console.error("Uncaught exception:", err);
1105
+ process.exit(1);
1106
+ });
1107
+
729
1108
  const transport = new StdioServerTransport();
730
1109
  await server.connect(transport);
package/package.json CHANGED
@@ -1,33 +1,21 @@
1
- {
2
- "name": "@oliverames/ynab-mcp-server",
3
- "version": "1.0.0",
4
- "description": "YNAB MCP server with full API coverage",
5
- "type": "module",
6
- "main": "index.js",
7
- "bin": {
8
- "ynab-mcp-server": "index.js"
9
- },
10
- "files": [
11
- "index.js"
12
- ],
13
- "keywords": [
14
- "mcp",
15
- "ynab",
16
- "budgeting",
17
- "model-context-protocol"
18
- ],
19
- "author": "Oliver Ames",
20
- "license": "MIT",
21
- "repository": {
22
- "type": "git",
23
- "url": "https://github.com/oliverames/oliver-claude-marketplace",
24
- "directory": "extensions/ynab-mcp-server"
25
- },
26
- "scripts": {
27
- "start": "node index.js"
28
- },
29
- "dependencies": {
30
- "@modelcontextprotocol/sdk": "^1.12.1",
31
- "ynab": "^2.5.0"
32
- }
33
- }
1
+ {
2
+ "name": "@oliverames/ynab-mcp-server",
3
+ "version": "1.2.0",
4
+ "description": "YNAB MCP server with full API coverage",
5
+ "type": "module",
6
+ "main": "index.js",
7
+ "bin": {
8
+ "ynab-mcp-server": "index.js"
9
+ },
10
+ "files": [
11
+ "index.js"
12
+ ],
13
+ "scripts": {
14
+ "start": "node index.js",
15
+ "test": "node test.js"
16
+ },
17
+ "dependencies": {
18
+ "@modelcontextprotocol/sdk": "^1.12.1",
19
+ "ynab": "^2.5.0"
20
+ }
21
+ }