@oliverames/ynab-mcp-server 1.6.0 → 2.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +136 -33
- package/assets/icon.png +0 -0
- package/docs/hosted-oauth-connector.md +85 -0
- package/index.js +474 -33
- package/package.json +14 -3
- package/scripts/build-mcpb.mjs +133 -0
- package/scripts/check-release-consistency.mjs +92 -0
- package/scripts/lib/smoke-client.mjs +133 -0
- package/scripts/smoke-batch-verify.mjs +81 -0
- package/scripts/smoke-list-tools.mjs +28 -0
- package/scripts/smoke-review-unapproved.mjs +27 -0
- package/scripts/test-safety-model.mjs +128 -0
package/index.js
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
|
|
3
3
|
import { execFileSync } from "node:child_process";
|
|
4
|
+
import { readFileSync } from "node:fs";
|
|
4
5
|
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
5
6
|
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
|
|
6
7
|
import { z } from "zod";
|
|
@@ -8,8 +9,24 @@ import * as ynab from "ynab";
|
|
|
8
9
|
|
|
9
10
|
// --- Init ---
|
|
10
11
|
|
|
12
|
+
const BASE_URL = "https://api.ynab.com/v1";
|
|
13
|
+
const YNAB_API_HOST = "api.ynab.com";
|
|
14
|
+
const MAX_TOKEN_FILE_BYTES = 4096;
|
|
15
|
+
const MAX_RESPONSE_BYTES = Number.parseInt(process.env.YNAB_MAX_RESPONSE_BYTES || "8388608", 10);
|
|
16
|
+
|
|
11
17
|
let API_TOKEN = process.env.YNAB_API_TOKEN;
|
|
12
|
-
let
|
|
18
|
+
let tokenLookupError;
|
|
19
|
+
if (!API_TOKEN && process.env.YNAB_API_TOKEN_FILE) {
|
|
20
|
+
try {
|
|
21
|
+
const tokenFileContents = readFileSync(process.env.YNAB_API_TOKEN_FILE, "utf8");
|
|
22
|
+
if (Buffer.byteLength(tokenFileContents, "utf8") > MAX_TOKEN_FILE_BYTES) {
|
|
23
|
+
throw new Error(`token file exceeds ${MAX_TOKEN_FILE_BYTES} bytes`);
|
|
24
|
+
}
|
|
25
|
+
API_TOKEN = tokenFileContents.trim();
|
|
26
|
+
} catch (e) {
|
|
27
|
+
tokenLookupError = `Could not read YNAB_API_TOKEN_FILE: ${e.message || String(e)}`;
|
|
28
|
+
}
|
|
29
|
+
}
|
|
13
30
|
if (!API_TOKEN && process.env.YNAB_OP_PATH) {
|
|
14
31
|
try {
|
|
15
32
|
API_TOKEN = execFileSync(
|
|
@@ -17,18 +34,20 @@ if (!API_TOKEN && process.env.YNAB_OP_PATH) {
|
|
|
17
34
|
{ encoding: "utf-8", timeout: 10000, stdio: ["pipe", "pipe", "pipe"] }
|
|
18
35
|
).trim();
|
|
19
36
|
} catch (e) {
|
|
20
|
-
|
|
37
|
+
tokenLookupError = `Could not read YNAB_OP_PATH via 1Password CLI: ${e.stderr?.toString().trim() || e.message || "unknown 1Password CLI error"}`;
|
|
21
38
|
}
|
|
22
39
|
}
|
|
23
40
|
if (!API_TOKEN) {
|
|
24
|
-
const
|
|
25
|
-
? `
|
|
26
|
-
: " Set YNAB_OP_PATH to enable
|
|
27
|
-
console.error(`YNAB_API_TOKEN environment variable is required.${
|
|
41
|
+
const fallbackMessage = tokenLookupError
|
|
42
|
+
? ` ${tokenLookupError}.`
|
|
43
|
+
: " Set YNAB_API_TOKEN_FILE or YNAB_OP_PATH to enable token fallback.";
|
|
44
|
+
console.error(`YNAB_API_TOKEN environment variable is required.${fallbackMessage}`);
|
|
28
45
|
process.exit(1);
|
|
29
46
|
}
|
|
30
47
|
|
|
31
|
-
const
|
|
48
|
+
const ynabRateLimit = createYnabRateLimiter();
|
|
49
|
+
const api = new ynab.API(API_TOKEN, BASE_URL);
|
|
50
|
+
api._configuration.config = { accessToken: API_TOKEN, basePath: BASE_URL, fetchApi: secureFetch };
|
|
32
51
|
const DEFAULT_BUDGET_ID = process.env.YNAB_BUDGET_ID;
|
|
33
52
|
|
|
34
53
|
// --- Helpers ---
|
|
@@ -45,6 +64,12 @@ function milliunits(dollars) {
|
|
|
45
64
|
return Math.round(dollars * 1000);
|
|
46
65
|
}
|
|
47
66
|
|
|
67
|
+
// Round a dollar sum to cents, killing IEEE-754 artifacts from summing floats
|
|
68
|
+
// (e.g. a group total like -53.730000000000004 produced by reduce/+= over amounts).
|
|
69
|
+
function round2(n) {
|
|
70
|
+
return n == null ? n : Math.round(n * 100) / 100;
|
|
71
|
+
}
|
|
72
|
+
|
|
48
73
|
function dollarsMap(obj) {
|
|
49
74
|
return obj ? Object.fromEntries(Object.entries(obj).map(([k, v]) => [k, dollars(v)])) : obj;
|
|
50
75
|
}
|
|
@@ -101,6 +126,89 @@ function mapTransactionUpdate(t) {
|
|
|
101
126
|
return out;
|
|
102
127
|
}
|
|
103
128
|
|
|
129
|
+
const TRANSACTION_UPDATE_VERIFICATION_FIELDS = [
|
|
130
|
+
["accountId", "account_id"],
|
|
131
|
+
["date", "date"],
|
|
132
|
+
["amount", "amount"],
|
|
133
|
+
["payeeId", "payee_id"],
|
|
134
|
+
["payeeName", "payee_name"],
|
|
135
|
+
["categoryId", "category_id"],
|
|
136
|
+
["memo", "memo"],
|
|
137
|
+
["cleared", "cleared"],
|
|
138
|
+
["approved", "approved"],
|
|
139
|
+
["flagColor", "flag_color"],
|
|
140
|
+
];
|
|
141
|
+
|
|
142
|
+
function hasOwn(obj, key) {
|
|
143
|
+
return Object.prototype.hasOwnProperty.call(obj, key);
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
function updateFieldMatches(expected, actual) {
|
|
147
|
+
if (typeof expected === "number" && typeof actual === "number") {
|
|
148
|
+
return Math.abs(expected - actual) < 0.0001;
|
|
149
|
+
}
|
|
150
|
+
return Object.is(expected, actual);
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
function transactionUpdateMismatches(requested, actual) {
|
|
154
|
+
const mismatches = [];
|
|
155
|
+
for (const [inputField, outputField] of TRANSACTION_UPDATE_VERIFICATION_FIELDS) {
|
|
156
|
+
if (!hasOwn(requested, inputField)) continue;
|
|
157
|
+
const expected = requested[inputField] ?? null;
|
|
158
|
+
const actualValue = actual[outputField] ?? null;
|
|
159
|
+
if (!updateFieldMatches(expected, actualValue)) {
|
|
160
|
+
mismatches.push({
|
|
161
|
+
field: inputField,
|
|
162
|
+
expected,
|
|
163
|
+
actual: actualValue,
|
|
164
|
+
});
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
return mismatches;
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
async function getFormattedTransaction(budgetId, transactionId) {
|
|
171
|
+
const { data } = await api.transactions.getTransactionById(budgetId, normalizeTransactionId(transactionId));
|
|
172
|
+
return formatTransaction(data.transaction);
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
async function verifyBulkTransactionUpdates(budgetId, requestedUpdates) {
|
|
176
|
+
const verification = {
|
|
177
|
+
checked: requestedUpdates.length,
|
|
178
|
+
retried: [],
|
|
179
|
+
failed: [],
|
|
180
|
+
};
|
|
181
|
+
const verified = [];
|
|
182
|
+
|
|
183
|
+
for (const requested of requestedUpdates) {
|
|
184
|
+
let refetched = await getFormattedTransaction(budgetId, requested.id);
|
|
185
|
+
let mismatches = transactionUpdateMismatches(requested, refetched);
|
|
186
|
+
|
|
187
|
+
if (mismatches.length > 0) {
|
|
188
|
+
verification.retried.push({
|
|
189
|
+
id: requested.id,
|
|
190
|
+
mismatches,
|
|
191
|
+
});
|
|
192
|
+
const { data } = await api.transactions.updateTransaction(budgetId, requested.id, {
|
|
193
|
+
transaction: mapTransactionUpdate(requested),
|
|
194
|
+
});
|
|
195
|
+
refetched = formatTransaction(data.transaction);
|
|
196
|
+
mismatches = transactionUpdateMismatches(requested, refetched);
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
if (mismatches.length > 0) {
|
|
200
|
+
verification.failed.push({
|
|
201
|
+
id: requested.id,
|
|
202
|
+
mismatches,
|
|
203
|
+
});
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
verified.push(refetched);
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
return { verification, verified };
|
|
210
|
+
}
|
|
211
|
+
|
|
104
212
|
// YNAB scheduled transactions that realize get composite IDs like `uuid_YYYY-MM-DD`.
|
|
105
213
|
// Strip the date suffix so API lookups work correctly.
|
|
106
214
|
function normalizeTransactionId(id) {
|
|
@@ -126,14 +234,87 @@ async function run(fn) {
|
|
|
126
234
|
const msg = detail
|
|
127
235
|
? (name ? `${name}: ${detail}` : detail)
|
|
128
236
|
: (e?.message || String(e));
|
|
129
|
-
return { content: [{ type: "text", text: `Error: ${msg}` }], isError: true };
|
|
237
|
+
return { content: [{ type: "text", text: `Error: ${sanitizeErrorMessage(msg)}` }], isError: true };
|
|
238
|
+
}
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
function sanitizeErrorMessage(value) {
|
|
242
|
+
let message = String(value ?? "");
|
|
243
|
+
if (API_TOKEN) {
|
|
244
|
+
message = message.split(API_TOKEN).join("[REDACTED_TOKEN]");
|
|
245
|
+
}
|
|
246
|
+
return message
|
|
247
|
+
.replace(/Bearer\s+[A-Za-z0-9._~+/=-]+/gi, "Bearer [REDACTED_TOKEN]")
|
|
248
|
+
.replace(/Authorization:\s*[^\r\n]+/gi, "Authorization: [REDACTED_TOKEN]");
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
function createYnabRateLimiter() {
|
|
252
|
+
const requestsPerHour = Number.parseFloat(process.env.YNAB_RATE_LIMIT_PER_HOUR || "190");
|
|
253
|
+
if (!Number.isFinite(requestsPerHour) || requestsPerHour <= 0) {
|
|
254
|
+
return async () => {};
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
const burst = Math.max(1, Number.parseInt(process.env.YNAB_RATE_LIMIT_BURST || "10", 10));
|
|
258
|
+
const refillMs = 3600000 / requestsPerHour;
|
|
259
|
+
let tokens = burst;
|
|
260
|
+
let updatedAt = Date.now();
|
|
261
|
+
|
|
262
|
+
return async function waitForYnabRateLimit() {
|
|
263
|
+
while (true) {
|
|
264
|
+
const now = Date.now();
|
|
265
|
+
const elapsed = now - updatedAt;
|
|
266
|
+
tokens = Math.min(burst, tokens + elapsed / refillMs);
|
|
267
|
+
updatedAt = now;
|
|
268
|
+
|
|
269
|
+
if (tokens >= 1) {
|
|
270
|
+
tokens -= 1;
|
|
271
|
+
return;
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
const waitMs = Math.ceil((1 - tokens) * refillMs);
|
|
275
|
+
await new Promise((resolve) => setTimeout(resolve, waitMs));
|
|
276
|
+
}
|
|
277
|
+
};
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
function assertYnabApiUrl(url) {
|
|
281
|
+
if (url.protocol !== "https:" || url.hostname.toLowerCase() !== YNAB_API_HOST || (url.port && url.port !== "443")) {
|
|
282
|
+
throw new Error(`Refusing YNAB API request to non-YNAB host: ${url.origin}`);
|
|
283
|
+
}
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
async function secureFetch(input, init = {}) {
|
|
287
|
+
const url = input instanceof URL ? input : new URL(typeof input === "string" ? input : input.url);
|
|
288
|
+
assertYnabApiUrl(url);
|
|
289
|
+
await ynabRateLimit();
|
|
290
|
+
|
|
291
|
+
const timeoutMs = Number.parseInt(process.env.YNAB_HTTP_TIMEOUT_MS || "30000", 10);
|
|
292
|
+
const controller = !init.signal && timeoutMs > 0 ? new AbortController() : null;
|
|
293
|
+
const timeout = controller
|
|
294
|
+
? setTimeout(() => controller.abort(new Error(`YNAB request timed out after ${timeoutMs}ms`)), timeoutMs)
|
|
295
|
+
: null;
|
|
296
|
+
|
|
297
|
+
try {
|
|
298
|
+
return await fetch(url, {
|
|
299
|
+
...init,
|
|
300
|
+
redirect: "manual",
|
|
301
|
+
signal: controller?.signal || init.signal,
|
|
302
|
+
});
|
|
303
|
+
} finally {
|
|
304
|
+
if (timeout) clearTimeout(timeout);
|
|
305
|
+
}
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
function buildYnabUrl(path) {
|
|
309
|
+
if (!path.startsWith("/") || path.startsWith("//") || /^[a-z][a-z0-9+.-]*:/i.test(path) || /[\r\n]/.test(path)) {
|
|
310
|
+
throw new Error("Refusing unsafe YNAB API path");
|
|
130
311
|
}
|
|
312
|
+
return new URL(`${BASE_URL}${path}`);
|
|
131
313
|
}
|
|
132
314
|
|
|
133
315
|
// Direct API helper for endpoints not yet in the ynab SDK
|
|
134
|
-
const BASE_URL = "https://api.ynab.com/v1";
|
|
135
316
|
async function ynabFetch(path, { method = "GET", body, query } = {}) {
|
|
136
|
-
const url =
|
|
317
|
+
const url = buildYnabUrl(path);
|
|
137
318
|
for (const [key, value] of Object.entries(query || {})) {
|
|
138
319
|
if (value !== undefined && value !== null) url.searchParams.set(key, String(value));
|
|
139
320
|
}
|
|
@@ -142,12 +323,19 @@ async function ynabFetch(path, { method = "GET", body, query } = {}) {
|
|
|
142
323
|
headers: { Authorization: `Bearer ${API_TOKEN}`, "Content-Type": "application/json" },
|
|
143
324
|
};
|
|
144
325
|
if (body) opts.body = JSON.stringify(body);
|
|
145
|
-
const res = await
|
|
326
|
+
const res = await secureFetch(url, opts);
|
|
327
|
+
const contentLength = Number.parseInt(res.headers.get("content-length") || "0", 10);
|
|
328
|
+
if (Number.isFinite(contentLength) && contentLength > MAX_RESPONSE_BYTES) {
|
|
329
|
+
throw new Error(`YNAB response exceeded ${MAX_RESPONSE_BYTES} bytes`);
|
|
330
|
+
}
|
|
146
331
|
const text = await res.text();
|
|
332
|
+
if (Buffer.byteLength(text, "utf8") > MAX_RESPONSE_BYTES) {
|
|
333
|
+
throw new Error(`YNAB response exceeded ${MAX_RESPONSE_BYTES} bytes`);
|
|
334
|
+
}
|
|
147
335
|
const json = text ? JSON.parse(text) : {};
|
|
148
336
|
if (!res.ok) {
|
|
149
|
-
const err = new Error(json?.error?.detail || `HTTP ${res.status}`);
|
|
150
|
-
err.error = json?.error;
|
|
337
|
+
const err = new Error(sanitizeErrorMessage(json?.error?.detail || `HTTP ${res.status}`));
|
|
338
|
+
err.error = json?.error ? { ...json.error, detail: sanitizeErrorMessage(json.error.detail) } : undefined;
|
|
151
339
|
throw err;
|
|
152
340
|
}
|
|
153
341
|
return json.data;
|
|
@@ -157,9 +345,87 @@ async function ynabFetch(path, { method = "GET", body, query } = {}) {
|
|
|
157
345
|
|
|
158
346
|
const server = new McpServer({
|
|
159
347
|
name: "ynab-mcp-server",
|
|
160
|
-
version: "1.
|
|
348
|
+
version: "2.1.0",
|
|
161
349
|
});
|
|
162
350
|
|
|
351
|
+
const WRITE_TOOL_METADATA = {
|
|
352
|
+
create_account: { destructiveHint: false, idempotentHint: false },
|
|
353
|
+
update_month_category: { destructiveHint: false, idempotentHint: true },
|
|
354
|
+
update_category: { destructiveHint: false, idempotentHint: true },
|
|
355
|
+
create_category: { destructiveHint: false, idempotentHint: false },
|
|
356
|
+
create_category_group: { destructiveHint: false, idempotentHint: false },
|
|
357
|
+
update_category_group: { destructiveHint: false, idempotentHint: true },
|
|
358
|
+
update_payee: { destructiveHint: false, idempotentHint: true },
|
|
359
|
+
create_payee: { destructiveHint: false, idempotentHint: false },
|
|
360
|
+
create_transaction: { destructiveHint: false, idempotentHint: false },
|
|
361
|
+
create_transactions: { destructiveHint: false, idempotentHint: false },
|
|
362
|
+
update_transaction: { destructiveHint: false, idempotentHint: true },
|
|
363
|
+
delete_transaction: { destructiveHint: true, idempotentHint: true },
|
|
364
|
+
update_transactions: { destructiveHint: false, idempotentHint: true },
|
|
365
|
+
approve_transactions: { destructiveHint: false, idempotentHint: true },
|
|
366
|
+
reassign_payee_transactions: { destructiveHint: false, idempotentHint: true },
|
|
367
|
+
import_transactions: { destructiveHint: false, idempotentHint: false },
|
|
368
|
+
create_scheduled_transaction: { destructiveHint: false, idempotentHint: false },
|
|
369
|
+
update_scheduled_transaction: { destructiveHint: false, idempotentHint: true },
|
|
370
|
+
delete_scheduled_transaction: { destructiveHint: true, idempotentHint: true },
|
|
371
|
+
};
|
|
372
|
+
|
|
373
|
+
function writesEnabled() {
|
|
374
|
+
return process.env.YNAB_ALLOW_WRITES === "1";
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
function writeDisabledResult(name) {
|
|
378
|
+
return {
|
|
379
|
+
content: [{
|
|
380
|
+
type: "text",
|
|
381
|
+
text: `Error: ${name} is disabled. Restart the MCP server with YNAB_ALLOW_WRITES=1 to enable write tools.`,
|
|
382
|
+
}],
|
|
383
|
+
isError: true,
|
|
384
|
+
};
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
function withWriteGateDescription(description = "") {
|
|
388
|
+
if (description.includes("YNAB_ALLOW_WRITES=1")) return description;
|
|
389
|
+
return `${description} Requires YNAB_ALLOW_WRITES=1; write tools are not registered by default.`;
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
const registerRawTool = server.registerTool.bind(server);
|
|
393
|
+
server.registerTool = (name, config, handler) => {
|
|
394
|
+
const writeMetadata = WRITE_TOOL_METADATA[name];
|
|
395
|
+
if (!writeMetadata) {
|
|
396
|
+
return registerRawTool(name, {
|
|
397
|
+
...config,
|
|
398
|
+
annotations: {
|
|
399
|
+
...config.annotations,
|
|
400
|
+
readOnlyHint: true,
|
|
401
|
+
destructiveHint: false,
|
|
402
|
+
openWorldHint: true,
|
|
403
|
+
},
|
|
404
|
+
}, handler);
|
|
405
|
+
}
|
|
406
|
+
|
|
407
|
+
if (!writesEnabled()) {
|
|
408
|
+
return undefined;
|
|
409
|
+
}
|
|
410
|
+
|
|
411
|
+
return registerRawTool(name, {
|
|
412
|
+
...config,
|
|
413
|
+
description: withWriteGateDescription(config.description),
|
|
414
|
+
annotations: {
|
|
415
|
+
...config.annotations,
|
|
416
|
+
readOnlyHint: false,
|
|
417
|
+
destructiveHint: writeMetadata.destructiveHint,
|
|
418
|
+
idempotentHint: writeMetadata.idempotentHint,
|
|
419
|
+
openWorldHint: true,
|
|
420
|
+
},
|
|
421
|
+
}, (args, extra) => {
|
|
422
|
+
if (!writesEnabled()) {
|
|
423
|
+
return writeDisabledResult(name);
|
|
424
|
+
}
|
|
425
|
+
return handler(args, extra);
|
|
426
|
+
});
|
|
427
|
+
};
|
|
428
|
+
|
|
163
429
|
// ==================== User & Budgets ====================
|
|
164
430
|
|
|
165
431
|
server.registerTool(
|
|
@@ -793,7 +1059,7 @@ function formatTransaction(t) {
|
|
|
793
1059
|
|
|
794
1060
|
server.registerTool(
|
|
795
1061
|
"get_transactions",
|
|
796
|
-
{ description: "Get transactions with optional filters. Use type='unapproved' or type='uncategorized' to filter. Optionally filter by account, category, payee, or month.", inputSchema: {
|
|
1062
|
+
{ description: "Get transactions with optional filters. Use type='unapproved' or type='uncategorized' to filter. Optionally filter by account, category, payee, or month. Each returned transaction includes 'import_payee_name_original' — the raw merchant string from the bank import (e.g. 'AplPay LS ONION RIVEMONTPELIER VT') — which encodes processor flag, merchant name (often longer than the cleaned payee_name), and city+state. This is the primary disambiguation field when payee_name is truncated or ambiguous. Note: large date ranges (6+ months on a busy budget) can return 50KB+ of data; narrow with categoryId/payeeId/month filters when possible.", inputSchema: {
|
|
797
1063
|
budgetId: z.string().optional().describe("Budget ID (uses default if not provided)"),
|
|
798
1064
|
sinceDate: z.string().optional().describe("Only return transactions on or after this date (YYYY-MM-DD)"),
|
|
799
1065
|
type: z.enum(["unapproved", "uncategorized"]).optional().describe("Filter by approval/categorization status"),
|
|
@@ -836,14 +1102,41 @@ server.registerTool(
|
|
|
836
1102
|
|
|
837
1103
|
server.registerTool(
|
|
838
1104
|
"get_transaction",
|
|
839
|
-
{ description: "Get a single transaction by ID. Automatically handles composite scheduled-transaction IDs (e.g. uuid_YYYY-MM-DD).", inputSchema: {
|
|
1105
|
+
{ description: "Get a single transaction by ID. Automatically handles composite scheduled-transaction IDs (e.g. uuid_YYYY-MM-DD): the date suffix is stripped before the lookup. If a composite ID's underlying matched transaction has been deleted, falls back to returning the active scheduled-transaction template wrapped in a marker shape { resource_type: 'scheduled_transaction', reason: 'composite_id_with_no_matched_transaction', scheduled_transaction, requested_id } so callers can distinguish the two return shapes. Non-composite IDs preserve strict behavior: a 404 still surfaces as resource_not_found.", inputSchema: {
|
|
840
1106
|
budgetId: z.string().optional().describe("Budget ID (uses default if not provided)"),
|
|
841
1107
|
transactionId: z.string().describe("Transaction ID"),
|
|
842
1108
|
} },
|
|
843
1109
|
({ budgetId, transactionId }) =>
|
|
844
1110
|
run(async () => {
|
|
845
|
-
const
|
|
846
|
-
|
|
1111
|
+
const bid = resolveBudgetId(budgetId);
|
|
1112
|
+
const normalizedId = normalizeTransactionId(transactionId);
|
|
1113
|
+
const isComposite = /_\d{4}-\d{2}-\d{2}$/.test(transactionId);
|
|
1114
|
+
try {
|
|
1115
|
+
const { data } = await api.transactions.getTransactionById(bid, normalizedId);
|
|
1116
|
+
return ok(formatTransaction(data.transaction));
|
|
1117
|
+
} catch (e) {
|
|
1118
|
+
// Only fall back for composite IDs on resource_not_found. Other errors
|
|
1119
|
+
// (auth, rate limit, network) and non-composite not-founds bubble up unchanged.
|
|
1120
|
+
if (!isComposite || e?.error?.name !== "resource_not_found") throw e;
|
|
1121
|
+
try {
|
|
1122
|
+
const { data } = await api.scheduledTransactions.getScheduledTransactionById(bid, normalizedId);
|
|
1123
|
+
return ok({
|
|
1124
|
+
resource_type: "scheduled_transaction",
|
|
1125
|
+
reason: "composite_id_with_no_matched_transaction",
|
|
1126
|
+
scheduled_transaction: formatScheduledTransaction(data.scheduled_transaction),
|
|
1127
|
+
requested_id: transactionId,
|
|
1128
|
+
});
|
|
1129
|
+
} catch (e2) {
|
|
1130
|
+
if (e2?.error?.name !== "resource_not_found") throw e2;
|
|
1131
|
+
throw {
|
|
1132
|
+
error: {
|
|
1133
|
+
id: "404",
|
|
1134
|
+
name: "resource_not_found",
|
|
1135
|
+
detail: `Resource not found (tried transaction ${normalizedId} and scheduled transaction ${normalizedId}; both returned not-found)`,
|
|
1136
|
+
},
|
|
1137
|
+
};
|
|
1138
|
+
}
|
|
1139
|
+
}
|
|
847
1140
|
})
|
|
848
1141
|
);
|
|
849
1142
|
|
|
@@ -956,7 +1249,7 @@ server.registerTool(
|
|
|
956
1249
|
|
|
957
1250
|
server.registerTool(
|
|
958
1251
|
"update_transactions",
|
|
959
|
-
{ description: "Batch update multiple transactions. Each transaction object must include its id and the fields to update.", inputSchema: {
|
|
1252
|
+
{ description: "Batch update multiple transactions. Each transaction object must include its id and the fields to update. IMPORTANT: only use transaction IDs extracted from get_transactions / review_unapproved results — never compose IDs by hand (fabricated IDs return 'transaction does not exist in this budget' errors). For combined category+approval changes, include both 'categoryId' and 'approved: true' in the same entry. This tool refetches each transaction after the bulk update, verifies requested fields actually persisted, and retries mismatches once through single-transaction updates. Never trust review_unapproved counts alone after approving transactions; use this response's verification block or get_transaction to confirm fields.", inputSchema: {
|
|
960
1253
|
budgetId: z.string().optional().describe("Budget ID (uses default if not provided)"),
|
|
961
1254
|
transactions: z
|
|
962
1255
|
.array(
|
|
@@ -975,16 +1268,121 @@ server.registerTool(
|
|
|
975
1268
|
})
|
|
976
1269
|
)
|
|
977
1270
|
.describe("Array of transaction updates"),
|
|
1271
|
+
returnSummary: z.boolean().optional().describe("If true, return compact counts (updated_count, approved_count, and verification counts) instead of the full updated-transaction objects. Use for large batches (~50+) whose full response would exceed the inline tool-result limit; the write is performed identically either way."),
|
|
978
1272
|
} },
|
|
979
|
-
({ budgetId, transactions: txns }) =>
|
|
1273
|
+
({ budgetId, transactions: txns, returnSummary }) =>
|
|
980
1274
|
run(async () => {
|
|
1275
|
+
const bid = resolveBudgetId(budgetId);
|
|
981
1276
|
const mapped = txns.map((t) => ({ id: t.id, ...mapTransactionUpdate(t) }));
|
|
982
|
-
const { data } = await api.transactions.updateTransactions(
|
|
1277
|
+
const { data } = await api.transactions.updateTransactions(bid, {
|
|
983
1278
|
transactions: mapped,
|
|
984
1279
|
});
|
|
1280
|
+
const { verification, verified } = await verifyBulkTransactionUpdates(bid, txns);
|
|
1281
|
+
if (verification.failed.length > 0) {
|
|
1282
|
+
return {
|
|
1283
|
+
content: [{
|
|
1284
|
+
type: "text",
|
|
1285
|
+
text: `Error: Bulk transaction update verification failed after retry: ${JSON.stringify(verification.failed, null, 2)}`,
|
|
1286
|
+
}],
|
|
1287
|
+
isError: true,
|
|
1288
|
+
};
|
|
1289
|
+
}
|
|
1290
|
+
if (returnSummary) {
|
|
1291
|
+
return ok({
|
|
1292
|
+
updated_count: verified.length,
|
|
1293
|
+
approved_count: verified.filter((t) => t.approved).length,
|
|
1294
|
+
duplicate_import_ids: data.duplicate_import_ids,
|
|
1295
|
+
verification: {
|
|
1296
|
+
checked: verification.checked,
|
|
1297
|
+
retried: verification.retried.length,
|
|
1298
|
+
failed: verification.failed.length,
|
|
1299
|
+
},
|
|
1300
|
+
});
|
|
1301
|
+
}
|
|
985
1302
|
return ok({
|
|
986
|
-
updated:
|
|
1303
|
+
updated: verified,
|
|
987
1304
|
duplicate_import_ids: data.duplicate_import_ids,
|
|
1305
|
+
verification,
|
|
1306
|
+
});
|
|
1307
|
+
})
|
|
1308
|
+
);
|
|
1309
|
+
|
|
1310
|
+
server.registerTool(
|
|
1311
|
+
"approve_transactions",
|
|
1312
|
+
{ description: "Approve unapproved transactions in bulk by filter, without hand-listing IDs. Fetches the current unapproved queue, optionally narrows by payeeId / categoryId / accountId, and sets approved:true on the matches. By default SKIPS uncategorized transactions (no category and not a transfer) so nothing is approved without a category; set includeUncategorized:true to override. Returns a compact summary (approved_count + verification counts), never full objects, so it is safe on large batches. The CALLER is responsible for getting user confirmation before invoking — this tool does not prompt.", inputSchema: {
|
|
1313
|
+
budgetId: z.string().optional().describe("Budget ID (uses default if not provided)"),
|
|
1314
|
+
payeeId: z.string().optional().describe("Only approve unapproved transactions for this payee"),
|
|
1315
|
+
categoryId: z.string().optional().describe("Only approve unapproved transactions in this category"),
|
|
1316
|
+
accountId: z.string().optional().describe("Only approve unapproved transactions in this account"),
|
|
1317
|
+
includeUncategorized: z.boolean().optional().describe("If true, also approve transactions with no category (default false — uncategorized are skipped for safety)"),
|
|
1318
|
+
} },
|
|
1319
|
+
({ budgetId, payeeId, categoryId, accountId, includeUncategorized }) =>
|
|
1320
|
+
run(async () => {
|
|
1321
|
+
const bid = resolveBudgetId(budgetId);
|
|
1322
|
+
const { data } = await api.transactions.getTransactions(bid, undefined, "unapproved");
|
|
1323
|
+
let txns = data.transactions.filter((t) => !t.deleted);
|
|
1324
|
+
if (payeeId) txns = txns.filter((t) => t.payee_id === payeeId);
|
|
1325
|
+
if (categoryId) txns = txns.filter((t) => t.category_id === categoryId);
|
|
1326
|
+
if (accountId) txns = txns.filter((t) => t.account_id === accountId);
|
|
1327
|
+
if (!includeUncategorized) {
|
|
1328
|
+
txns = txns.filter((t) => (t.category_id && t.category_name !== "Uncategorized") || t.transfer_account_id);
|
|
1329
|
+
}
|
|
1330
|
+
if (txns.length === 0) {
|
|
1331
|
+
return ok({ approved_count: 0, matched: 0, message: "No matching unapproved transactions to approve." });
|
|
1332
|
+
}
|
|
1333
|
+
const updates = txns.map((t) => ({ id: t.id, approved: true }));
|
|
1334
|
+
const mapped = updates.map((t) => ({ id: t.id, ...mapTransactionUpdate(t) }));
|
|
1335
|
+
const { data: updData } = await api.transactions.updateTransactions(bid, { transactions: mapped });
|
|
1336
|
+
const { verification, verified } = await verifyBulkTransactionUpdates(bid, updates);
|
|
1337
|
+
if (verification.failed.length > 0) {
|
|
1338
|
+
return {
|
|
1339
|
+
content: [{ type: "text", text: `Error: approval verification failed after retry: ${JSON.stringify(verification.failed, null, 2)}` }],
|
|
1340
|
+
isError: true,
|
|
1341
|
+
};
|
|
1342
|
+
}
|
|
1343
|
+
return ok({
|
|
1344
|
+
matched: txns.length,
|
|
1345
|
+
approved_count: verified.filter((t) => t.approved).length,
|
|
1346
|
+
filters: { payeeId: payeeId || null, categoryId: categoryId || null, accountId: accountId || null, includeUncategorized: !!includeUncategorized },
|
|
1347
|
+
duplicate_import_ids: updData.duplicate_import_ids,
|
|
1348
|
+
verification: { checked: verification.checked, retried: verification.retried.length, failed: verification.failed.length },
|
|
1349
|
+
});
|
|
1350
|
+
})
|
|
1351
|
+
);
|
|
1352
|
+
|
|
1353
|
+
server.registerTool(
|
|
1354
|
+
"reassign_payee_transactions",
|
|
1355
|
+
{ description: "Move all transactions from one payee to another. The YNAB API has no payee-merge or payee-delete endpoint, so this is the merge workaround: refetch every transaction for fromPayeeId and set payee_id = toPayeeId. Use to consolidate a duplicate payee that a slightly different bank-import string created (e.g. fold 'Myles Court Barber' into the existing 'Myles Court Barbershop'). The emptied source payee still exists afterward and must be deleted manually in the YNAB UI (Settings → Manage Payees) if wanted. Returns a compact summary.", inputSchema: {
|
|
1356
|
+
budgetId: z.string().optional().describe("Budget ID (uses default if not provided)"),
|
|
1357
|
+
fromPayeeId: z.string().describe("Payee whose transactions will be moved"),
|
|
1358
|
+
toPayeeId: z.string().describe("Destination payee that transactions will be reassigned to"),
|
|
1359
|
+
sinceDate: z.string().optional().describe("Only move transactions on or after this date (YYYY-MM-DD); omit to move all history"),
|
|
1360
|
+
} },
|
|
1361
|
+
({ budgetId, fromPayeeId, toPayeeId, sinceDate }) =>
|
|
1362
|
+
run(async () => {
|
|
1363
|
+
const bid = resolveBudgetId(budgetId);
|
|
1364
|
+
const { data } = await api.transactions.getTransactionsByPayee(bid, fromPayeeId, sinceDate);
|
|
1365
|
+
const txns = data.transactions.filter((t) => !t.deleted);
|
|
1366
|
+
if (txns.length === 0) {
|
|
1367
|
+
return ok({ reassigned_count: 0, message: "No transactions found for fromPayeeId in the given range." });
|
|
1368
|
+
}
|
|
1369
|
+
const updates = txns.map((t) => ({ id: t.id, payeeId: toPayeeId }));
|
|
1370
|
+
const mapped = updates.map((t) => ({ id: t.id, ...mapTransactionUpdate(t) }));
|
|
1371
|
+
const { data: updData } = await api.transactions.updateTransactions(bid, { transactions: mapped });
|
|
1372
|
+
const { verification, verified } = await verifyBulkTransactionUpdates(bid, updates);
|
|
1373
|
+
if (verification.failed.length > 0) {
|
|
1374
|
+
return {
|
|
1375
|
+
content: [{ type: "text", text: `Error: payee reassignment verification failed after retry: ${JSON.stringify(verification.failed, null, 2)}` }],
|
|
1376
|
+
isError: true,
|
|
1377
|
+
};
|
|
1378
|
+
}
|
|
1379
|
+
return ok({
|
|
1380
|
+
reassigned_count: verified.length,
|
|
1381
|
+
from_payee_id: fromPayeeId,
|
|
1382
|
+
to_payee_id: toPayeeId,
|
|
1383
|
+
duplicate_import_ids: updData.duplicate_import_ids,
|
|
1384
|
+
note: "Source payee is now empty but still exists; delete it in the YNAB UI (Settings → Manage Payees) if desired.",
|
|
1385
|
+
verification: { checked: verification.checked, retried: verification.retried.length, failed: verification.failed.length },
|
|
988
1386
|
});
|
|
989
1387
|
})
|
|
990
1388
|
);
|
|
@@ -1211,8 +1609,12 @@ server.registerTool(
|
|
|
1211
1609
|
|
|
1212
1610
|
server.registerTool(
|
|
1213
1611
|
"review_unapproved",
|
|
1214
|
-
{ description: "Get all unapproved transactions grouped by status: those already categorized (ready to approve) and those still uncategorized (need category first). Each transaction includes a 'flags' array: manually_entered (not bank-imported), match_broken (matched reference
|
|
1215
|
-
|
|
1612
|
+
{ description: "Get all unapproved transactions grouped by status: those already categorized (ready to approve) and those still uncategorized (need category first). Each transaction includes a 'flags' array: manually_entered (not bank-imported), match_broken (matched reference is stale — the `matched_transaction_id` field is read-only via this API; YNAB web/iOS UI is required to clear that link. The transaction itself remains fully mutable: you CAN approve, recategorize, and edit memo via update_transaction. The broken match persists as a cosmetic flag until the user resolves it in the UI.), scheduled_transaction_realized, new_payee (no transaction history for this payee), no_prior_amount_match (novel amount for this payee), category_drift:was_X (payee categorized differently before). Never approve uncategorized transactions without explicit user instruction. For large budgets the full response can exceed 100KB; pass summary:true for counts + by-payee aggregates only, or compact:true to keep per-transaction rows (with IDs) while dropping bulky fields so the response fits inline.", inputSchema: {
|
|
1613
|
+
budgetId: z.string().optional().describe("Budget ID (uses default if not provided)"),
|
|
1614
|
+
summary: z.boolean().optional().describe("If true, omit per-transaction details from the response and return only counts + by-payee aggregates (for both ready_to_approve and needs_category_first). Use this when the full unapproved queue is large; drill into specifics with get_transactions afterwards."),
|
|
1615
|
+
compact: z.boolean().optional().describe("If true (and summary is not set), keep per-transaction detail but return only the fields needed to act — id, date, payee_name, amount, category_name, account_name, flags — dropping bulky fields (import strings, subtransactions, matched/import ids) that push the full response past the inline size limit. Use when you need transaction IDs to approve or recategorize but the full queue would overflow."),
|
|
1616
|
+
} },
|
|
1617
|
+
({ budgetId, summary, compact }) =>
|
|
1216
1618
|
run(async () => {
|
|
1217
1619
|
const bid = resolveBudgetId(budgetId);
|
|
1218
1620
|
|
|
@@ -1273,6 +1675,17 @@ server.registerTool(
|
|
|
1273
1675
|
const categorized = [], uncategorized = [];
|
|
1274
1676
|
for (const t of flaggedTxns) (isCategorized(t) ? categorized : uncategorized).push(t);
|
|
1275
1677
|
|
|
1678
|
+
// Compact projection: only the fields needed to act on a transaction
|
|
1679
|
+
const slimTx = (t) => ({
|
|
1680
|
+
id: t.id,
|
|
1681
|
+
date: t.date,
|
|
1682
|
+
payee_name: t.payee_name,
|
|
1683
|
+
amount: t.amount,
|
|
1684
|
+
category_name: t.category_name,
|
|
1685
|
+
account_name: t.account_name,
|
|
1686
|
+
flags: t.flags,
|
|
1687
|
+
});
|
|
1688
|
+
|
|
1276
1689
|
// Group categorized transactions by payee for easier per-group review
|
|
1277
1690
|
const byPayee = {};
|
|
1278
1691
|
for (const t of categorized) {
|
|
@@ -1283,25 +1696,53 @@ server.registerTool(
|
|
|
1283
1696
|
const groups = Object.values(byPayee).map((g) => {
|
|
1284
1697
|
// Aggregate flags across all transactions in the group (deduplicated)
|
|
1285
1698
|
const allFlags = [...new Set(g.transactions.flatMap((t) => t.flags))];
|
|
1286
|
-
|
|
1287
|
-
|
|
1699
|
+
const base = {
|
|
1700
|
+
payee: g.payee,
|
|
1701
|
+
category_name: g.category_name,
|
|
1288
1702
|
count: g.transactions.length,
|
|
1289
|
-
total: g.transactions.reduce((sum, t) => sum + t.amount, 0),
|
|
1703
|
+
total: round2(g.transactions.reduce((sum, t) => sum + t.amount, 0)),
|
|
1290
1704
|
flags: allFlags,
|
|
1291
1705
|
};
|
|
1706
|
+
return summary ? base : { ...base, transactions: compact ? g.transactions.map(slimTx) : g.transactions };
|
|
1292
1707
|
});
|
|
1293
1708
|
|
|
1709
|
+
// Build uncategorized payload — full transactions by default, by-payee aggregates when summary:true
|
|
1710
|
+
const uncategorizedPayload = (() => {
|
|
1711
|
+
if (!summary) return compact ? uncategorized.map(slimTx) : uncategorized;
|
|
1712
|
+
const byPayeeUncat = {};
|
|
1713
|
+
for (const t of uncategorized) {
|
|
1714
|
+
const key = t.payee_name || "Unknown Payee";
|
|
1715
|
+
if (!byPayeeUncat[key]) byPayeeUncat[key] = { payee_name: key, count: 0, total: 0, flags: new Set() };
|
|
1716
|
+
byPayeeUncat[key].count += 1;
|
|
1717
|
+
byPayeeUncat[key].total += t.amount;
|
|
1718
|
+
for (const f of t.flags) byPayeeUncat[key].flags.add(f);
|
|
1719
|
+
}
|
|
1720
|
+
return Object.values(byPayeeUncat).map((g) => ({
|
|
1721
|
+
payee_name: g.payee_name,
|
|
1722
|
+
count: g.count,
|
|
1723
|
+
total: round2(g.total),
|
|
1724
|
+
flags: [...g.flags],
|
|
1725
|
+
}));
|
|
1726
|
+
})();
|
|
1727
|
+
|
|
1728
|
+
const needsCategoryFirst = {
|
|
1729
|
+
count: uncategorized.length,
|
|
1730
|
+
warning: "Do NOT approve these without assigning a category first",
|
|
1731
|
+
};
|
|
1732
|
+
if (summary) {
|
|
1733
|
+
needsCategoryFirst.payees = uncategorizedPayload;
|
|
1734
|
+
} else {
|
|
1735
|
+
needsCategoryFirst.transactions = uncategorizedPayload;
|
|
1736
|
+
}
|
|
1737
|
+
|
|
1294
1738
|
return ok({
|
|
1295
1739
|
total: flaggedTxns.length,
|
|
1740
|
+
summary: !!summary,
|
|
1296
1741
|
ready_to_approve: {
|
|
1297
1742
|
count: categorized.length,
|
|
1298
1743
|
by_payee: groups,
|
|
1299
1744
|
},
|
|
1300
|
-
needs_category_first:
|
|
1301
|
-
count: uncategorized.length,
|
|
1302
|
-
warning: "Do NOT approve these without assigning a category first",
|
|
1303
|
-
transactions: uncategorized,
|
|
1304
|
-
},
|
|
1745
|
+
needs_category_first: needsCategoryFirst,
|
|
1305
1746
|
});
|
|
1306
1747
|
})
|
|
1307
1748
|
);
|
|
@@ -1329,7 +1770,7 @@ server.registerTool(
|
|
|
1329
1770
|
return ok({
|
|
1330
1771
|
month,
|
|
1331
1772
|
overspent_count: overspent.length,
|
|
1332
|
-
total_overspent: overspent.reduce((sum, c) => sum + c.balance, 0),
|
|
1773
|
+
total_overspent: round2(overspent.reduce((sum, c) => sum + c.balance, 0)),
|
|
1333
1774
|
categories: overspent,
|
|
1334
1775
|
});
|
|
1335
1776
|
})
|