@oliverames/ynab-mcp-server 1.1.0 → 1.2.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (4) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +349 -0
  3. package/index.js +335 -48
  4. package/package.json +4 -2
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Oliver Ames
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,349 @@
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
+ <code>43 tools</code> &bull;
14
+ <code>100% API coverage</code> &bull;
15
+ <code>YNAB API v1.79</code>
16
+ </p>
17
+
18
+ <p align="center">
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="LICENSE"><img src="https://img.shields.io/badge/license-MIT-f5a542?style=flat-square" alt="License"></a>
21
+ <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
+ </p>
23
+
24
+ <p align="center">
25
+ <a href="#quick-start">Quick Start</a> &bull;
26
+ <a href="#what-you-can-do">What You Can Do</a> &bull;
27
+ <a href="#tools-reference">All 43 Tools</a> &bull;
28
+ <a href="#environment-variables">Configuration</a>
29
+ </p>
30
+
31
+ ---
32
+
33
+ ## Why This Exists
34
+
35
+ 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
+
37
+ 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
+
39
+ ---
40
+
41
+ ## Quick Start
42
+
43
+ ### 1. Get a YNAB Personal Access Token
44
+
45
+ Go to [YNAB Developer Settings](https://app.ynab.com/settings/developer) and create a new personal access token.
46
+
47
+ ### 2. Configure your MCP client
48
+
49
+ **Claude Desktop** (`claude_desktop_config.json`):
50
+
51
+ ```json
52
+ {
53
+ "mcpServers": {
54
+ "ynab": {
55
+ "command": "npx",
56
+ "args": ["-y", "@oliverames/ynab-mcp-server"],
57
+ "env": {
58
+ "YNAB_API_TOKEN": "your-token-here"
59
+ }
60
+ }
61
+ }
62
+ }
63
+ ```
64
+
65
+ **Claude Code** (`.mcp.json`):
66
+
67
+ ```json
68
+ {
69
+ "mcpServers": {
70
+ "ynab": {
71
+ "command": "npx",
72
+ "args": ["-y", "@oliverames/ynab-mcp-server"],
73
+ "env": {
74
+ "YNAB_API_TOKEN": "your-token-here"
75
+ }
76
+ }
77
+ }
78
+ }
79
+ ```
80
+
81
+ Or install globally and point to the binary directly:
82
+
83
+ ```bash
84
+ npm install -g @oliverames/ynab-mcp-server
85
+ ```
86
+
87
+ ```json
88
+ {
89
+ "mcpServers": {
90
+ "ynab": {
91
+ "command": "ynab-mcp-server",
92
+ "env": {
93
+ "YNAB_API_TOKEN": "your-token-here",
94
+ "YNAB_BUDGET_ID": "optional-default-budget-id"
95
+ }
96
+ }
97
+ }
98
+ }
99
+ ```
100
+
101
+ That's it. Your AI can now talk to YNAB.
102
+
103
+ ---
104
+
105
+ ## What You Can Do
106
+
107
+ | Ask your AI... | What happens under the hood |
108
+ |---|---|
109
+ | "How much did I spend on groceries this month?" | `search_categories` → `get_month_category` |
110
+ | "Show me all unapproved transactions" | `review_unapproved` groups by readiness |
111
+ | "Log a $50 Costco trip under groceries" | `search_payees` → `search_categories` → `create_transaction` |
112
+ | "Set up monthly $1,500 rent on the 1st" | `create_scheduled_transaction` with `monthly` frequency |
113
+ | "Move $200 from emergency fund to dining" | `search_categories` → `update_month_category` (x2) |
114
+ | "Categorize all my Amazon orders from this week" | `get_transactions` (filtered) → `update_transactions` (batch) |
115
+ | "Create a 'Side Projects' spending category" | `search_categories` (find group) → `create_category` |
116
+ | "How has my budget been re-allocated this month?" | `get_money_movements_by_month` |
117
+ | "What recurring payments do I have?" | `list_scheduled_transactions` |
118
+ | "Import my latest bank transactions" | `import_transactions` triggers linked account sync |
119
+
120
+ ---
121
+
122
+ ## Features
123
+
124
+ **Complete YNAB API v1.79 coverage** with 43 tools:
125
+
126
+ | Resource | Tools | Capabilities |
127
+ |----------|-------|-------------|
128
+ | **Budgets** | 4 | List, view details, settings |
129
+ | **Accounts** | 3 | List, view, create |
130
+ | **Categories** | 9 | Full CRUD, groups, search, goals, monthly budgets |
131
+ | **Payees** | 4 | List, view, rename, search |
132
+ | **Payee Locations** | 3 | GPS coordinates for mobile transactions |
133
+ | **Months** | 2 | Monthly summaries with per-category breakdown |
134
+ | **Money Movements** | 4 | Budget re-allocation tracking |
135
+ | **Transactions** | 8 | Full CRUD, bulk ops, split transactions, multi-filter |
136
+ | **Scheduled Transactions** | 5 | Full CRUD for recurring transactions |
137
+ | **Convenience** | 1 | Unapproved transaction review workflow |
138
+
139
+ ### Design Decisions
140
+
141
+ - **Dollar amounts everywhere** — inputs and outputs are in dollars (`-12.34`), never milliunits (`-12340`). Conversion is automatic and transparent.
142
+ - **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.
143
+ - **Split transactions** — first-class support for subtransactions in create, read, and format operations.
144
+ - **Bulk operations** — `create_transactions` and `update_transactions` handle arrays in a single API call.
145
+ - **Fetch-then-merge updates** — scheduled transaction updates (which use PUT semantics) automatically fetch the current state and merge your changes, so you only specify what changed.
146
+ - **Fuzzy search** — `search_categories` and `search_payees` do case-insensitive partial matching across all entries.
147
+ - **Approval workflow** — `review_unapproved` groups transactions into "ready to approve" (categorized, split, or transfer) and "needs attention" (uncategorized), with a built-in warning against approving uncategorized entries.
148
+ - **Nullable updates** — update tools accept `null` for clearable fields (`memo`, `payeeName`, `categoryId`, `flagColor`) to distinguish "don't change" (omit) from "clear this field" (`null`).
149
+ - **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
+
151
+ ---
152
+
153
+ ## Tools Reference
154
+
155
+ ### User & Budgets
156
+
157
+ | Tool | Description |
158
+ |------|-------------|
159
+ | `get_user` | Get the authenticated user |
160
+ | `list_budgets` | List all budgets with IDs, names, date ranges, format settings, and default budget |
161
+ | `get_budget` | Get budget summary (name, currency, account/category/payee counts) |
162
+ | `get_budget_settings` | Get currency and date format settings |
163
+
164
+ ### Accounts
165
+
166
+ | Tool | Description |
167
+ |------|-------------|
168
+ | `list_accounts` | List all accounts with balances, debt details, and import status |
169
+ | `get_account` | Get full account details including notes and debt fields |
170
+ | `create_account` | Create a new account (checking, savings, creditCard, mortgage, etc.) |
171
+
172
+ **Supported account types:** `checking`, `savings`, `cash`, `creditCard`, `lineOfCredit`, `otherAsset`, `otherLiability`, `mortgage`, `autoLoan`, `studentLoan`, `personalLoan`, `medicalDebt`, `otherDebt`
173
+
174
+ ### Categories & Category Groups
175
+
176
+ | Tool | Description |
177
+ |------|-------------|
178
+ | `list_categories` | List all category groups and their categories with budgeted/activity/balance |
179
+ | `get_category` | Get full category details including goal progress and cadence |
180
+ | `get_month_category` | Get category budget for a specific month |
181
+ | `update_month_category` | Set the budgeted amount for a category in a month |
182
+ | `update_category` | Update name, note, goal target, goal target date, or move to a different group |
183
+ | `create_category` | Create a new category in an existing group (with optional goal) |
184
+ | `create_category_group` | Create a new category group |
185
+ | `update_category_group` | Rename a category group |
186
+ | `search_categories` | Case-insensitive partial name search (e.g., "groc" finds "Groceries") |
187
+
188
+ ### Payees
189
+
190
+ | Tool | Description |
191
+ |------|-------------|
192
+ | `list_payees` | List all payees with transfer account mappings |
193
+ | `get_payee` | Get payee details |
194
+ | `update_payee` | Rename a payee |
195
+ | `search_payees` | Case-insensitive partial name search |
196
+
197
+ ### Payee Locations
198
+
199
+ | Tool | Description |
200
+ |------|-------------|
201
+ | `list_payee_locations` | List all payee locations (GPS coordinates from mobile app) |
202
+ | `get_payee_location` | Get a specific payee location |
203
+ | `get_payee_locations_by_payee` | Get all locations for a specific payee |
204
+
205
+ ### Months
206
+
207
+ | Tool | Description |
208
+ |------|-------------|
209
+ | `list_months` | List budget months with income, budgeted, activity, to-be-budgeted, age of money, and notes |
210
+ | `get_month` | Get month detail with per-category budget/activity/balance/goal breakdown |
211
+
212
+ ### Money Movements
213
+
214
+ | Tool | Description |
215
+ |------|-------------|
216
+ | `list_money_movements` | List all money movements (budget re-allocations between categories) |
217
+ | `get_money_movements_by_month` | Get money movements for a specific month |
218
+ | `list_money_movement_groups` | List all money movement groups (batched re-allocations) |
219
+ | `get_money_movement_groups_by_month` | Get money movement groups for a specific month |
220
+
221
+ ### Transactions
222
+
223
+ | Tool | Description |
224
+ |------|-------------|
225
+ | `get_transactions` | Get transactions with filters: by account, category, payee, month, or status (`unapproved`/`uncategorized`) |
226
+ | `get_transaction` | Get a single transaction by ID (includes subtransactions) |
227
+ | `create_transaction` | Create a transaction with optional split (subtransactions must sum to total) |
228
+ | `create_transactions` | Bulk create multiple transactions in a single API call (supports split transactions) |
229
+ | `update_transaction` | Partial update — only specified fields change |
230
+ | `update_transactions` | Batch update multiple transactions at once |
231
+ | `delete_transaction` | Delete a transaction |
232
+ | `import_transactions` | Trigger import from linked bank accounts |
233
+
234
+ ### Scheduled Transactions
235
+
236
+ | Tool | Description |
237
+ |------|-------------|
238
+ | `list_scheduled_transactions` | List all recurring transactions |
239
+ | `get_scheduled_transaction` | Get a specific scheduled transaction |
240
+ | `create_scheduled_transaction` | Create a recurring transaction with frequency |
241
+ | `update_scheduled_transaction` | Update (fetch-then-merge preserves unchanged fields) |
242
+ | `delete_scheduled_transaction` | Delete a scheduled transaction |
243
+
244
+ **Supported frequencies:** `never`, `daily`, `weekly`, `everyOtherWeek`, `twiceAMonth`, `every4Weeks`, `monthly`, `everyOtherMonth`, `every3Months`, `every4Months`, `twiceAYear`, `yearly`, `everyOtherYear`
245
+
246
+ ### Convenience
247
+
248
+ | Tool | Description |
249
+ |------|-------------|
250
+ | `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. |
251
+
252
+ ---
253
+
254
+ ## Environment Variables
255
+
256
+ | Variable | Required | Description |
257
+ |----------|----------|-------------|
258
+ | `YNAB_API_TOKEN` | Yes | [Personal access token](https://app.ynab.com/settings/developer) from YNAB Developer Settings |
259
+ | `YNAB_BUDGET_ID` | No | Default budget ID. If omitted, uses `"last-used"` (your most recently accessed budget). Run `list_budgets` to find IDs. |
260
+
261
+ ---
262
+
263
+ ## Amount Handling
264
+
265
+ 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.
266
+
267
+ | Direction | Sign | Example |
268
+ |-----------|------|---------|
269
+ | Outflow (spending) | Negative | `-50.00` |
270
+ | Inflow (income) | Positive | `2500.00` |
271
+ | Transfer out | Negative | `-1000.00` |
272
+ | Transfer in | Positive | `1000.00` |
273
+
274
+ ---
275
+
276
+ ## Rate Limiting
277
+
278
+ 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.
279
+
280
+ ---
281
+
282
+ ## Architecture
283
+
284
+ ```
285
+ ┌─────────────────────┐ ┌──────────────────┐ ┌──────────────┐
286
+ │ AI Assistant │────▶│ YNAB MCP Server │────▶│ YNAB API │
287
+ │ (Claude, GPT, etc) │◀────│ (this package) │◀────│ api.ynab.com│
288
+ └─────────────────────┘ └──────────────────┘ └──────────────┘
289
+ MCP stdio transport HTTPS/REST
290
+ ```
291
+
292
+ - **Transport:** stdio (standard MCP server pattern)
293
+ - **Auth:** Bearer token via `YNAB_API_TOKEN` environment variable
294
+ - **SDK:** Official [`ynab`](https://www.npmjs.com/package/ynab) v2.5+ for core endpoints, direct `fetch` for newer API features
295
+ - **Validation:** All parameters validated with [Zod](https://zod.dev) schemas
296
+ - **Error handling:** API errors are caught, formatted, and returned as MCP error responses with detail messages
297
+
298
+ ---
299
+
300
+ ## Testing
301
+
302
+ The test suite (43 tests) runs against a live YNAB budget. It creates test data and cleans up after itself:
303
+
304
+ ```bash
305
+ YNAB_API_TOKEN=your-token YNAB_BUDGET_ID=your-budget-id npm test
306
+ ```
307
+
308
+ 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.
309
+
310
+ ---
311
+
312
+ ## Development
313
+
314
+ ```bash
315
+ git clone https://github.com/oliverames/ynab-mcp-server.git
316
+ cd ynab-mcp-server
317
+ npm install
318
+ YNAB_API_TOKEN=your-token npm start
319
+ ```
320
+
321
+ ### Dependencies
322
+
323
+ - [`@modelcontextprotocol/sdk`](https://www.npmjs.com/package/@modelcontextprotocol/sdk) — MCP server framework
324
+ - [`ynab`](https://www.npmjs.com/package/ynab) — Official YNAB JavaScript client
325
+
326
+ Zero additional dependencies. No build step. Pure ESM.
327
+
328
+ ---
329
+
330
+ ## License
331
+
332
+ MIT
333
+
334
+ ---
335
+
336
+ <p align="center">
337
+ <a href="https://www.buymeacoffee.com/oliverames">
338
+ <img src="https://img.shields.io/badge/Buy_Me_a_Coffee-support-f5a542?style=for-the-badge&logo=buy-me-a-coffee&logoColor=white" alt="Buy Me a Coffee">
339
+ </a>
340
+ </p>
341
+
342
+ <p align="center">
343
+ <sub>
344
+ Built by <a href="https://ames.consulting">Oliver Ames</a> in Vermont
345
+ &bull; <a href="https://github.com/oliverames">GitHub</a>
346
+ &bull; <a href="https://linkedin.com/in/oliverames">LinkedIn</a>
347
+ &bull; <a href="https://bsky.app/profile/oliverames.bsky.social">Bluesky</a>
348
+ </sub>
349
+ </p>
package/index.js CHANGED
@@ -31,6 +31,36 @@ function milliunits(dollars) {
31
31
  return Math.round(dollars * 1000);
32
32
  }
33
33
 
34
+ function dollarsMap(obj) {
35
+ return obj ? Object.fromEntries(Object.entries(obj).map(([k, v]) => [k, dollars(v)])) : obj;
36
+ }
37
+
38
+ function mapTransactionInput(t) {
39
+ const out = {
40
+ account_id: t.accountId,
41
+ date: t.date,
42
+ amount: milliunits(t.amount),
43
+ payee_id: t.payeeId,
44
+ payee_name: t.payeeName,
45
+ category_id: t.categoryId,
46
+ memo: t.memo,
47
+ cleared: t.cleared,
48
+ approved: t.approved,
49
+ flag_color: t.flagColor,
50
+ import_id: t.importId,
51
+ };
52
+ if (t.subtransactions) {
53
+ out.subtransactions = t.subtransactions.map((s) => ({
54
+ amount: milliunits(s.amount),
55
+ category_id: s.categoryId,
56
+ payee_id: s.payeeId,
57
+ payee_name: s.payeeName,
58
+ memo: s.memo,
59
+ }));
60
+ }
61
+ return out;
62
+ }
63
+
34
64
  function ok(data) {
35
65
  return { content: [{ type: "text", text: JSON.stringify(data, null, 2) }] };
36
66
  }
@@ -48,11 +78,29 @@ async function run(fn) {
48
78
  }
49
79
  }
50
80
 
81
+ // Direct API helper for endpoints not yet in the ynab SDK
82
+ const BASE_URL = "https://api.ynab.com/v1";
83
+ async function ynabFetch(path, { method = "GET", body } = {}) {
84
+ const opts = {
85
+ method,
86
+ headers: { Authorization: `Bearer ${API_TOKEN}`, "Content-Type": "application/json" },
87
+ };
88
+ if (body) opts.body = JSON.stringify(body);
89
+ const res = await fetch(`${BASE_URL}${path}`, opts);
90
+ const json = await res.json();
91
+ if (!res.ok) {
92
+ const err = new Error(json?.error?.detail || `HTTP ${res.status}`);
93
+ err.error = json?.error;
94
+ throw err;
95
+ }
96
+ return json.data;
97
+ }
98
+
51
99
  // --- Server ---
52
100
 
53
101
  const server = new McpServer({
54
102
  name: "ynab-mcp-server",
55
- version: "1.0.0",
103
+ version: "1.2.1",
56
104
  });
57
105
 
58
106
  // ==================== User & Budgets ====================
@@ -67,13 +115,19 @@ server.tool("get_user", "Get the authenticated user", {}, () =>
67
115
  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.", {}, () =>
68
116
  run(async () => {
69
117
  const { data } = await api.budgets.getBudgets();
70
- return ok(data.budgets.map((b) => ({ id: b.id, name: b.name, last_modified_on: b.last_modified_on })));
118
+ const result = {
119
+ budgets: 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, date_format: b.date_format, currency_format: b.currency_format })),
120
+ };
121
+ if (data.default_budget) {
122
+ result.default_budget = { id: data.default_budget.id, name: data.default_budget.name };
123
+ }
124
+ return ok(result);
71
125
  })
72
126
  );
73
127
 
74
128
  server.tool(
75
129
  "get_budget",
76
- "Get full budget details including accounts, categories, and payees",
130
+ "Get a budget summary including name, currency format, and account/category/payee counts",
77
131
  { budgetId: z.string().optional().describe("Budget ID (uses default if not provided)") },
78
132
  ({ budgetId }) =>
79
133
  run(async () => {
@@ -82,6 +136,10 @@ server.tool(
82
136
  return ok({
83
137
  id: b.id,
84
138
  name: b.name,
139
+ last_modified_on: b.last_modified_on,
140
+ first_month: b.first_month,
141
+ last_month: b.last_month,
142
+ date_format: b.date_format,
85
143
  currency_format: b.currency_format,
86
144
  accounts: b.accounts?.length,
87
145
  categories: b.categories?.length,
@@ -117,6 +175,10 @@ function formatAccount(a) {
117
175
  direct_import_linked: a.direct_import_linked,
118
176
  direct_import_in_error: a.direct_import_in_error,
119
177
  last_reconciled_at: a.last_reconciled_at,
178
+ debt_original_balance: dollars(a.debt_original_balance),
179
+ debt_interest_rates: a.debt_interest_rates,
180
+ debt_minimum_payments: dollarsMap(a.debt_minimum_payments),
181
+ debt_escrow_amounts: dollarsMap(a.debt_escrow_amounts),
120
182
  deleted: a.deleted,
121
183
  };
122
184
  if ("note" in a) out.note = a.note;
@@ -173,6 +235,7 @@ function formatCategory(c) {
173
235
  id: c.id,
174
236
  category_group_id: c.category_group_id,
175
237
  category_group_name: c.category_group_name,
238
+ original_category_group_id: c.original_category_group_id,
176
239
  name: c.name,
177
240
  hidden: c.hidden,
178
241
  note: c.note,
@@ -180,6 +243,10 @@ function formatCategory(c) {
180
243
  activity: dollars(c.activity),
181
244
  balance: dollars(c.balance),
182
245
  goal_type: c.goal_type,
246
+ goal_day: c.goal_day,
247
+ goal_cadence: c.goal_cadence,
248
+ goal_cadence_frequency: c.goal_cadence_frequency,
249
+ goal_creation_month: c.goal_creation_month,
183
250
  goal_target: dollars(c.goal_target),
184
251
  goal_target_date: c.goal_target_date,
185
252
  goal_percentage_complete: c.goal_percentage_complete,
@@ -188,6 +255,7 @@ function formatCategory(c) {
188
255
  goal_overall_funded: dollars(c.goal_overall_funded),
189
256
  goal_overall_left: dollars(c.goal_overall_left),
190
257
  goal_needs_whole_amount: c.goal_needs_whole_amount,
258
+ deleted: c.deleted,
191
259
  };
192
260
  }
193
261
 
@@ -203,6 +271,7 @@ server.tool(
203
271
  id: g.id,
204
272
  name: g.name,
205
273
  hidden: g.hidden,
274
+ deleted: g.deleted,
206
275
  categories: g.categories.map((c) => ({
207
276
  id: c.id,
208
277
  name: c.name,
@@ -211,6 +280,7 @@ server.tool(
211
280
  activity: dollars(c.activity),
212
281
  balance: dollars(c.balance),
213
282
  goal_type: c.goal_type,
283
+ deleted: c.deleted,
214
284
  })),
215
285
  }))
216
286
  );
@@ -274,14 +344,16 @@ server.tool(
274
344
  note: z.string().nullable().optional().describe("Category note (null to clear)"),
275
345
  categoryGroupId: z.string().optional().describe("Move to a different category group"),
276
346
  goalTarget: z.number().nullable().optional().describe("Goal target amount in dollars (only if category already has a goal)"),
347
+ goalTargetDate: z.string().nullable().optional().describe("Goal target date in ISO format (e.g. 2026-12-01, null to clear)"),
277
348
  },
278
- ({ budgetId, categoryId, name, note, categoryGroupId, goalTarget }) =>
349
+ ({ budgetId, categoryId, name, note, categoryGroupId, goalTarget, goalTargetDate }) =>
279
350
  run(async () => {
280
351
  const cat = {};
281
352
  if (name !== undefined) cat.name = name;
282
353
  if (note !== undefined) cat.note = note;
283
354
  if (categoryGroupId !== undefined) cat.category_group_id = categoryGroupId;
284
355
  if (goalTarget !== undefined) cat.goal_target = goalTarget != null ? milliunits(goalTarget) : null;
356
+ if (goalTargetDate !== undefined) cat.goal_target_date = goalTargetDate;
285
357
 
286
358
  const { data } = await api.categories.updateCategory(resolveBudgetId(budgetId), categoryId, {
287
359
  category: cat,
@@ -290,6 +362,67 @@ server.tool(
290
362
  })
291
363
  );
292
364
 
365
+ server.tool(
366
+ "create_category",
367
+ "Create a new category in a category group",
368
+ {
369
+ budgetId: z.string().optional().describe("Budget ID (uses default if not provided)"),
370
+ categoryGroupId: z.string().describe("Category group ID to create the category in"),
371
+ name: z.string().describe("Category name"),
372
+ note: z.string().optional().describe("Category note"),
373
+ goalTarget: z.number().optional().describe("Goal target amount in dollars (creates a 'Needed for Spending' goal)"),
374
+ goalTargetDate: z.string().optional().describe("Goal target date in ISO format (e.g. 2026-12-01)"),
375
+ },
376
+ ({ budgetId, categoryGroupId, name, note, goalTarget, goalTargetDate }) =>
377
+ run(async () => {
378
+ const bid = resolveBudgetId(budgetId);
379
+ const cat = { category_group_id: categoryGroupId, name };
380
+ if (note !== undefined) cat.note = note;
381
+ if (goalTarget !== undefined) cat.goal_target = milliunits(goalTarget);
382
+ if (goalTargetDate !== undefined) cat.goal_target_date = goalTargetDate;
383
+ const data = await ynabFetch(`/budgets/${bid}/categories`, {
384
+ method: "POST",
385
+ body: { category: cat },
386
+ });
387
+ return ok(formatCategory(data.category));
388
+ })
389
+ );
390
+
391
+ server.tool(
392
+ "create_category_group",
393
+ "Create a new category group",
394
+ {
395
+ budgetId: z.string().optional().describe("Budget ID (uses default if not provided)"),
396
+ name: z.string().describe("Category group name (max 50 characters)"),
397
+ },
398
+ ({ budgetId, name }) =>
399
+ run(async () => {
400
+ const data = await ynabFetch(`/budgets/${resolveBudgetId(budgetId)}/category_groups`, {
401
+ method: "POST",
402
+ body: { category_group: { name } },
403
+ });
404
+ return ok(data.category_group);
405
+ })
406
+ );
407
+
408
+ server.tool(
409
+ "update_category_group",
410
+ "Rename a category group",
411
+ {
412
+ budgetId: z.string().optional().describe("Budget ID (uses default if not provided)"),
413
+ categoryGroupId: z.string().describe("Category group ID"),
414
+ name: z.string().describe("New category group name (max 50 characters)"),
415
+ },
416
+ ({ budgetId, categoryGroupId, name }) =>
417
+ run(async () => {
418
+ const data = await ynabFetch(`/budgets/${resolveBudgetId(budgetId)}/category_groups/${categoryGroupId}`, {
419
+ method: "PATCH",
420
+ body: { category_group: { name } },
421
+ });
422
+ return ok(data.category_group);
423
+ })
424
+ );
425
+
293
426
  // ==================== Payees ====================
294
427
 
295
428
  server.tool(
@@ -299,7 +432,7 @@ server.tool(
299
432
  ({ budgetId }) =>
300
433
  run(async () => {
301
434
  const { data } = await api.payees.getPayees(resolveBudgetId(budgetId));
302
- return ok(data.payees.map((p) => ({ id: p.id, name: p.name, transfer_account_id: p.transfer_account_id })));
435
+ return ok(data.payees.map((p) => ({ id: p.id, name: p.name, transfer_account_id: p.transfer_account_id, deleted: p.deleted })));
303
436
  })
304
437
  );
305
438
 
@@ -334,6 +467,47 @@ server.tool(
334
467
  })
335
468
  );
336
469
 
470
+ // ==================== Payee Locations ====================
471
+
472
+ server.tool(
473
+ "list_payee_locations",
474
+ "List all payee locations (GPS coordinates where transactions occurred)",
475
+ { budgetId: z.string().optional().describe("Budget ID (uses default if not provided)") },
476
+ ({ budgetId }) =>
477
+ run(async () => {
478
+ const { data } = await api.payeeLocations.getPayeeLocations(resolveBudgetId(budgetId));
479
+ return ok(data.payee_locations);
480
+ })
481
+ );
482
+
483
+ server.tool(
484
+ "get_payee_location",
485
+ "Get a specific payee location",
486
+ {
487
+ budgetId: z.string().optional().describe("Budget ID (uses default if not provided)"),
488
+ payeeLocationId: z.string().describe("Payee location ID"),
489
+ },
490
+ ({ budgetId, payeeLocationId }) =>
491
+ run(async () => {
492
+ const { data } = await api.payeeLocations.getPayeeLocationById(resolveBudgetId(budgetId), payeeLocationId);
493
+ return ok(data.payee_location);
494
+ })
495
+ );
496
+
497
+ server.tool(
498
+ "get_payee_locations_by_payee",
499
+ "Get all locations for a specific payee",
500
+ {
501
+ budgetId: z.string().optional().describe("Budget ID (uses default if not provided)"),
502
+ payeeId: z.string().describe("Payee ID"),
503
+ },
504
+ ({ budgetId, payeeId }) =>
505
+ run(async () => {
506
+ const { data } = await api.payeeLocations.getPayeeLocationsByPayee(resolveBudgetId(budgetId), payeeId);
507
+ return ok(data.payee_locations);
508
+ })
509
+ );
510
+
337
511
  // ==================== Months ====================
338
512
 
339
513
  server.tool(
@@ -346,11 +520,13 @@ server.tool(
346
520
  return ok(
347
521
  data.months.map((m) => ({
348
522
  month: m.month,
523
+ note: m.note,
349
524
  income: dollars(m.income),
350
525
  budgeted: dollars(m.budgeted),
351
526
  activity: dollars(m.activity),
352
527
  to_be_budgeted: dollars(m.to_be_budgeted),
353
528
  age_of_money: m.age_of_money,
529
+ deleted: m.deleted,
354
530
  }))
355
531
  );
356
532
  })
@@ -369,14 +545,17 @@ server.tool(
369
545
  const m = data.month;
370
546
  return ok({
371
547
  month: m.month,
548
+ note: m.note,
372
549
  income: dollars(m.income),
373
550
  budgeted: dollars(m.budgeted),
374
551
  activity: dollars(m.activity),
375
552
  to_be_budgeted: dollars(m.to_be_budgeted),
376
553
  age_of_money: m.age_of_money,
554
+ deleted: m.deleted,
377
555
  categories: m.categories?.map((c) => ({
378
556
  id: c.id,
379
557
  name: c.name,
558
+ hidden: c.hidden,
380
559
  category_group_name: c.category_group_name,
381
560
  budgeted: dollars(c.budgeted),
382
561
  activity: dollars(c.activity),
@@ -384,11 +563,79 @@ server.tool(
384
563
  goal_type: c.goal_type,
385
564
  goal_target: dollars(c.goal_target),
386
565
  goal_under_funded: dollars(c.goal_under_funded),
566
+ deleted: c.deleted,
387
567
  })),
388
568
  });
389
569
  })
390
570
  );
391
571
 
572
+ // ==================== Money Movements ====================
573
+
574
+ function formatMoneyMovement(m) {
575
+ return {
576
+ id: m.id,
577
+ month: m.month,
578
+ moved_at: m.moved_at,
579
+ note: m.note,
580
+ money_movement_group_id: m.money_movement_group_id,
581
+ performed_by_user_id: m.performed_by_user_id,
582
+ from_category_id: m.from_category_id,
583
+ to_category_id: m.to_category_id,
584
+ amount: dollars(m.amount),
585
+ deleted: m.deleted,
586
+ };
587
+ }
588
+
589
+ server.tool(
590
+ "list_money_movements",
591
+ "List all money movements (budget re-allocations between categories)",
592
+ { budgetId: z.string().optional().describe("Budget ID (uses default if not provided)") },
593
+ ({ budgetId }) =>
594
+ run(async () => {
595
+ const data = await ynabFetch(`/budgets/${resolveBudgetId(budgetId)}/money_movements`);
596
+ return ok(data.money_movements.map(formatMoneyMovement));
597
+ })
598
+ );
599
+
600
+ server.tool(
601
+ "get_money_movements_by_month",
602
+ "Get money movements for a specific month",
603
+ {
604
+ budgetId: z.string().optional().describe("Budget ID (uses default if not provided)"),
605
+ month: z.string().describe("Month in YYYY-MM-DD format (first of month), or 'current'"),
606
+ },
607
+ ({ budgetId, month }) =>
608
+ run(async () => {
609
+ const data = await ynabFetch(`/budgets/${resolveBudgetId(budgetId)}/months/${month}/money_movements`);
610
+ return ok(data.money_movements.map(formatMoneyMovement));
611
+ })
612
+ );
613
+
614
+ server.tool(
615
+ "list_money_movement_groups",
616
+ "List all money movement groups (batches of related money movements)",
617
+ { budgetId: z.string().optional().describe("Budget ID (uses default if not provided)") },
618
+ ({ budgetId }) =>
619
+ run(async () => {
620
+ const data = await ynabFetch(`/budgets/${resolveBudgetId(budgetId)}/money_movement_groups`);
621
+ return ok(data.money_movement_groups);
622
+ })
623
+ );
624
+
625
+ server.tool(
626
+ "get_money_movement_groups_by_month",
627
+ "Get money movement groups for a specific month",
628
+ {
629
+ budgetId: z.string().optional().describe("Budget ID (uses default if not provided)"),
630
+ month: z.string().describe("Month in YYYY-MM-DD format (first of month), or 'current'"),
631
+ },
632
+ ({ budgetId, month }) =>
633
+ run(async () => {
634
+ const data = await ynabFetch(`/budgets/${resolveBudgetId(budgetId)}/months/${month}/money_movement_groups`);
635
+ return ok(data.money_movement_groups);
636
+ })
637
+ );
638
+
392
639
  // ==================== Transactions ====================
393
640
 
394
641
  function formatTransaction(t) {
@@ -408,17 +655,25 @@ function formatTransaction(t) {
408
655
  category_id: t.category_id,
409
656
  category_name: t.category_name,
410
657
  transfer_account_id: t.transfer_account_id,
658
+ transfer_transaction_id: t.transfer_transaction_id,
659
+ matched_transaction_id: t.matched_transaction_id,
411
660
  import_id: t.import_id,
412
661
  import_payee_name: t.import_payee_name,
662
+ import_payee_name_original: t.import_payee_name_original,
413
663
  debt_transaction_type: t.debt_transaction_type,
664
+ deleted: t.deleted,
414
665
  subtransactions: t.subtransactions?.map((s) => ({
415
666
  id: s.id,
667
+ transaction_id: s.transaction_id,
416
668
  amount: dollars(s.amount),
417
669
  memo: s.memo,
418
670
  payee_id: s.payee_id,
419
671
  payee_name: s.payee_name,
420
672
  category_id: s.category_id,
421
673
  category_name: s.category_name,
674
+ transfer_account_id: s.transfer_account_id,
675
+ transfer_transaction_id: s.transfer_transaction_id,
676
+ deleted: s.deleted,
422
677
  })),
423
678
  };
424
679
  }
@@ -477,7 +732,7 @@ server.tool(
477
732
 
478
733
  server.tool(
479
734
  "create_transaction",
480
- "Create a new transaction. Amounts are in dollars (positive for inflows, negative for outflows).",
735
+ "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.",
481
736
  {
482
737
  budgetId: z.string().optional().describe("Budget ID (uses default if not provided)"),
483
738
  accountId: z.string().describe("Account ID"),
@@ -499,37 +754,53 @@ server.tool(
499
754
  memo: z.string().optional().describe("Memo"),
500
755
  })).optional().describe("Split transaction into subtransactions. The subtransaction amounts must sum to the total transaction amount."),
501
756
  },
502
- ({ budgetId, accountId, date, amount, payeeId, payeeName, categoryId, memo, cleared, approved, flagColor, importId, subtransactions }) =>
503
- run(async () => {
504
- const txn = {
505
- account_id: accountId,
506
- date,
507
- amount: milliunits(amount),
508
- payee_id: payeeId,
509
- payee_name: payeeName,
510
- category_id: categoryId,
511
- memo,
512
- cleared,
513
- approved,
514
- flag_color: flagColor,
515
- import_id: importId,
516
- };
517
- if (subtransactions) {
518
- txn.subtransactions = subtransactions.map((s) => ({
519
- amount: milliunits(s.amount),
520
- category_id: s.categoryId,
521
- payee_id: s.payeeId,
522
- payee_name: s.payeeName,
523
- memo: s.memo,
524
- }));
525
- }
757
+ ({ budgetId, ...txnFields }) =>
758
+ run(async () => {
526
759
  const { data } = await api.transactions.createTransaction(resolveBudgetId(budgetId), {
527
- transaction: txn,
760
+ transaction: mapTransactionInput(txnFields),
528
761
  });
529
762
  return ok(formatTransaction(data.transaction));
530
763
  })
531
764
  );
532
765
 
766
+ server.tool(
767
+ "create_transactions",
768
+ "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.",
769
+ {
770
+ budgetId: z.string().optional().describe("Budget ID (uses default if not provided)"),
771
+ transactions: z.array(z.object({
772
+ accountId: z.string().describe("Account ID"),
773
+ date: z.string().describe("Transaction date (YYYY-MM-DD)"),
774
+ amount: z.number().describe("Amount in dollars (negative for outflows, positive for inflows)"),
775
+ payeeId: z.string().optional().describe("Payee ID"),
776
+ payeeName: z.string().optional().describe("Payee name (creates new payee if no payeeId)"),
777
+ categoryId: z.string().optional().describe("Category ID"),
778
+ memo: z.string().optional().describe("Transaction memo"),
779
+ cleared: z.enum(["cleared", "uncleared", "reconciled"]).optional().describe("Cleared status"),
780
+ approved: z.boolean().optional().describe("Whether transaction is approved"),
781
+ flagColor: z.enum(["red", "orange", "yellow", "green", "blue", "purple"]).optional().describe("Flag color"),
782
+ importId: z.string().optional().describe("Unique import ID for deduplication (max 36 chars)"),
783
+ subtransactions: z.array(z.object({
784
+ amount: z.number().describe("Subtransaction amount in dollars"),
785
+ categoryId: z.string().optional().describe("Category ID"),
786
+ payeeId: z.string().optional().describe("Payee ID"),
787
+ payeeName: z.string().optional().describe("Payee name"),
788
+ memo: z.string().optional().describe("Memo"),
789
+ })).optional().describe("Split transaction into subtransactions"),
790
+ })).describe("Array of transactions to create"),
791
+ },
792
+ ({ budgetId, transactions: txns }) =>
793
+ run(async () => {
794
+ const { data } = await api.transactions.createTransactions(resolveBudgetId(budgetId), {
795
+ transactions: txns.map(mapTransactionInput),
796
+ });
797
+ return ok({
798
+ created: data.transactions?.map(formatTransaction),
799
+ duplicate_import_ids: data.duplicate_import_ids,
800
+ });
801
+ })
802
+ );
803
+
533
804
  server.tool(
534
805
  "update_transaction",
535
806
  "Update an existing transaction. Only provided fields are changed. Amounts in dollars.",
@@ -539,10 +810,10 @@ server.tool(
539
810
  accountId: z.string().optional().describe("Account ID"),
540
811
  date: z.string().optional().describe("Transaction date (YYYY-MM-DD)"),
541
812
  amount: z.number().optional().describe("Amount in dollars"),
542
- payeeId: z.string().optional().describe("Payee ID"),
543
- payeeName: z.string().optional().describe("Payee name"),
813
+ payeeId: z.string().nullable().optional().describe("Payee ID (null to remove)"),
814
+ payeeName: z.string().nullable().optional().describe("Payee name (null to clear)"),
544
815
  categoryId: z.string().nullable().optional().describe("Category ID (null to uncategorize)"),
545
- memo: z.string().optional().describe("Transaction memo"),
816
+ memo: z.string().nullable().optional().describe("Transaction memo (null to clear)"),
546
817
  cleared: z.enum(["cleared", "uncleared", "reconciled"]).optional().describe("Cleared status"),
547
818
  approved: z.boolean().optional().describe("Whether transaction is approved"),
548
819
  flagColor: z.enum(["red", "orange", "yellow", "green", "blue", "purple"]).nullable().optional().describe("Flag color (null to remove)"),
@@ -591,16 +862,16 @@ server.tool(
591
862
  .array(
592
863
  z.object({
593
864
  id: z.string().describe("Transaction ID"),
594
- account_id: z.string().optional(),
595
- date: z.string().optional(),
865
+ accountId: z.string().optional().describe("Account ID"),
866
+ date: z.string().optional().describe("Transaction date (YYYY-MM-DD)"),
596
867
  amount: z.number().optional().describe("Amount in dollars"),
597
- payee_id: z.string().optional(),
598
- payee_name: z.string().optional(),
599
- category_id: z.string().optional(),
600
- memo: z.string().optional(),
601
- cleared: z.enum(["cleared", "uncleared", "reconciled"]).optional(),
602
- approved: z.boolean().optional(),
603
- flag_color: z.enum(["red", "orange", "yellow", "green", "blue", "purple"]).nullable().optional(),
868
+ payeeId: z.string().nullable().optional().describe("Payee ID (null to remove)"),
869
+ payeeName: z.string().nullable().optional().describe("Payee name (null to clear)"),
870
+ categoryId: z.string().nullable().optional().describe("Category ID (null to uncategorize)"),
871
+ memo: z.string().nullable().optional().describe("Transaction memo (null to clear)"),
872
+ cleared: z.enum(["cleared", "uncleared", "reconciled"]).optional().describe("Cleared status"),
873
+ approved: z.boolean().optional().describe("Whether transaction is approved"),
874
+ flagColor: z.enum(["red", "orange", "yellow", "green", "blue", "purple"]).nullable().optional().describe("Flag color (null to remove)"),
604
875
  })
605
876
  )
606
877
  .describe("Array of transaction updates"),
@@ -608,8 +879,17 @@ server.tool(
608
879
  ({ budgetId, transactions: txns }) =>
609
880
  run(async () => {
610
881
  const mapped = txns.map((t) => {
611
- const out = { ...t };
612
- if (out.amount !== undefined) out.amount = milliunits(out.amount);
882
+ const out = { id: t.id };
883
+ if (t.accountId !== undefined) out.account_id = t.accountId;
884
+ if (t.date !== undefined) out.date = t.date;
885
+ if (t.amount !== undefined) out.amount = milliunits(t.amount);
886
+ if (t.payeeId !== undefined) out.payee_id = t.payeeId;
887
+ if (t.payeeName !== undefined) out.payee_name = t.payeeName;
888
+ if (t.categoryId !== undefined) out.category_id = t.categoryId;
889
+ if (t.memo !== undefined) out.memo = t.memo;
890
+ if (t.cleared !== undefined) out.cleared = t.cleared;
891
+ if (t.approved !== undefined) out.approved = t.approved;
892
+ if (t.flagColor !== undefined) out.flag_color = t.flagColor;
613
893
  return out;
614
894
  });
615
895
  const { data } = await api.transactions.updateTransactions(resolveBudgetId(budgetId), {
@@ -652,14 +932,18 @@ function formatScheduledTransaction(t) {
652
932
  category_id: t.category_id,
653
933
  category_name: t.category_name,
654
934
  transfer_account_id: t.transfer_account_id,
935
+ deleted: t.deleted,
655
936
  subtransactions: t.subtransactions?.map((s) => ({
656
937
  id: s.id,
938
+ scheduled_transaction_id: s.scheduled_transaction_id,
657
939
  amount: dollars(s.amount),
658
940
  memo: s.memo,
659
941
  payee_id: s.payee_id,
660
942
  payee_name: s.payee_name,
661
943
  category_id: s.category_id,
662
944
  category_name: s.category_name,
945
+ transfer_account_id: s.transfer_account_id,
946
+ deleted: s.deleted,
663
947
  })),
664
948
  };
665
949
  }
@@ -829,7 +1113,7 @@ server.tool(
829
1113
  const q = query.toLowerCase();
830
1114
  const matches = data.payees
831
1115
  .filter((p) => p.name.toLowerCase().includes(q))
832
- .map((p) => ({ id: p.id, name: p.name, transfer_account_id: p.transfer_account_id }));
1116
+ .map((p) => ({ id: p.id, name: p.name, transfer_account_id: p.transfer_account_id, deleted: p.deleted }));
833
1117
  if (matches.length === 0) return ok({ message: `No payees matching "${query}"` });
834
1118
  return ok(matches);
835
1119
  })
@@ -843,8 +1127,11 @@ server.tool(
843
1127
  run(async () => {
844
1128
  const { data } = await api.transactions.getTransactions(resolveBudgetId(budgetId), undefined, "unapproved");
845
1129
  const txns = data.transactions.map(formatTransaction);
846
- const categorized = txns.filter((t) => t.category_id && t.category_name !== "Uncategorized");
847
- const uncategorized = txns.filter((t) => !t.category_id || t.category_name === "Uncategorized");
1130
+ const isCategorized = (t) => (t.category_id && t.category_name !== "Uncategorized")
1131
+ || (t.subtransactions && t.subtransactions.length > 0) // split transactions are categorized via subtransactions
1132
+ || t.transfer_account_id; // transfers don't need categories
1133
+ const categorized = txns.filter(isCategorized);
1134
+ const uncategorized = txns.filter((t) => !isCategorized(t));
848
1135
  return ok({
849
1136
  total: txns.length,
850
1137
  ready_to_approve: {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@oliverames/ynab-mcp-server",
3
- "version": "1.1.0",
3
+ "version": "1.2.1",
4
4
  "description": "YNAB MCP server with full API coverage",
5
5
  "type": "module",
6
6
  "main": "index.js",
@@ -11,7 +11,9 @@
11
11
  "index.js"
12
12
  ],
13
13
  "scripts": {
14
- "start": "node index.js"
14
+ "start": "node index.js",
15
+ "test": "node test.js",
16
+ "postpublish": "$HOME/Developer/projects/ames-claude/bump-and-sync"
15
17
  },
16
18
  "dependencies": {
17
19
  "@modelcontextprotocol/sdk": "^1.12.1",