@oliverames/ynab-mcp-server 1.0.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/index.js +730 -0
- package/package.json +33 -0
package/index.js
ADDED
|
@@ -0,0 +1,730 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
4
|
+
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
|
|
5
|
+
import { z } from "zod";
|
|
6
|
+
import * as ynab from "ynab";
|
|
7
|
+
|
|
8
|
+
// --- Init ---
|
|
9
|
+
|
|
10
|
+
const API_TOKEN = process.env.YNAB_API_TOKEN;
|
|
11
|
+
if (!API_TOKEN) {
|
|
12
|
+
console.error("YNAB_API_TOKEN environment variable is required");
|
|
13
|
+
process.exit(1);
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
const api = new ynab.API(API_TOKEN);
|
|
17
|
+
const DEFAULT_BUDGET_ID = process.env.YNAB_BUDGET_ID;
|
|
18
|
+
|
|
19
|
+
// --- Helpers ---
|
|
20
|
+
|
|
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)");
|
|
24
|
+
return id;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function dollars(milliunits) {
|
|
28
|
+
return milliunits == null ? null : milliunits / 1000;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function milliunits(dollars) {
|
|
32
|
+
return Math.round(dollars * 1000);
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function ok(data) {
|
|
36
|
+
return { content: [{ type: "text", text: JSON.stringify(data, null, 2) }] };
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
async function run(fn) {
|
|
40
|
+
try {
|
|
41
|
+
return await fn();
|
|
42
|
+
} catch (e) {
|
|
43
|
+
const msg = e?.error?.detail || e?.message || String(e);
|
|
44
|
+
return { content: [{ type: "text", text: `Error: ${msg}` }] };
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
// --- Server ---
|
|
49
|
+
|
|
50
|
+
const server = new McpServer({
|
|
51
|
+
name: "ynab-mcp-server",
|
|
52
|
+
version: "1.0.0",
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
// ==================== User & Budgets ====================
|
|
56
|
+
|
|
57
|
+
server.tool("get_user", "Get the authenticated user", {}, () =>
|
|
58
|
+
run(async () => {
|
|
59
|
+
const { data } = await api.user.getUser();
|
|
60
|
+
return ok(data.user);
|
|
61
|
+
})
|
|
62
|
+
);
|
|
63
|
+
|
|
64
|
+
server.tool("list_budgets", "List all budgets", {}, () =>
|
|
65
|
+
run(async () => {
|
|
66
|
+
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 })));
|
|
68
|
+
})
|
|
69
|
+
);
|
|
70
|
+
|
|
71
|
+
server.tool(
|
|
72
|
+
"get_budget",
|
|
73
|
+
"Get full budget details including accounts, categories, and payees",
|
|
74
|
+
{ budgetId: z.string().optional().describe("Budget ID (uses default if not provided)") },
|
|
75
|
+
({ budgetId }) =>
|
|
76
|
+
run(async () => {
|
|
77
|
+
const { data } = await api.budgets.getBudgetById(resolveBudgetId(budgetId));
|
|
78
|
+
const b = data.budget;
|
|
79
|
+
return ok({
|
|
80
|
+
id: b.id,
|
|
81
|
+
name: b.name,
|
|
82
|
+
currency_format: b.currency_format,
|
|
83
|
+
accounts: b.accounts?.length,
|
|
84
|
+
categories: b.categories?.length,
|
|
85
|
+
payees: b.payees?.length,
|
|
86
|
+
});
|
|
87
|
+
})
|
|
88
|
+
);
|
|
89
|
+
|
|
90
|
+
server.tool(
|
|
91
|
+
"get_budget_settings",
|
|
92
|
+
"Get budget settings (currency format, date format)",
|
|
93
|
+
{ budgetId: z.string().optional().describe("Budget ID (uses default if not provided)") },
|
|
94
|
+
({ budgetId }) =>
|
|
95
|
+
run(async () => {
|
|
96
|
+
const { data } = await api.budgets.getBudgetSettingsById(resolveBudgetId(budgetId));
|
|
97
|
+
return ok(data.settings);
|
|
98
|
+
})
|
|
99
|
+
);
|
|
100
|
+
|
|
101
|
+
// ==================== Accounts ====================
|
|
102
|
+
|
|
103
|
+
server.tool(
|
|
104
|
+
"list_accounts",
|
|
105
|
+
"List all accounts in a budget",
|
|
106
|
+
{ budgetId: z.string().optional().describe("Budget ID (uses default if not provided)") },
|
|
107
|
+
({ budgetId }) =>
|
|
108
|
+
run(async () => {
|
|
109
|
+
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
|
+
);
|
|
122
|
+
})
|
|
123
|
+
);
|
|
124
|
+
|
|
125
|
+
server.tool(
|
|
126
|
+
"get_account",
|
|
127
|
+
"Get details for a specific account",
|
|
128
|
+
{
|
|
129
|
+
budgetId: z.string().optional().describe("Budget ID (uses default if not provided)"),
|
|
130
|
+
accountId: z.string().describe("Account ID"),
|
|
131
|
+
},
|
|
132
|
+
({ budgetId, accountId }) =>
|
|
133
|
+
run(async () => {
|
|
134
|
+
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) });
|
|
137
|
+
})
|
|
138
|
+
);
|
|
139
|
+
|
|
140
|
+
server.tool(
|
|
141
|
+
"create_account",
|
|
142
|
+
"Create a new account",
|
|
143
|
+
{
|
|
144
|
+
budgetId: z.string().optional().describe("Budget ID (uses default if not provided)"),
|
|
145
|
+
name: z.string().describe("Account name"),
|
|
146
|
+
type: z.enum(["checking", "savings", "cash", "creditCard", "lineOfCredit", "otherAsset", "otherLiability", "mortgage", "autoLoan", "studentLoan", "personalLoan", "medicalDebt", "otherDebt"]).describe("Account type"),
|
|
147
|
+
balance: z.number().describe("Starting balance in dollars"),
|
|
148
|
+
},
|
|
149
|
+
({ budgetId, name, type, balance: bal }) =>
|
|
150
|
+
run(async () => {
|
|
151
|
+
const { data } = await api.accounts.createAccount(resolveBudgetId(budgetId), {
|
|
152
|
+
account: { name, type, balance: milliunits(bal) },
|
|
153
|
+
});
|
|
154
|
+
return ok(data.account);
|
|
155
|
+
})
|
|
156
|
+
);
|
|
157
|
+
|
|
158
|
+
// ==================== Categories ====================
|
|
159
|
+
|
|
160
|
+
server.tool(
|
|
161
|
+
"list_categories",
|
|
162
|
+
"List all category groups and their categories",
|
|
163
|
+
{ budgetId: z.string().optional().describe("Budget ID (uses default if not provided)") },
|
|
164
|
+
({ budgetId }) =>
|
|
165
|
+
run(async () => {
|
|
166
|
+
const { data } = await api.categories.getCategories(resolveBudgetId(budgetId));
|
|
167
|
+
return ok(
|
|
168
|
+
data.category_groups.map((g) => ({
|
|
169
|
+
id: g.id,
|
|
170
|
+
name: g.name,
|
|
171
|
+
hidden: g.hidden,
|
|
172
|
+
categories: g.categories.map((c) => ({
|
|
173
|
+
id: c.id,
|
|
174
|
+
name: c.name,
|
|
175
|
+
hidden: c.hidden,
|
|
176
|
+
budgeted: dollars(c.budgeted),
|
|
177
|
+
activity: dollars(c.activity),
|
|
178
|
+
balance: dollars(c.balance),
|
|
179
|
+
})),
|
|
180
|
+
}))
|
|
181
|
+
);
|
|
182
|
+
})
|
|
183
|
+
);
|
|
184
|
+
|
|
185
|
+
server.tool(
|
|
186
|
+
"get_category",
|
|
187
|
+
"Get a specific category",
|
|
188
|
+
{
|
|
189
|
+
budgetId: z.string().optional().describe("Budget ID (uses default if not provided)"),
|
|
190
|
+
categoryId: z.string().describe("Category ID"),
|
|
191
|
+
},
|
|
192
|
+
({ budgetId, categoryId }) =>
|
|
193
|
+
run(async () => {
|
|
194
|
+
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) });
|
|
197
|
+
})
|
|
198
|
+
);
|
|
199
|
+
|
|
200
|
+
server.tool(
|
|
201
|
+
"get_month_category",
|
|
202
|
+
"Get category budget for a specific month",
|
|
203
|
+
{
|
|
204
|
+
budgetId: z.string().optional().describe("Budget ID (uses default if not provided)"),
|
|
205
|
+
month: z.string().describe("Month in YYYY-MM-DD format (first of month)"),
|
|
206
|
+
categoryId: z.string().describe("Category ID"),
|
|
207
|
+
},
|
|
208
|
+
({ budgetId, month, categoryId }) =>
|
|
209
|
+
run(async () => {
|
|
210
|
+
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) });
|
|
213
|
+
})
|
|
214
|
+
);
|
|
215
|
+
|
|
216
|
+
server.tool(
|
|
217
|
+
"update_month_category",
|
|
218
|
+
"Set the budgeted amount for a category in a specific month",
|
|
219
|
+
{
|
|
220
|
+
budgetId: z.string().optional().describe("Budget ID (uses default if not provided)"),
|
|
221
|
+
month: z.string().describe("Month in YYYY-MM-DD format (first of month)"),
|
|
222
|
+
categoryId: z.string().describe("Category ID"),
|
|
223
|
+
budgeted: z.number().describe("Amount to budget in dollars"),
|
|
224
|
+
},
|
|
225
|
+
({ budgetId, month, categoryId, budgeted }) =>
|
|
226
|
+
run(async () => {
|
|
227
|
+
const { data } = await api.categories.updateMonthCategory(resolveBudgetId(budgetId), month, categoryId, {
|
|
228
|
+
category: { budgeted: milliunits(budgeted) },
|
|
229
|
+
});
|
|
230
|
+
const c = data.category;
|
|
231
|
+
return ok({ ...c, budgeted: dollars(c.budgeted), activity: dollars(c.activity), balance: dollars(c.balance) });
|
|
232
|
+
})
|
|
233
|
+
);
|
|
234
|
+
|
|
235
|
+
// ==================== Payees ====================
|
|
236
|
+
|
|
237
|
+
server.tool(
|
|
238
|
+
"list_payees",
|
|
239
|
+
"List all payees",
|
|
240
|
+
{ budgetId: z.string().optional().describe("Budget ID (uses default if not provided)") },
|
|
241
|
+
({ budgetId }) =>
|
|
242
|
+
run(async () => {
|
|
243
|
+
const { data } = await api.payees.getPayees(resolveBudgetId(budgetId));
|
|
244
|
+
return ok(data.payees.map((p) => ({ id: p.id, name: p.name, transfer_account_id: p.transfer_account_id })));
|
|
245
|
+
})
|
|
246
|
+
);
|
|
247
|
+
|
|
248
|
+
server.tool(
|
|
249
|
+
"get_payee",
|
|
250
|
+
"Get a specific payee",
|
|
251
|
+
{
|
|
252
|
+
budgetId: z.string().optional().describe("Budget ID (uses default if not provided)"),
|
|
253
|
+
payeeId: z.string().describe("Payee ID"),
|
|
254
|
+
},
|
|
255
|
+
({ budgetId, payeeId }) =>
|
|
256
|
+
run(async () => {
|
|
257
|
+
const { data } = await api.payees.getPayeeById(resolveBudgetId(budgetId), payeeId);
|
|
258
|
+
return ok(data.payee);
|
|
259
|
+
})
|
|
260
|
+
);
|
|
261
|
+
|
|
262
|
+
server.tool(
|
|
263
|
+
"update_payee",
|
|
264
|
+
"Rename a payee",
|
|
265
|
+
{
|
|
266
|
+
budgetId: z.string().optional().describe("Budget ID (uses default if not provided)"),
|
|
267
|
+
payeeId: z.string().describe("Payee ID"),
|
|
268
|
+
name: z.string().describe("New payee name"),
|
|
269
|
+
},
|
|
270
|
+
({ budgetId, payeeId, name }) =>
|
|
271
|
+
run(async () => {
|
|
272
|
+
const { data } = await api.payees.updatePayee(resolveBudgetId(budgetId), payeeId, {
|
|
273
|
+
payee: { name },
|
|
274
|
+
});
|
|
275
|
+
return ok(data.payee);
|
|
276
|
+
})
|
|
277
|
+
);
|
|
278
|
+
|
|
279
|
+
// ==================== Months ====================
|
|
280
|
+
|
|
281
|
+
server.tool(
|
|
282
|
+
"list_months",
|
|
283
|
+
"List all budget months",
|
|
284
|
+
{ budgetId: z.string().optional().describe("Budget ID (uses default if not provided)") },
|
|
285
|
+
({ budgetId }) =>
|
|
286
|
+
run(async () => {
|
|
287
|
+
const { data } = await api.months.getBudgetMonths(resolveBudgetId(budgetId));
|
|
288
|
+
return ok(
|
|
289
|
+
data.months.map((m) => ({
|
|
290
|
+
month: m.month,
|
|
291
|
+
income: dollars(m.income),
|
|
292
|
+
budgeted: dollars(m.budgeted),
|
|
293
|
+
activity: dollars(m.activity),
|
|
294
|
+
to_be_budgeted: dollars(m.to_be_budgeted),
|
|
295
|
+
}))
|
|
296
|
+
);
|
|
297
|
+
})
|
|
298
|
+
);
|
|
299
|
+
|
|
300
|
+
server.tool(
|
|
301
|
+
"get_month",
|
|
302
|
+
"Get budget month detail with per-category breakdown",
|
|
303
|
+
{
|
|
304
|
+
budgetId: z.string().optional().describe("Budget ID (uses default if not provided)"),
|
|
305
|
+
month: z.string().describe("Month in YYYY-MM-DD format (first of month)"),
|
|
306
|
+
},
|
|
307
|
+
({ budgetId, month }) =>
|
|
308
|
+
run(async () => {
|
|
309
|
+
const { data } = await api.months.getBudgetMonth(resolveBudgetId(budgetId), month);
|
|
310
|
+
const m = data.month;
|
|
311
|
+
return ok({
|
|
312
|
+
month: m.month,
|
|
313
|
+
income: dollars(m.income),
|
|
314
|
+
budgeted: dollars(m.budgeted),
|
|
315
|
+
activity: dollars(m.activity),
|
|
316
|
+
to_be_budgeted: dollars(m.to_be_budgeted),
|
|
317
|
+
categories: m.categories?.map((c) => ({
|
|
318
|
+
id: c.id,
|
|
319
|
+
name: c.name,
|
|
320
|
+
budgeted: dollars(c.budgeted),
|
|
321
|
+
activity: dollars(c.activity),
|
|
322
|
+
balance: dollars(c.balance),
|
|
323
|
+
})),
|
|
324
|
+
});
|
|
325
|
+
})
|
|
326
|
+
);
|
|
327
|
+
|
|
328
|
+
// ==================== Transactions ====================
|
|
329
|
+
|
|
330
|
+
function formatTransaction(t) {
|
|
331
|
+
return {
|
|
332
|
+
id: t.id,
|
|
333
|
+
date: t.date,
|
|
334
|
+
amount: dollars(t.amount),
|
|
335
|
+
memo: t.memo,
|
|
336
|
+
cleared: t.cleared,
|
|
337
|
+
approved: t.approved,
|
|
338
|
+
flag_color: t.flag_color,
|
|
339
|
+
flag_name: t.flag_name,
|
|
340
|
+
account_id: t.account_id,
|
|
341
|
+
account_name: t.account_name,
|
|
342
|
+
payee_id: t.payee_id,
|
|
343
|
+
payee_name: t.payee_name,
|
|
344
|
+
category_id: t.category_id,
|
|
345
|
+
category_name: t.category_name,
|
|
346
|
+
transfer_account_id: t.transfer_account_id,
|
|
347
|
+
subtransactions: t.subtransactions?.map((s) => ({
|
|
348
|
+
id: s.id,
|
|
349
|
+
amount: dollars(s.amount),
|
|
350
|
+
memo: s.memo,
|
|
351
|
+
payee_id: s.payee_id,
|
|
352
|
+
payee_name: s.payee_name,
|
|
353
|
+
category_id: s.category_id,
|
|
354
|
+
category_name: s.category_name,
|
|
355
|
+
})),
|
|
356
|
+
};
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
server.tool(
|
|
360
|
+
"get_transactions",
|
|
361
|
+
"Get transactions with optional filters. Use type='unapproved' or type='uncategorized' to filter. Optionally filter by account, category, payee, or month.",
|
|
362
|
+
{
|
|
363
|
+
budgetId: z.string().optional().describe("Budget ID (uses default if not provided)"),
|
|
364
|
+
sinceDate: z.string().optional().describe("Only return transactions on or after this date (YYYY-MM-DD)"),
|
|
365
|
+
type: z.enum(["unapproved", "uncategorized"]).optional().describe("Filter by approval/categorization status"),
|
|
366
|
+
accountId: z.string().optional().describe("Filter by account ID"),
|
|
367
|
+
categoryId: z.string().optional().describe("Filter by category ID"),
|
|
368
|
+
payeeId: z.string().optional().describe("Filter by payee ID"),
|
|
369
|
+
month: z.string().optional().describe("Filter by month (YYYY-MM-DD, first of month)"),
|
|
370
|
+
},
|
|
371
|
+
({ budgetId, sinceDate, type, accountId, categoryId, payeeId, month }) =>
|
|
372
|
+
run(async () => {
|
|
373
|
+
const bid = resolveBudgetId(budgetId);
|
|
374
|
+
let transactions;
|
|
375
|
+
|
|
376
|
+
if (accountId) {
|
|
377
|
+
const { data } = await api.transactions.getTransactionsByAccount(bid, accountId, sinceDate, type);
|
|
378
|
+
transactions = data.transactions;
|
|
379
|
+
} else if (categoryId) {
|
|
380
|
+
const { data } = await api.transactions.getTransactionsByCategory(bid, categoryId, sinceDate, type);
|
|
381
|
+
transactions = data.transactions;
|
|
382
|
+
} else if (payeeId) {
|
|
383
|
+
const { data } = await api.transactions.getTransactionsByPayee(bid, payeeId, sinceDate, type);
|
|
384
|
+
transactions = data.transactions;
|
|
385
|
+
} 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);
|
|
392
|
+
} else {
|
|
393
|
+
const { data } = await api.transactions.getTransactions(bid, sinceDate, type);
|
|
394
|
+
transactions = data.transactions;
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
return ok(transactions.map(formatTransaction));
|
|
398
|
+
})
|
|
399
|
+
);
|
|
400
|
+
|
|
401
|
+
server.tool(
|
|
402
|
+
"get_transaction",
|
|
403
|
+
"Get a single transaction by ID",
|
|
404
|
+
{
|
|
405
|
+
budgetId: z.string().optional().describe("Budget ID (uses default if not provided)"),
|
|
406
|
+
transactionId: z.string().describe("Transaction ID"),
|
|
407
|
+
},
|
|
408
|
+
({ budgetId, transactionId }) =>
|
|
409
|
+
run(async () => {
|
|
410
|
+
const { data } = await api.transactions.getTransactionById(resolveBudgetId(budgetId), transactionId);
|
|
411
|
+
return ok(formatTransaction(data.transaction));
|
|
412
|
+
})
|
|
413
|
+
);
|
|
414
|
+
|
|
415
|
+
server.tool(
|
|
416
|
+
"create_transaction",
|
|
417
|
+
"Create a new transaction. Amounts are in dollars (positive for inflows, negative for outflows).",
|
|
418
|
+
{
|
|
419
|
+
budgetId: z.string().optional().describe("Budget ID (uses default if not provided)"),
|
|
420
|
+
accountId: z.string().describe("Account ID"),
|
|
421
|
+
date: z.string().describe("Transaction date (YYYY-MM-DD)"),
|
|
422
|
+
amount: z.number().describe("Amount in dollars (negative for outflows, positive for inflows)"),
|
|
423
|
+
payeeId: z.string().optional().describe("Payee ID"),
|
|
424
|
+
payeeName: z.string().optional().describe("Payee name (creates new payee if no payeeId)"),
|
|
425
|
+
categoryId: z.string().optional().describe("Category ID"),
|
|
426
|
+
memo: z.string().optional().describe("Transaction memo"),
|
|
427
|
+
cleared: z.enum(["cleared", "uncleared", "reconciled"]).optional().describe("Cleared status"),
|
|
428
|
+
approved: z.boolean().optional().describe("Whether transaction is approved"),
|
|
429
|
+
flagColor: z.enum(["red", "orange", "yellow", "green", "blue", "purple"]).optional().describe("Flag color"),
|
|
430
|
+
},
|
|
431
|
+
({ budgetId, accountId, date, amount, payeeId, payeeName, categoryId, memo, cleared, approved, flagColor }) =>
|
|
432
|
+
run(async () => {
|
|
433
|
+
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
|
+
},
|
|
446
|
+
});
|
|
447
|
+
return ok(formatTransaction(data.transaction));
|
|
448
|
+
})
|
|
449
|
+
);
|
|
450
|
+
|
|
451
|
+
server.tool(
|
|
452
|
+
"update_transaction",
|
|
453
|
+
"Update an existing transaction. Only provided fields are changed. Amounts in dollars.",
|
|
454
|
+
{
|
|
455
|
+
budgetId: z.string().optional().describe("Budget ID (uses default if not provided)"),
|
|
456
|
+
transactionId: z.string().describe("Transaction ID"),
|
|
457
|
+
accountId: z.string().optional().describe("Account ID"),
|
|
458
|
+
date: z.string().optional().describe("Transaction date (YYYY-MM-DD)"),
|
|
459
|
+
amount: z.number().optional().describe("Amount in dollars"),
|
|
460
|
+
payeeId: z.string().optional().describe("Payee ID"),
|
|
461
|
+
payeeName: z.string().optional().describe("Payee name"),
|
|
462
|
+
categoryId: z.string().nullable().optional().describe("Category ID (null to uncategorize)"),
|
|
463
|
+
memo: z.string().optional().describe("Transaction memo"),
|
|
464
|
+
cleared: z.enum(["cleared", "uncleared", "reconciled"]).optional().describe("Cleared status"),
|
|
465
|
+
approved: z.boolean().optional().describe("Whether transaction is approved"),
|
|
466
|
+
flagColor: z.enum(["red", "orange", "yellow", "green", "blue", "purple"]).nullable().optional().describe("Flag color (null to remove)"),
|
|
467
|
+
},
|
|
468
|
+
({ budgetId, transactionId, accountId, date, amount, payeeId, payeeName, categoryId, memo, cleared, approved, flagColor }) =>
|
|
469
|
+
run(async () => {
|
|
470
|
+
const txn = {};
|
|
471
|
+
if (accountId !== undefined) txn.account_id = accountId;
|
|
472
|
+
if (date !== undefined) txn.date = date;
|
|
473
|
+
if (amount !== undefined) txn.amount = milliunits(amount);
|
|
474
|
+
if (payeeId !== undefined) txn.payee_id = payeeId;
|
|
475
|
+
if (payeeName !== undefined) txn.payee_name = payeeName;
|
|
476
|
+
if (categoryId !== undefined) txn.category_id = categoryId;
|
|
477
|
+
if (memo !== undefined) txn.memo = memo;
|
|
478
|
+
if (cleared !== undefined) txn.cleared = cleared;
|
|
479
|
+
if (approved !== undefined) txn.approved = approved;
|
|
480
|
+
if (flagColor !== undefined) txn.flag_color = flagColor;
|
|
481
|
+
|
|
482
|
+
const { data } = await api.transactions.updateTransaction(resolveBudgetId(budgetId), transactionId, {
|
|
483
|
+
transaction: txn,
|
|
484
|
+
});
|
|
485
|
+
return ok(formatTransaction(data.transaction));
|
|
486
|
+
})
|
|
487
|
+
);
|
|
488
|
+
|
|
489
|
+
server.tool(
|
|
490
|
+
"delete_transaction",
|
|
491
|
+
"Delete a transaction",
|
|
492
|
+
{
|
|
493
|
+
budgetId: z.string().optional().describe("Budget ID (uses default if not provided)"),
|
|
494
|
+
transactionId: z.string().describe("Transaction ID"),
|
|
495
|
+
},
|
|
496
|
+
({ budgetId, transactionId }) =>
|
|
497
|
+
run(async () => {
|
|
498
|
+
const { data } = await api.transactions.deleteTransaction(resolveBudgetId(budgetId), transactionId);
|
|
499
|
+
return ok(data.transaction);
|
|
500
|
+
})
|
|
501
|
+
);
|
|
502
|
+
|
|
503
|
+
server.tool(
|
|
504
|
+
"update_transactions",
|
|
505
|
+
"Batch update multiple transactions. Each transaction object must include its id and the fields to update.",
|
|
506
|
+
{
|
|
507
|
+
budgetId: z.string().optional().describe("Budget ID (uses default if not provided)"),
|
|
508
|
+
transactions: z
|
|
509
|
+
.array(
|
|
510
|
+
z.object({
|
|
511
|
+
id: z.string().describe("Transaction ID"),
|
|
512
|
+
account_id: z.string().optional(),
|
|
513
|
+
date: z.string().optional(),
|
|
514
|
+
amount: z.number().optional().describe("Amount in dollars"),
|
|
515
|
+
payee_id: z.string().optional(),
|
|
516
|
+
payee_name: z.string().optional(),
|
|
517
|
+
category_id: z.string().optional(),
|
|
518
|
+
memo: z.string().optional(),
|
|
519
|
+
cleared: z.enum(["cleared", "uncleared", "reconciled"]).optional(),
|
|
520
|
+
approved: z.boolean().optional(),
|
|
521
|
+
flag_color: z.enum(["red", "orange", "yellow", "green", "blue", "purple"]).nullable().optional(),
|
|
522
|
+
})
|
|
523
|
+
)
|
|
524
|
+
.describe("Array of transaction updates"),
|
|
525
|
+
},
|
|
526
|
+
({ budgetId, transactions: txns }) =>
|
|
527
|
+
run(async () => {
|
|
528
|
+
const mapped = txns.map((t) => {
|
|
529
|
+
const out = { ...t };
|
|
530
|
+
if (out.amount !== undefined) out.amount = milliunits(out.amount);
|
|
531
|
+
return out;
|
|
532
|
+
});
|
|
533
|
+
const { data } = await api.transactions.updateTransactions(resolveBudgetId(budgetId), {
|
|
534
|
+
transactions: mapped,
|
|
535
|
+
});
|
|
536
|
+
return ok({
|
|
537
|
+
updated: data.transactions?.map(formatTransaction),
|
|
538
|
+
duplicate_import_ids: data.duplicate_import_ids,
|
|
539
|
+
});
|
|
540
|
+
})
|
|
541
|
+
);
|
|
542
|
+
|
|
543
|
+
server.tool(
|
|
544
|
+
"import_transactions",
|
|
545
|
+
"Trigger import of linked account transactions",
|
|
546
|
+
{ budgetId: z.string().optional().describe("Budget ID (uses default if not provided)") },
|
|
547
|
+
({ budgetId }) =>
|
|
548
|
+
run(async () => {
|
|
549
|
+
const { data } = await api.transactions.importTransactions(resolveBudgetId(budgetId));
|
|
550
|
+
return ok(data);
|
|
551
|
+
})
|
|
552
|
+
);
|
|
553
|
+
|
|
554
|
+
// ==================== Scheduled Transactions ====================
|
|
555
|
+
|
|
556
|
+
function formatScheduledTransaction(t) {
|
|
557
|
+
return {
|
|
558
|
+
id: t.id,
|
|
559
|
+
date_first: t.date_first,
|
|
560
|
+
date_next: t.date_next,
|
|
561
|
+
frequency: t.frequency,
|
|
562
|
+
amount: dollars(t.amount),
|
|
563
|
+
memo: t.memo,
|
|
564
|
+
flag_color: t.flag_color,
|
|
565
|
+
flag_name: t.flag_name,
|
|
566
|
+
account_id: t.account_id,
|
|
567
|
+
account_name: t.account_name,
|
|
568
|
+
payee_id: t.payee_id,
|
|
569
|
+
payee_name: t.payee_name,
|
|
570
|
+
category_id: t.category_id,
|
|
571
|
+
category_name: t.category_name,
|
|
572
|
+
};
|
|
573
|
+
}
|
|
574
|
+
|
|
575
|
+
server.tool(
|
|
576
|
+
"list_scheduled_transactions",
|
|
577
|
+
"List all scheduled (recurring) transactions",
|
|
578
|
+
{ budgetId: z.string().optional().describe("Budget ID (uses default if not provided)") },
|
|
579
|
+
({ budgetId }) =>
|
|
580
|
+
run(async () => {
|
|
581
|
+
const { data } = await api.scheduledTransactions.getScheduledTransactions(resolveBudgetId(budgetId));
|
|
582
|
+
return ok(data.scheduled_transactions.map(formatScheduledTransaction));
|
|
583
|
+
})
|
|
584
|
+
);
|
|
585
|
+
|
|
586
|
+
server.tool(
|
|
587
|
+
"get_scheduled_transaction",
|
|
588
|
+
"Get a specific scheduled transaction",
|
|
589
|
+
{
|
|
590
|
+
budgetId: z.string().optional().describe("Budget ID (uses default if not provided)"),
|
|
591
|
+
scheduledTransactionId: z.string().describe("Scheduled transaction ID"),
|
|
592
|
+
},
|
|
593
|
+
({ budgetId, scheduledTransactionId }) =>
|
|
594
|
+
run(async () => {
|
|
595
|
+
const { data } = await api.scheduledTransactions.getScheduledTransactionById(resolveBudgetId(budgetId), scheduledTransactionId);
|
|
596
|
+
return ok(formatScheduledTransaction(data.scheduled_transaction));
|
|
597
|
+
})
|
|
598
|
+
);
|
|
599
|
+
|
|
600
|
+
server.tool(
|
|
601
|
+
"create_scheduled_transaction",
|
|
602
|
+
"Create a new scheduled (recurring) transaction",
|
|
603
|
+
{
|
|
604
|
+
budgetId: z.string().optional().describe("Budget ID (uses default if not provided)"),
|
|
605
|
+
accountId: z.string().describe("Account ID"),
|
|
606
|
+
dateFirst: z.string().describe("First occurrence date (YYYY-MM-DD)"),
|
|
607
|
+
frequency: z.enum(["never", "daily", "weekly", "everyOtherWeek", "twiceAMonth", "every4Weeks", "monthly", "everyOtherMonth", "every3Months", "every4Months", "twiceAYear", "yearly", "everyOtherYear"]).describe("Recurrence frequency"),
|
|
608
|
+
amount: z.number().describe("Amount in dollars (negative for outflows)"),
|
|
609
|
+
payeeId: z.string().optional().describe("Payee ID"),
|
|
610
|
+
payeeName: z.string().optional().describe("Payee name"),
|
|
611
|
+
categoryId: z.string().optional().describe("Category ID"),
|
|
612
|
+
memo: z.string().optional().describe("Memo"),
|
|
613
|
+
flagColor: z.enum(["red", "orange", "yellow", "green", "blue", "purple"]).optional().describe("Flag color"),
|
|
614
|
+
},
|
|
615
|
+
({ budgetId, accountId, dateFirst, frequency, amount, payeeId, payeeName, categoryId, memo, flagColor }) =>
|
|
616
|
+
run(async () => {
|
|
617
|
+
const { data } = await api.scheduledTransactions.createScheduledTransaction(resolveBudgetId(budgetId), {
|
|
618
|
+
scheduled_transaction: {
|
|
619
|
+
account_id: accountId,
|
|
620
|
+
date: dateFirst,
|
|
621
|
+
frequency,
|
|
622
|
+
amount: milliunits(amount),
|
|
623
|
+
payee_id: payeeId,
|
|
624
|
+
payee_name: payeeName,
|
|
625
|
+
category_id: categoryId,
|
|
626
|
+
memo,
|
|
627
|
+
flag_color: flagColor,
|
|
628
|
+
},
|
|
629
|
+
});
|
|
630
|
+
return ok(formatScheduledTransaction(data.scheduled_transaction));
|
|
631
|
+
})
|
|
632
|
+
);
|
|
633
|
+
|
|
634
|
+
server.tool(
|
|
635
|
+
"delete_scheduled_transaction",
|
|
636
|
+
"Delete a scheduled transaction",
|
|
637
|
+
{
|
|
638
|
+
budgetId: z.string().optional().describe("Budget ID (uses default if not provided)"),
|
|
639
|
+
scheduledTransactionId: z.string().describe("Scheduled transaction ID"),
|
|
640
|
+
},
|
|
641
|
+
({ budgetId, scheduledTransactionId }) =>
|
|
642
|
+
run(async () => {
|
|
643
|
+
const { data } = await api.scheduledTransactions.deleteScheduledTransaction(resolveBudgetId(budgetId), scheduledTransactionId);
|
|
644
|
+
return ok(data.scheduled_transaction);
|
|
645
|
+
})
|
|
646
|
+
);
|
|
647
|
+
|
|
648
|
+
// ==================== Convenience Tools ====================
|
|
649
|
+
|
|
650
|
+
server.tool(
|
|
651
|
+
"search_categories",
|
|
652
|
+
"Search categories by partial name match (case-insensitive). Useful for finding category IDs when you only know part of the name.",
|
|
653
|
+
{
|
|
654
|
+
budgetId: z.string().optional().describe("Budget ID (uses default if not provided)"),
|
|
655
|
+
query: z.string().describe("Partial category name to search for (e.g. 'work' matches '💻 Work Expenses (Oliver LLC)')"),
|
|
656
|
+
},
|
|
657
|
+
({ budgetId, query }) =>
|
|
658
|
+
run(async () => {
|
|
659
|
+
const { data } = await api.categories.getCategories(resolveBudgetId(budgetId));
|
|
660
|
+
const q = query.toLowerCase();
|
|
661
|
+
const matches = [];
|
|
662
|
+
for (const g of data.category_groups) {
|
|
663
|
+
if (g.hidden) continue;
|
|
664
|
+
for (const c of g.categories) {
|
|
665
|
+
if (c.hidden) continue;
|
|
666
|
+
if (c.name.toLowerCase().includes(q)) {
|
|
667
|
+
matches.push({
|
|
668
|
+
id: c.id,
|
|
669
|
+
name: c.name,
|
|
670
|
+
group: g.name,
|
|
671
|
+
budgeted: dollars(c.budgeted),
|
|
672
|
+
activity: dollars(c.activity),
|
|
673
|
+
balance: dollars(c.balance),
|
|
674
|
+
});
|
|
675
|
+
}
|
|
676
|
+
}
|
|
677
|
+
}
|
|
678
|
+
if (matches.length === 0) return ok({ message: `No categories matching "${query}"`, suggestions: "Try a shorter search term" });
|
|
679
|
+
return ok(matches);
|
|
680
|
+
})
|
|
681
|
+
);
|
|
682
|
+
|
|
683
|
+
server.tool(
|
|
684
|
+
"search_payees",
|
|
685
|
+
"Search payees by partial name match (case-insensitive). Useful for finding payee IDs.",
|
|
686
|
+
{
|
|
687
|
+
budgetId: z.string().optional().describe("Budget ID (uses default if not provided)"),
|
|
688
|
+
query: z.string().describe("Partial payee name to search for"),
|
|
689
|
+
},
|
|
690
|
+
({ budgetId, query }) =>
|
|
691
|
+
run(async () => {
|
|
692
|
+
const { data } = await api.payees.getPayees(resolveBudgetId(budgetId));
|
|
693
|
+
const q = query.toLowerCase();
|
|
694
|
+
const matches = data.payees
|
|
695
|
+
.filter((p) => p.name.toLowerCase().includes(q))
|
|
696
|
+
.map((p) => ({ id: p.id, name: p.name, transfer_account_id: p.transfer_account_id }));
|
|
697
|
+
if (matches.length === 0) return ok({ message: `No payees matching "${query}"` });
|
|
698
|
+
return ok(matches);
|
|
699
|
+
})
|
|
700
|
+
);
|
|
701
|
+
|
|
702
|
+
server.tool(
|
|
703
|
+
"review_unapproved",
|
|
704
|
+
"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.",
|
|
705
|
+
{ budgetId: z.string().optional().describe("Budget ID (uses default if not provided)") },
|
|
706
|
+
({ budgetId }) =>
|
|
707
|
+
run(async () => {
|
|
708
|
+
const { data } = await api.transactions.getTransactions(resolveBudgetId(budgetId), undefined, "unapproved");
|
|
709
|
+
const txns = data.transactions.map(formatTransaction);
|
|
710
|
+
const categorized = txns.filter((t) => t.category_id && t.category_name !== "Uncategorized");
|
|
711
|
+
const uncategorized = txns.filter((t) => !t.category_id || t.category_name === "Uncategorized");
|
|
712
|
+
return ok({
|
|
713
|
+
total: txns.length,
|
|
714
|
+
ready_to_approve: {
|
|
715
|
+
count: categorized.length,
|
|
716
|
+
transactions: categorized,
|
|
717
|
+
},
|
|
718
|
+
needs_category_first: {
|
|
719
|
+
count: uncategorized.length,
|
|
720
|
+
warning: "Do NOT approve these without assigning a category first",
|
|
721
|
+
transactions: uncategorized,
|
|
722
|
+
},
|
|
723
|
+
});
|
|
724
|
+
})
|
|
725
|
+
);
|
|
726
|
+
|
|
727
|
+
// --- Start ---
|
|
728
|
+
|
|
729
|
+
const transport = new StdioServerTransport();
|
|
730
|
+
await server.connect(transport);
|
package/package.json
ADDED
|
@@ -0,0 +1,33 @@
|
|
|
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
|
+
}
|