@oliverames/ynab-mcp-server 1.7.1 → 2.1.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.
- package/README.md +136 -33
- package/assets/icon.png +0 -0
- package/docs/hosted-oauth-connector.md +85 -0
- package/index.js +620 -75
- package/package.json +14 -4
- package/scripts/build-mcpb.mjs +133 -0
- package/scripts/check-release-consistency.mjs +100 -0
- package/scripts/lib/smoke-client.mjs +133 -0
- package/scripts/smoke-batch-verify.mjs +81 -0
- package/scripts/smoke-list-tools.mjs +33 -0
- package/scripts/smoke-review-unapproved.mjs +27 -0
- package/scripts/test-safety-model.mjs +140 -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.${
|
|
28
|
-
process.exit(1);
|
|
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} Starting in discovery-only mode.`);
|
|
29
45
|
}
|
|
30
46
|
|
|
31
|
-
const
|
|
47
|
+
const ynabRateLimit = createYnabRateLimiter();
|
|
48
|
+
const effectiveApiToken = API_TOKEN || "missing-token-for-tool-discovery";
|
|
49
|
+
const api = new ynab.API(effectiveApiToken, BASE_URL);
|
|
50
|
+
api._configuration.config = { accessToken: effectiveApiToken, 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,28 +234,108 @@ 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 };
|
|
130
238
|
}
|
|
131
239
|
}
|
|
132
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");
|
|
311
|
+
}
|
|
312
|
+
return new URL(`${BASE_URL}${path}`);
|
|
313
|
+
}
|
|
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
|
}
|
|
140
321
|
const opts = {
|
|
141
322
|
method,
|
|
142
|
-
headers: { Authorization: `Bearer ${
|
|
323
|
+
headers: { Authorization: `Bearer ${effectiveApiToken}`, "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,12 +345,132 @@ 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.1",
|
|
161
349
|
});
|
|
162
350
|
|
|
351
|
+
const registeredTools = new Map();
|
|
352
|
+
const toolCatalog = new Map();
|
|
353
|
+
|
|
354
|
+
function registerTool(name, config, handler) {
|
|
355
|
+
const registration = server.registerTool(name, config, handler);
|
|
356
|
+
toolCatalog.set(name, { config });
|
|
357
|
+
if (registration !== undefined) {
|
|
358
|
+
registeredTools.set(name, { config, handler });
|
|
359
|
+
}
|
|
360
|
+
return registration;
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
function listRegisteredYnabTools() {
|
|
364
|
+
return [...toolCatalog.entries()]
|
|
365
|
+
.filter(([name]) => !name.startsWith("ynab_"))
|
|
366
|
+
.map(([name, { config }]) => {
|
|
367
|
+
const writeMetadata = WRITE_TOOL_METADATA[name];
|
|
368
|
+
const isWrite = !!writeMetadata;
|
|
369
|
+
return {
|
|
370
|
+
name,
|
|
371
|
+
title: config?.title ?? name,
|
|
372
|
+
description: isWrite ? withWriteGateDescription(config?.description ?? "") : config?.description ?? "",
|
|
373
|
+
has_input_schema: !!config?.inputSchema,
|
|
374
|
+
is_write: isWrite,
|
|
375
|
+
registered: registeredTools.has(name),
|
|
376
|
+
status: isWrite && !writesEnabled() ? "hidden_requires_YNAB_ALLOW_WRITES_1" : "available",
|
|
377
|
+
};
|
|
378
|
+
});
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
const WRITE_TOOL_METADATA = {
|
|
382
|
+
create_account: { destructiveHint: false, idempotentHint: false },
|
|
383
|
+
update_month_category: { destructiveHint: false, idempotentHint: true },
|
|
384
|
+
update_category: { destructiveHint: false, idempotentHint: true },
|
|
385
|
+
create_category: { destructiveHint: false, idempotentHint: false },
|
|
386
|
+
create_category_group: { destructiveHint: false, idempotentHint: false },
|
|
387
|
+
update_category_group: { destructiveHint: false, idempotentHint: true },
|
|
388
|
+
update_payee: { destructiveHint: false, idempotentHint: true },
|
|
389
|
+
create_payee: { destructiveHint: false, idempotentHint: false },
|
|
390
|
+
create_transaction: { destructiveHint: false, idempotentHint: false },
|
|
391
|
+
create_transactions: { destructiveHint: false, idempotentHint: false },
|
|
392
|
+
update_transaction: { destructiveHint: false, idempotentHint: true },
|
|
393
|
+
delete_transaction: { destructiveHint: true, idempotentHint: true },
|
|
394
|
+
update_transactions: { destructiveHint: false, idempotentHint: true },
|
|
395
|
+
approve_transactions: { destructiveHint: false, idempotentHint: true },
|
|
396
|
+
reassign_payee_transactions: { destructiveHint: false, idempotentHint: true },
|
|
397
|
+
ynab_write_tool_execute: { destructiveHint: false, idempotentHint: false },
|
|
398
|
+
import_transactions: { destructiveHint: false, idempotentHint: false },
|
|
399
|
+
create_scheduled_transaction: { destructiveHint: false, idempotentHint: false },
|
|
400
|
+
update_scheduled_transaction: { destructiveHint: false, idempotentHint: true },
|
|
401
|
+
delete_scheduled_transaction: { destructiveHint: true, idempotentHint: true },
|
|
402
|
+
};
|
|
403
|
+
|
|
404
|
+
function writesEnabled() {
|
|
405
|
+
return process.env.YNAB_ALLOW_WRITES === "1";
|
|
406
|
+
}
|
|
407
|
+
|
|
408
|
+
function ynabAuthStatus() {
|
|
409
|
+
return {
|
|
410
|
+
authenticated: !!API_TOKEN,
|
|
411
|
+
default_budget_id_configured: !!DEFAULT_BUDGET_ID,
|
|
412
|
+
writes_enabled: writesEnabled(),
|
|
413
|
+
message: API_TOKEN
|
|
414
|
+
? "YNAB MCP server has an API token configured."
|
|
415
|
+
: "YNAB MCP server is running in discovery-only mode. Set YNAB_API_TOKEN, YNAB_API_TOKEN_FILE, or YNAB_OP_PATH, then restart the MCP server before calling API tools.",
|
|
416
|
+
};
|
|
417
|
+
}
|
|
418
|
+
|
|
419
|
+
function writeDisabledResult(name) {
|
|
420
|
+
return {
|
|
421
|
+
content: [{
|
|
422
|
+
type: "text",
|
|
423
|
+
text: `Error: ${name} is disabled. Restart the MCP server with YNAB_ALLOW_WRITES=1 to enable write tools.`,
|
|
424
|
+
}],
|
|
425
|
+
isError: true,
|
|
426
|
+
};
|
|
427
|
+
}
|
|
428
|
+
|
|
429
|
+
function withWriteGateDescription(description = "") {
|
|
430
|
+
if (description.includes("YNAB_ALLOW_WRITES=1")) return description;
|
|
431
|
+
return `${description} Requires YNAB_ALLOW_WRITES=1; write tools are not registered by default.`;
|
|
432
|
+
}
|
|
433
|
+
|
|
434
|
+
const registerRawTool = server.registerTool.bind(server);
|
|
435
|
+
server.registerTool = (name, config, handler) => {
|
|
436
|
+
const writeMetadata = WRITE_TOOL_METADATA[name];
|
|
437
|
+
if (!writeMetadata) {
|
|
438
|
+
return registerRawTool(name, {
|
|
439
|
+
...config,
|
|
440
|
+
annotations: {
|
|
441
|
+
...config.annotations,
|
|
442
|
+
readOnlyHint: true,
|
|
443
|
+
destructiveHint: false,
|
|
444
|
+
openWorldHint: true,
|
|
445
|
+
},
|
|
446
|
+
}, handler);
|
|
447
|
+
}
|
|
448
|
+
|
|
449
|
+
if (!writesEnabled()) {
|
|
450
|
+
return undefined;
|
|
451
|
+
}
|
|
452
|
+
|
|
453
|
+
return registerRawTool(name, {
|
|
454
|
+
...config,
|
|
455
|
+
description: withWriteGateDescription(config.description),
|
|
456
|
+
annotations: {
|
|
457
|
+
...config.annotations,
|
|
458
|
+
readOnlyHint: false,
|
|
459
|
+
destructiveHint: writeMetadata.destructiveHint,
|
|
460
|
+
idempotentHint: writeMetadata.idempotentHint,
|
|
461
|
+
openWorldHint: true,
|
|
462
|
+
},
|
|
463
|
+
}, (args, extra) => {
|
|
464
|
+
if (!writesEnabled()) {
|
|
465
|
+
return writeDisabledResult(name);
|
|
466
|
+
}
|
|
467
|
+
return handler(args, extra);
|
|
468
|
+
});
|
|
469
|
+
};
|
|
470
|
+
|
|
163
471
|
// ==================== User & Budgets ====================
|
|
164
472
|
|
|
165
|
-
|
|
473
|
+
registerTool(
|
|
166
474
|
"get_user",
|
|
167
475
|
{ description: "Get the authenticated user" },
|
|
168
476
|
() =>
|
|
@@ -172,7 +480,7 @@ server.registerTool(
|
|
|
172
480
|
})
|
|
173
481
|
);
|
|
174
482
|
|
|
175
|
-
|
|
483
|
+
registerTool(
|
|
176
484
|
"list_budgets",
|
|
177
485
|
{ description: "List all budgets. Use a budget ID from the results in other tools, or omit budgetId to use the last-used budget." },
|
|
178
486
|
() =>
|
|
@@ -188,7 +496,7 @@ server.registerTool(
|
|
|
188
496
|
})
|
|
189
497
|
);
|
|
190
498
|
|
|
191
|
-
|
|
499
|
+
registerTool(
|
|
192
500
|
"get_budget",
|
|
193
501
|
{ description: "Get a budget summary including name, currency format, and account/category/payee counts", inputSchema: { budgetId: z.string().optional().describe("Budget ID (uses default if not provided)") } },
|
|
194
502
|
({ budgetId }) =>
|
|
@@ -210,7 +518,7 @@ server.registerTool(
|
|
|
210
518
|
})
|
|
211
519
|
);
|
|
212
520
|
|
|
213
|
-
|
|
521
|
+
registerTool(
|
|
214
522
|
"get_budget_settings",
|
|
215
523
|
{ description: "Get budget settings (currency format, date format)", inputSchema: { budgetId: z.string().optional().describe("Budget ID (uses default if not provided)") } },
|
|
216
524
|
({ budgetId }) =>
|
|
@@ -246,7 +554,7 @@ function formatAccount(a) {
|
|
|
246
554
|
return withCurrencyFields(out, a, ["balance", "cleared_balance", "uncleared_balance"]);
|
|
247
555
|
}
|
|
248
556
|
|
|
249
|
-
|
|
557
|
+
registerTool(
|
|
250
558
|
"list_accounts",
|
|
251
559
|
{ description: "List all accounts in a budget", inputSchema: {
|
|
252
560
|
budgetId: z.string().optional().describe("Budget ID (uses default if not provided)"),
|
|
@@ -260,7 +568,7 @@ server.registerTool(
|
|
|
260
568
|
})
|
|
261
569
|
);
|
|
262
570
|
|
|
263
|
-
|
|
571
|
+
registerTool(
|
|
264
572
|
"get_account",
|
|
265
573
|
{ description: "Get details for a specific account", inputSchema: {
|
|
266
574
|
budgetId: z.string().optional().describe("Budget ID (uses default if not provided)"),
|
|
@@ -273,7 +581,7 @@ server.registerTool(
|
|
|
273
581
|
})
|
|
274
582
|
);
|
|
275
583
|
|
|
276
|
-
|
|
584
|
+
registerTool(
|
|
277
585
|
"create_account",
|
|
278
586
|
{ description: "Create a new account", inputSchema: {
|
|
279
587
|
budgetId: z.string().optional().describe("Budget ID (uses default if not provided)"),
|
|
@@ -332,7 +640,7 @@ function formatCategory(c) {
|
|
|
332
640
|
]);
|
|
333
641
|
}
|
|
334
642
|
|
|
335
|
-
|
|
643
|
+
registerTool(
|
|
336
644
|
"list_categories",
|
|
337
645
|
{ description: "List all category groups and their categories", inputSchema: {
|
|
338
646
|
budgetId: z.string().optional().describe("Budget ID (uses default if not provided)"),
|
|
@@ -368,7 +676,7 @@ server.registerTool(
|
|
|
368
676
|
})
|
|
369
677
|
);
|
|
370
678
|
|
|
371
|
-
|
|
679
|
+
registerTool(
|
|
372
680
|
"get_category",
|
|
373
681
|
{ description: "Get a specific category", inputSchema: {
|
|
374
682
|
budgetId: z.string().optional().describe("Budget ID (uses default if not provided)"),
|
|
@@ -381,7 +689,7 @@ server.registerTool(
|
|
|
381
689
|
})
|
|
382
690
|
);
|
|
383
691
|
|
|
384
|
-
|
|
692
|
+
registerTool(
|
|
385
693
|
"get_month_category",
|
|
386
694
|
{ description: "Get category budget for a specific month", inputSchema: {
|
|
387
695
|
budgetId: z.string().optional().describe("Budget ID (uses default if not provided)"),
|
|
@@ -395,7 +703,7 @@ server.registerTool(
|
|
|
395
703
|
})
|
|
396
704
|
);
|
|
397
705
|
|
|
398
|
-
|
|
706
|
+
registerTool(
|
|
399
707
|
"update_month_category",
|
|
400
708
|
{ description: "Set the budgeted amount for a category in a specific month", inputSchema: {
|
|
401
709
|
budgetId: z.string().optional().describe("Budget ID (uses default if not provided)"),
|
|
@@ -412,7 +720,7 @@ server.registerTool(
|
|
|
412
720
|
})
|
|
413
721
|
);
|
|
414
722
|
|
|
415
|
-
|
|
723
|
+
registerTool(
|
|
416
724
|
"update_category",
|
|
417
725
|
{ description: "Update a category's name, note, goal target, or move it to a different group", inputSchema: {
|
|
418
726
|
budgetId: z.string().optional().describe("Budget ID (uses default if not provided)"),
|
|
@@ -442,7 +750,7 @@ server.registerTool(
|
|
|
442
750
|
})
|
|
443
751
|
);
|
|
444
752
|
|
|
445
|
-
|
|
753
|
+
registerTool(
|
|
446
754
|
"create_category",
|
|
447
755
|
{ description: "Create a new category in a category group", inputSchema: {
|
|
448
756
|
budgetId: z.string().optional().describe("Budget ID (uses default if not provided)"),
|
|
@@ -469,7 +777,7 @@ server.registerTool(
|
|
|
469
777
|
})
|
|
470
778
|
);
|
|
471
779
|
|
|
472
|
-
|
|
780
|
+
registerTool(
|
|
473
781
|
"create_category_group",
|
|
474
782
|
{ description: "Create a new category group", inputSchema: {
|
|
475
783
|
budgetId: z.string().optional().describe("Budget ID (uses default if not provided)"),
|
|
@@ -485,7 +793,7 @@ server.registerTool(
|
|
|
485
793
|
})
|
|
486
794
|
);
|
|
487
795
|
|
|
488
|
-
|
|
796
|
+
registerTool(
|
|
489
797
|
"update_category_group",
|
|
490
798
|
{ description: "Rename a category group", inputSchema: {
|
|
491
799
|
budgetId: z.string().optional().describe("Budget ID (uses default if not provided)"),
|
|
@@ -504,7 +812,7 @@ server.registerTool(
|
|
|
504
812
|
|
|
505
813
|
// ==================== Payees ====================
|
|
506
814
|
|
|
507
|
-
|
|
815
|
+
registerTool(
|
|
508
816
|
"list_payees",
|
|
509
817
|
{ description: "List all payees", inputSchema: {
|
|
510
818
|
budgetId: z.string().optional().describe("Budget ID (uses default if not provided)"),
|
|
@@ -518,7 +826,7 @@ server.registerTool(
|
|
|
518
826
|
})
|
|
519
827
|
);
|
|
520
828
|
|
|
521
|
-
|
|
829
|
+
registerTool(
|
|
522
830
|
"get_payee",
|
|
523
831
|
{ description: "Get a specific payee", inputSchema: {
|
|
524
832
|
budgetId: z.string().optional().describe("Budget ID (uses default if not provided)"),
|
|
@@ -531,7 +839,7 @@ server.registerTool(
|
|
|
531
839
|
})
|
|
532
840
|
);
|
|
533
841
|
|
|
534
|
-
|
|
842
|
+
registerTool(
|
|
535
843
|
"update_payee",
|
|
536
844
|
{ description: "Rename a payee", inputSchema: {
|
|
537
845
|
budgetId: z.string().optional().describe("Budget ID (uses default if not provided)"),
|
|
@@ -547,7 +855,7 @@ server.registerTool(
|
|
|
547
855
|
})
|
|
548
856
|
);
|
|
549
857
|
|
|
550
|
-
|
|
858
|
+
registerTool(
|
|
551
859
|
"create_payee",
|
|
552
860
|
{ description: "Create a new payee", inputSchema: {
|
|
553
861
|
budgetId: z.string().optional().describe("Budget ID (uses default if not provided)"),
|
|
@@ -565,7 +873,7 @@ server.registerTool(
|
|
|
565
873
|
|
|
566
874
|
// ==================== Payee Locations ====================
|
|
567
875
|
|
|
568
|
-
|
|
876
|
+
registerTool(
|
|
569
877
|
"list_payee_locations",
|
|
570
878
|
{ description: "List all payee locations (GPS coordinates where transactions occurred)", inputSchema: { budgetId: z.string().optional().describe("Budget ID (uses default if not provided)") } },
|
|
571
879
|
({ budgetId }) =>
|
|
@@ -575,7 +883,7 @@ server.registerTool(
|
|
|
575
883
|
})
|
|
576
884
|
);
|
|
577
885
|
|
|
578
|
-
|
|
886
|
+
registerTool(
|
|
579
887
|
"get_payee_location",
|
|
580
888
|
{ description: "Get a specific payee location", inputSchema: {
|
|
581
889
|
budgetId: z.string().optional().describe("Budget ID (uses default if not provided)"),
|
|
@@ -588,7 +896,7 @@ server.registerTool(
|
|
|
588
896
|
})
|
|
589
897
|
);
|
|
590
898
|
|
|
591
|
-
|
|
899
|
+
registerTool(
|
|
592
900
|
"get_payee_locations_by_payee",
|
|
593
901
|
{ description: "Get all locations for a specific payee", inputSchema: {
|
|
594
902
|
budgetId: z.string().optional().describe("Budget ID (uses default if not provided)"),
|
|
@@ -603,7 +911,7 @@ server.registerTool(
|
|
|
603
911
|
|
|
604
912
|
// ==================== Months ====================
|
|
605
913
|
|
|
606
|
-
|
|
914
|
+
registerTool(
|
|
607
915
|
"list_months",
|
|
608
916
|
{ description: "List all budget months", inputSchema: {
|
|
609
917
|
budgetId: z.string().optional().describe("Budget ID (uses default if not provided)"),
|
|
@@ -632,7 +940,7 @@ server.registerTool(
|
|
|
632
940
|
})
|
|
633
941
|
);
|
|
634
942
|
|
|
635
|
-
|
|
943
|
+
registerTool(
|
|
636
944
|
"get_month",
|
|
637
945
|
{ description: "Get budget month detail with per-category breakdown", inputSchema: {
|
|
638
946
|
budgetId: z.string().optional().describe("Budget ID (uses default if not provided)"),
|
|
@@ -696,7 +1004,7 @@ function formatMoneyMovement(m) {
|
|
|
696
1004
|
}, m, ["amount"]);
|
|
697
1005
|
}
|
|
698
1006
|
|
|
699
|
-
|
|
1007
|
+
registerTool(
|
|
700
1008
|
"list_money_movements",
|
|
701
1009
|
{ description: "List all money movements (budget re-allocations between categories)", inputSchema: { budgetId: z.string().optional().describe("Budget ID (uses default if not provided)") } },
|
|
702
1010
|
({ budgetId }) =>
|
|
@@ -706,7 +1014,7 @@ server.registerTool(
|
|
|
706
1014
|
})
|
|
707
1015
|
);
|
|
708
1016
|
|
|
709
|
-
|
|
1017
|
+
registerTool(
|
|
710
1018
|
"get_money_movements_by_month",
|
|
711
1019
|
{ description: "Get money movements for a specific month", inputSchema: {
|
|
712
1020
|
budgetId: z.string().optional().describe("Budget ID (uses default if not provided)"),
|
|
@@ -719,7 +1027,7 @@ server.registerTool(
|
|
|
719
1027
|
})
|
|
720
1028
|
);
|
|
721
1029
|
|
|
722
|
-
|
|
1030
|
+
registerTool(
|
|
723
1031
|
"list_money_movement_groups",
|
|
724
1032
|
{ description: "List all money movement groups (batches of related money movements)", inputSchema: { budgetId: z.string().optional().describe("Budget ID (uses default if not provided)") } },
|
|
725
1033
|
({ budgetId }) =>
|
|
@@ -729,7 +1037,7 @@ server.registerTool(
|
|
|
729
1037
|
})
|
|
730
1038
|
);
|
|
731
1039
|
|
|
732
|
-
|
|
1040
|
+
registerTool(
|
|
733
1041
|
"get_money_movement_groups_by_month",
|
|
734
1042
|
{ description: "Get money movement groups for a specific month", inputSchema: {
|
|
735
1043
|
budgetId: z.string().optional().describe("Budget ID (uses default if not provided)"),
|
|
@@ -791,7 +1099,7 @@ function formatTransaction(t) {
|
|
|
791
1099
|
return withCurrencyFields(out, t, ["amount"]);
|
|
792
1100
|
}
|
|
793
1101
|
|
|
794
|
-
|
|
1102
|
+
registerTool(
|
|
795
1103
|
"get_transactions",
|
|
796
1104
|
{ 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
1105
|
budgetId: z.string().optional().describe("Budget ID (uses default if not provided)"),
|
|
@@ -834,20 +1142,47 @@ server.registerTool(
|
|
|
834
1142
|
})
|
|
835
1143
|
);
|
|
836
1144
|
|
|
837
|
-
|
|
1145
|
+
registerTool(
|
|
838
1146
|
"get_transaction",
|
|
839
|
-
{ description: "Get a single transaction by ID. Automatically handles composite scheduled-transaction IDs (e.g. uuid_YYYY-MM-DD).", inputSchema: {
|
|
1147
|
+
{ 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
1148
|
budgetId: z.string().optional().describe("Budget ID (uses default if not provided)"),
|
|
841
1149
|
transactionId: z.string().describe("Transaction ID"),
|
|
842
1150
|
} },
|
|
843
1151
|
({ budgetId, transactionId }) =>
|
|
844
1152
|
run(async () => {
|
|
845
|
-
const
|
|
846
|
-
|
|
1153
|
+
const bid = resolveBudgetId(budgetId);
|
|
1154
|
+
const normalizedId = normalizeTransactionId(transactionId);
|
|
1155
|
+
const isComposite = /_\d{4}-\d{2}-\d{2}$/.test(transactionId);
|
|
1156
|
+
try {
|
|
1157
|
+
const { data } = await api.transactions.getTransactionById(bid, normalizedId);
|
|
1158
|
+
return ok(formatTransaction(data.transaction));
|
|
1159
|
+
} catch (e) {
|
|
1160
|
+
// Only fall back for composite IDs on resource_not_found. Other errors
|
|
1161
|
+
// (auth, rate limit, network) and non-composite not-founds bubble up unchanged.
|
|
1162
|
+
if (!isComposite || e?.error?.name !== "resource_not_found") throw e;
|
|
1163
|
+
try {
|
|
1164
|
+
const { data } = await api.scheduledTransactions.getScheduledTransactionById(bid, normalizedId);
|
|
1165
|
+
return ok({
|
|
1166
|
+
resource_type: "scheduled_transaction",
|
|
1167
|
+
reason: "composite_id_with_no_matched_transaction",
|
|
1168
|
+
scheduled_transaction: formatScheduledTransaction(data.scheduled_transaction),
|
|
1169
|
+
requested_id: transactionId,
|
|
1170
|
+
});
|
|
1171
|
+
} catch (e2) {
|
|
1172
|
+
if (e2?.error?.name !== "resource_not_found") throw e2;
|
|
1173
|
+
throw {
|
|
1174
|
+
error: {
|
|
1175
|
+
id: "404",
|
|
1176
|
+
name: "resource_not_found",
|
|
1177
|
+
detail: `Resource not found (tried transaction ${normalizedId} and scheduled transaction ${normalizedId}; both returned not-found)`,
|
|
1178
|
+
},
|
|
1179
|
+
};
|
|
1180
|
+
}
|
|
1181
|
+
}
|
|
847
1182
|
})
|
|
848
1183
|
);
|
|
849
1184
|
|
|
850
|
-
|
|
1185
|
+
registerTool(
|
|
851
1186
|
"create_transaction",
|
|
852
1187
|
{ description: "Create a new transaction. Amounts are in dollars (positive for inflows, negative for outflows). Note: future-dated transactions cannot be created here - use create_scheduled_transaction instead.", inputSchema: {
|
|
853
1188
|
budgetId: z.string().optional().describe("Budget ID (uses default if not provided)"),
|
|
@@ -879,7 +1214,7 @@ server.registerTool(
|
|
|
879
1214
|
})
|
|
880
1215
|
);
|
|
881
1216
|
|
|
882
|
-
|
|
1217
|
+
registerTool(
|
|
883
1218
|
"create_transactions",
|
|
884
1219
|
{ description: "Create multiple transactions at once. Amounts are in dollars. Returns created transactions and any duplicate import IDs. Future-dated transactions are not supported - use create_scheduled_transaction instead.", inputSchema: {
|
|
885
1220
|
budgetId: z.string().optional().describe("Budget ID (uses default if not provided)"),
|
|
@@ -916,7 +1251,7 @@ server.registerTool(
|
|
|
916
1251
|
})
|
|
917
1252
|
);
|
|
918
1253
|
|
|
919
|
-
|
|
1254
|
+
registerTool(
|
|
920
1255
|
"update_transaction",
|
|
921
1256
|
{ description: "Update an existing transaction. Only provided fields are changed. Amounts in dollars.", inputSchema: {
|
|
922
1257
|
budgetId: z.string().optional().describe("Budget ID (uses default if not provided)"),
|
|
@@ -941,7 +1276,7 @@ server.registerTool(
|
|
|
941
1276
|
})
|
|
942
1277
|
);
|
|
943
1278
|
|
|
944
|
-
|
|
1279
|
+
registerTool(
|
|
945
1280
|
"delete_transaction",
|
|
946
1281
|
{ description: "Delete a transaction", inputSchema: {
|
|
947
1282
|
budgetId: z.string().optional().describe("Budget ID (uses default if not provided)"),
|
|
@@ -954,9 +1289,9 @@ server.registerTool(
|
|
|
954
1289
|
})
|
|
955
1290
|
);
|
|
956
1291
|
|
|
957
|
-
|
|
1292
|
+
registerTool(
|
|
958
1293
|
"update_transactions",
|
|
959
|
-
{ 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
|
|
1294
|
+
{ 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
1295
|
budgetId: z.string().optional().describe("Budget ID (uses default if not provided)"),
|
|
961
1296
|
transactions: z
|
|
962
1297
|
.array(
|
|
@@ -975,21 +1310,126 @@ server.registerTool(
|
|
|
975
1310
|
})
|
|
976
1311
|
)
|
|
977
1312
|
.describe("Array of transaction updates"),
|
|
1313
|
+
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
1314
|
} },
|
|
979
|
-
({ budgetId, transactions: txns }) =>
|
|
1315
|
+
({ budgetId, transactions: txns, returnSummary }) =>
|
|
980
1316
|
run(async () => {
|
|
1317
|
+
const bid = resolveBudgetId(budgetId);
|
|
981
1318
|
const mapped = txns.map((t) => ({ id: t.id, ...mapTransactionUpdate(t) }));
|
|
982
|
-
const { data } = await api.transactions.updateTransactions(
|
|
1319
|
+
const { data } = await api.transactions.updateTransactions(bid, {
|
|
983
1320
|
transactions: mapped,
|
|
984
1321
|
});
|
|
1322
|
+
const { verification, verified } = await verifyBulkTransactionUpdates(bid, txns);
|
|
1323
|
+
if (verification.failed.length > 0) {
|
|
1324
|
+
return {
|
|
1325
|
+
content: [{
|
|
1326
|
+
type: "text",
|
|
1327
|
+
text: `Error: Bulk transaction update verification failed after retry: ${JSON.stringify(verification.failed, null, 2)}`,
|
|
1328
|
+
}],
|
|
1329
|
+
isError: true,
|
|
1330
|
+
};
|
|
1331
|
+
}
|
|
1332
|
+
if (returnSummary) {
|
|
1333
|
+
return ok({
|
|
1334
|
+
updated_count: verified.length,
|
|
1335
|
+
approved_count: verified.filter((t) => t.approved).length,
|
|
1336
|
+
duplicate_import_ids: data.duplicate_import_ids,
|
|
1337
|
+
verification: {
|
|
1338
|
+
checked: verification.checked,
|
|
1339
|
+
retried: verification.retried.length,
|
|
1340
|
+
failed: verification.failed.length,
|
|
1341
|
+
},
|
|
1342
|
+
});
|
|
1343
|
+
}
|
|
985
1344
|
return ok({
|
|
986
|
-
updated:
|
|
1345
|
+
updated: verified,
|
|
987
1346
|
duplicate_import_ids: data.duplicate_import_ids,
|
|
1347
|
+
verification,
|
|
1348
|
+
});
|
|
1349
|
+
})
|
|
1350
|
+
);
|
|
1351
|
+
|
|
1352
|
+
registerTool(
|
|
1353
|
+
"approve_transactions",
|
|
1354
|
+
{ 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: {
|
|
1355
|
+
budgetId: z.string().optional().describe("Budget ID (uses default if not provided)"),
|
|
1356
|
+
payeeId: z.string().optional().describe("Only approve unapproved transactions for this payee"),
|
|
1357
|
+
categoryId: z.string().optional().describe("Only approve unapproved transactions in this category"),
|
|
1358
|
+
accountId: z.string().optional().describe("Only approve unapproved transactions in this account"),
|
|
1359
|
+
includeUncategorized: z.boolean().optional().describe("If true, also approve transactions with no category (default false — uncategorized are skipped for safety)"),
|
|
1360
|
+
} },
|
|
1361
|
+
({ budgetId, payeeId, categoryId, accountId, includeUncategorized }) =>
|
|
1362
|
+
run(async () => {
|
|
1363
|
+
const bid = resolveBudgetId(budgetId);
|
|
1364
|
+
const { data } = await api.transactions.getTransactions(bid, undefined, "unapproved");
|
|
1365
|
+
let txns = data.transactions.filter((t) => !t.deleted);
|
|
1366
|
+
if (payeeId) txns = txns.filter((t) => t.payee_id === payeeId);
|
|
1367
|
+
if (categoryId) txns = txns.filter((t) => t.category_id === categoryId);
|
|
1368
|
+
if (accountId) txns = txns.filter((t) => t.account_id === accountId);
|
|
1369
|
+
if (!includeUncategorized) {
|
|
1370
|
+
txns = txns.filter((t) => (t.category_id && t.category_name !== "Uncategorized") || t.transfer_account_id);
|
|
1371
|
+
}
|
|
1372
|
+
if (txns.length === 0) {
|
|
1373
|
+
return ok({ approved_count: 0, matched: 0, message: "No matching unapproved transactions to approve." });
|
|
1374
|
+
}
|
|
1375
|
+
const updates = txns.map((t) => ({ id: t.id, approved: true }));
|
|
1376
|
+
const mapped = updates.map((t) => ({ id: t.id, ...mapTransactionUpdate(t) }));
|
|
1377
|
+
const { data: updData } = await api.transactions.updateTransactions(bid, { transactions: mapped });
|
|
1378
|
+
const { verification, verified } = await verifyBulkTransactionUpdates(bid, updates);
|
|
1379
|
+
if (verification.failed.length > 0) {
|
|
1380
|
+
return {
|
|
1381
|
+
content: [{ type: "text", text: `Error: approval verification failed after retry: ${JSON.stringify(verification.failed, null, 2)}` }],
|
|
1382
|
+
isError: true,
|
|
1383
|
+
};
|
|
1384
|
+
}
|
|
1385
|
+
return ok({
|
|
1386
|
+
matched: txns.length,
|
|
1387
|
+
approved_count: verified.filter((t) => t.approved).length,
|
|
1388
|
+
filters: { payeeId: payeeId || null, categoryId: categoryId || null, accountId: accountId || null, includeUncategorized: !!includeUncategorized },
|
|
1389
|
+
duplicate_import_ids: updData.duplicate_import_ids,
|
|
1390
|
+
verification: { checked: verification.checked, retried: verification.retried.length, failed: verification.failed.length },
|
|
988
1391
|
});
|
|
989
1392
|
})
|
|
990
1393
|
);
|
|
991
1394
|
|
|
992
|
-
|
|
1395
|
+
registerTool(
|
|
1396
|
+
"reassign_payee_transactions",
|
|
1397
|
+
{ 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: {
|
|
1398
|
+
budgetId: z.string().optional().describe("Budget ID (uses default if not provided)"),
|
|
1399
|
+
fromPayeeId: z.string().describe("Payee whose transactions will be moved"),
|
|
1400
|
+
toPayeeId: z.string().describe("Destination payee that transactions will be reassigned to"),
|
|
1401
|
+
sinceDate: z.string().optional().describe("Only move transactions on or after this date (YYYY-MM-DD); omit to move all history"),
|
|
1402
|
+
} },
|
|
1403
|
+
({ budgetId, fromPayeeId, toPayeeId, sinceDate }) =>
|
|
1404
|
+
run(async () => {
|
|
1405
|
+
const bid = resolveBudgetId(budgetId);
|
|
1406
|
+
const { data } = await api.transactions.getTransactionsByPayee(bid, fromPayeeId, sinceDate);
|
|
1407
|
+
const txns = data.transactions.filter((t) => !t.deleted);
|
|
1408
|
+
if (txns.length === 0) {
|
|
1409
|
+
return ok({ reassigned_count: 0, message: "No transactions found for fromPayeeId in the given range." });
|
|
1410
|
+
}
|
|
1411
|
+
const updates = txns.map((t) => ({ id: t.id, payeeId: toPayeeId }));
|
|
1412
|
+
const mapped = updates.map((t) => ({ id: t.id, ...mapTransactionUpdate(t) }));
|
|
1413
|
+
const { data: updData } = await api.transactions.updateTransactions(bid, { transactions: mapped });
|
|
1414
|
+
const { verification, verified } = await verifyBulkTransactionUpdates(bid, updates);
|
|
1415
|
+
if (verification.failed.length > 0) {
|
|
1416
|
+
return {
|
|
1417
|
+
content: [{ type: "text", text: `Error: payee reassignment verification failed after retry: ${JSON.stringify(verification.failed, null, 2)}` }],
|
|
1418
|
+
isError: true,
|
|
1419
|
+
};
|
|
1420
|
+
}
|
|
1421
|
+
return ok({
|
|
1422
|
+
reassigned_count: verified.length,
|
|
1423
|
+
from_payee_id: fromPayeeId,
|
|
1424
|
+
to_payee_id: toPayeeId,
|
|
1425
|
+
duplicate_import_ids: updData.duplicate_import_ids,
|
|
1426
|
+
note: "Source payee is now empty but still exists; delete it in the YNAB UI (Settings → Manage Payees) if desired.",
|
|
1427
|
+
verification: { checked: verification.checked, retried: verification.retried.length, failed: verification.failed.length },
|
|
1428
|
+
});
|
|
1429
|
+
})
|
|
1430
|
+
);
|
|
1431
|
+
|
|
1432
|
+
registerTool(
|
|
993
1433
|
"import_transactions",
|
|
994
1434
|
{ description: "Trigger import of linked account transactions", inputSchema: { budgetId: z.string().optional().describe("Budget ID (uses default if not provided)") } },
|
|
995
1435
|
({ budgetId }) =>
|
|
@@ -1041,7 +1481,7 @@ function formatScheduledTransaction(t) {
|
|
|
1041
1481
|
return withCurrencyFields(out, t, ["amount"]);
|
|
1042
1482
|
}
|
|
1043
1483
|
|
|
1044
|
-
|
|
1484
|
+
registerTool(
|
|
1045
1485
|
"list_scheduled_transactions",
|
|
1046
1486
|
{ description: "List all scheduled (recurring) transactions. NOTE: only manually-created recurring entries appear here — auto-imported recurring charges (subscriptions, utilities, insurance) are NOT included. Use prior-month transaction history to identify recurring charge timing instead.", inputSchema: {
|
|
1047
1487
|
budgetId: z.string().optional().describe("Budget ID (uses default if not provided)"),
|
|
@@ -1055,7 +1495,7 @@ server.registerTool(
|
|
|
1055
1495
|
})
|
|
1056
1496
|
);
|
|
1057
1497
|
|
|
1058
|
-
|
|
1498
|
+
registerTool(
|
|
1059
1499
|
"get_scheduled_transaction",
|
|
1060
1500
|
{ description: "Get a specific scheduled transaction", inputSchema: {
|
|
1061
1501
|
budgetId: z.string().optional().describe("Budget ID (uses default if not provided)"),
|
|
@@ -1068,7 +1508,7 @@ server.registerTool(
|
|
|
1068
1508
|
})
|
|
1069
1509
|
);
|
|
1070
1510
|
|
|
1071
|
-
|
|
1511
|
+
registerTool(
|
|
1072
1512
|
"create_scheduled_transaction",
|
|
1073
1513
|
{ description: "Create a new scheduled (recurring) transaction", inputSchema: {
|
|
1074
1514
|
budgetId: z.string().optional().describe("Budget ID (uses default if not provided)"),
|
|
@@ -1101,7 +1541,7 @@ server.registerTool(
|
|
|
1101
1541
|
})
|
|
1102
1542
|
);
|
|
1103
1543
|
|
|
1104
|
-
|
|
1544
|
+
registerTool(
|
|
1105
1545
|
"update_scheduled_transaction",
|
|
1106
1546
|
{ description: "Update an existing scheduled transaction. Only provided fields are changed. Amounts in dollars.", inputSchema: {
|
|
1107
1547
|
budgetId: z.string().optional().describe("Budget ID (uses default if not provided)"),
|
|
@@ -1144,7 +1584,7 @@ server.registerTool(
|
|
|
1144
1584
|
})
|
|
1145
1585
|
);
|
|
1146
1586
|
|
|
1147
|
-
|
|
1587
|
+
registerTool(
|
|
1148
1588
|
"delete_scheduled_transaction",
|
|
1149
1589
|
{ description: "Delete a scheduled transaction", inputSchema: {
|
|
1150
1590
|
budgetId: z.string().optional().describe("Budget ID (uses default if not provided)"),
|
|
@@ -1159,7 +1599,7 @@ server.registerTool(
|
|
|
1159
1599
|
|
|
1160
1600
|
// ==================== Convenience Tools ====================
|
|
1161
1601
|
|
|
1162
|
-
|
|
1602
|
+
registerTool(
|
|
1163
1603
|
"search_categories",
|
|
1164
1604
|
{ description: "Search categories by partial name match (case-insensitive). Useful for finding category IDs when you only know part of the name.", inputSchema: {
|
|
1165
1605
|
budgetId: z.string().optional().describe("Budget ID (uses default if not provided)"),
|
|
@@ -1191,7 +1631,7 @@ server.registerTool(
|
|
|
1191
1631
|
})
|
|
1192
1632
|
);
|
|
1193
1633
|
|
|
1194
|
-
|
|
1634
|
+
registerTool(
|
|
1195
1635
|
"search_payees",
|
|
1196
1636
|
{ description: "Search payees by partial name match (case-insensitive). Useful for finding payee IDs.", inputSchema: {
|
|
1197
1637
|
budgetId: z.string().optional().describe("Budget ID (uses default if not provided)"),
|
|
@@ -1209,13 +1649,14 @@ server.registerTool(
|
|
|
1209
1649
|
})
|
|
1210
1650
|
);
|
|
1211
1651
|
|
|
1212
|
-
|
|
1652
|
+
registerTool(
|
|
1213
1653
|
"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 is stale —
|
|
1654
|
+
{ 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: {
|
|
1215
1655
|
budgetId: z.string().optional().describe("Budget ID (uses default if not provided)"),
|
|
1216
1656
|
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."),
|
|
1657
|
+
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."),
|
|
1217
1658
|
} },
|
|
1218
|
-
({ budgetId, summary }) =>
|
|
1659
|
+
({ budgetId, summary, compact }) =>
|
|
1219
1660
|
run(async () => {
|
|
1220
1661
|
const bid = resolveBudgetId(budgetId);
|
|
1221
1662
|
|
|
@@ -1276,6 +1717,17 @@ server.registerTool(
|
|
|
1276
1717
|
const categorized = [], uncategorized = [];
|
|
1277
1718
|
for (const t of flaggedTxns) (isCategorized(t) ? categorized : uncategorized).push(t);
|
|
1278
1719
|
|
|
1720
|
+
// Compact projection: only the fields needed to act on a transaction
|
|
1721
|
+
const slimTx = (t) => ({
|
|
1722
|
+
id: t.id,
|
|
1723
|
+
date: t.date,
|
|
1724
|
+
payee_name: t.payee_name,
|
|
1725
|
+
amount: t.amount,
|
|
1726
|
+
category_name: t.category_name,
|
|
1727
|
+
account_name: t.account_name,
|
|
1728
|
+
flags: t.flags,
|
|
1729
|
+
});
|
|
1730
|
+
|
|
1279
1731
|
// Group categorized transactions by payee for easier per-group review
|
|
1280
1732
|
const byPayee = {};
|
|
1281
1733
|
for (const t of categorized) {
|
|
@@ -1290,15 +1742,15 @@ server.registerTool(
|
|
|
1290
1742
|
payee: g.payee,
|
|
1291
1743
|
category_name: g.category_name,
|
|
1292
1744
|
count: g.transactions.length,
|
|
1293
|
-
total: g.transactions.reduce((sum, t) => sum + t.amount, 0),
|
|
1745
|
+
total: round2(g.transactions.reduce((sum, t) => sum + t.amount, 0)),
|
|
1294
1746
|
flags: allFlags,
|
|
1295
1747
|
};
|
|
1296
|
-
return summary ? base : { ...base, transactions: g.transactions };
|
|
1748
|
+
return summary ? base : { ...base, transactions: compact ? g.transactions.map(slimTx) : g.transactions };
|
|
1297
1749
|
});
|
|
1298
1750
|
|
|
1299
1751
|
// Build uncategorized payload — full transactions by default, by-payee aggregates when summary:true
|
|
1300
1752
|
const uncategorizedPayload = (() => {
|
|
1301
|
-
if (!summary) return uncategorized;
|
|
1753
|
+
if (!summary) return compact ? uncategorized.map(slimTx) : uncategorized;
|
|
1302
1754
|
const byPayeeUncat = {};
|
|
1303
1755
|
for (const t of uncategorized) {
|
|
1304
1756
|
const key = t.payee_name || "Unknown Payee";
|
|
@@ -1310,7 +1762,7 @@ server.registerTool(
|
|
|
1310
1762
|
return Object.values(byPayeeUncat).map((g) => ({
|
|
1311
1763
|
payee_name: g.payee_name,
|
|
1312
1764
|
count: g.count,
|
|
1313
|
-
total: g.total,
|
|
1765
|
+
total: round2(g.total),
|
|
1314
1766
|
flags: [...g.flags],
|
|
1315
1767
|
}));
|
|
1316
1768
|
})();
|
|
@@ -1337,7 +1789,7 @@ server.registerTool(
|
|
|
1337
1789
|
})
|
|
1338
1790
|
);
|
|
1339
1791
|
|
|
1340
|
-
|
|
1792
|
+
registerTool(
|
|
1341
1793
|
"get_overspent_categories",
|
|
1342
1794
|
{ description: "Get all categories with a negative balance for a given month. Use this to find prior-month overspends that are silently reducing the current month's Ready to Assign.", inputSchema: {
|
|
1343
1795
|
budgetId: z.string().optional().describe("Budget ID (uses default if not provided)"),
|
|
@@ -1360,12 +1812,105 @@ server.registerTool(
|
|
|
1360
1812
|
return ok({
|
|
1361
1813
|
month,
|
|
1362
1814
|
overspent_count: overspent.length,
|
|
1363
|
-
total_overspent: overspent.reduce((sum, c) => sum + c.balance, 0),
|
|
1815
|
+
total_overspent: round2(overspent.reduce((sum, c) => sum + c.balance, 0)),
|
|
1364
1816
|
categories: overspent,
|
|
1365
1817
|
});
|
|
1366
1818
|
})
|
|
1367
1819
|
);
|
|
1368
1820
|
|
|
1821
|
+
registerTool(
|
|
1822
|
+
"ynab_auth_status",
|
|
1823
|
+
{
|
|
1824
|
+
title: "YNAB Auth Status",
|
|
1825
|
+
description: "Check whether the YNAB MCP server has credentials configured and whether write tools are enabled.",
|
|
1826
|
+
inputSchema: {},
|
|
1827
|
+
},
|
|
1828
|
+
() => ok(ynabAuthStatus())
|
|
1829
|
+
);
|
|
1830
|
+
|
|
1831
|
+
registerTool(
|
|
1832
|
+
"ynab_tool_index",
|
|
1833
|
+
{
|
|
1834
|
+
title: "YNAB Tool Index",
|
|
1835
|
+
description: "Discover the YNAB MCP server tools. Use this when you need YNAB budgets, accounts, categories, payees, transactions, scheduled transactions, unapproved transaction review, approval, or budget cleanup tools.",
|
|
1836
|
+
inputSchema: {},
|
|
1837
|
+
},
|
|
1838
|
+
() => ok({
|
|
1839
|
+
server: "ynab-mcp-server",
|
|
1840
|
+
package: "@oliverames/ynab-mcp-server",
|
|
1841
|
+
auth: ynabAuthStatus(),
|
|
1842
|
+
writes_enabled: writesEnabled(),
|
|
1843
|
+
tools: listRegisteredYnabTools(),
|
|
1844
|
+
execute_with: "ynab_tool_execute",
|
|
1845
|
+
write_execute_with: writesEnabled() ? "ynab_write_tool_execute" : null,
|
|
1846
|
+
})
|
|
1847
|
+
);
|
|
1848
|
+
|
|
1849
|
+
registerTool(
|
|
1850
|
+
"ynab_tool_execute",
|
|
1851
|
+
{
|
|
1852
|
+
title: "Execute YNAB Tool",
|
|
1853
|
+
description: "Execute an existing read-only YNAB MCP tool by name. Use ynab_tool_index first to discover YNAB tool names, then pass the selected tool_name and its JSON input. Write-capable tools must be called directly or through ynab_write_tool_execute when YNAB_ALLOW_WRITES=1.",
|
|
1854
|
+
inputSchema: {
|
|
1855
|
+
tool_name: z.string().describe("Existing read-only YNAB tool name, such as review_unapproved, get_transactions, list_categories, search_categories, or search_payees."),
|
|
1856
|
+
input: z.record(z.string(), z.any()).optional().describe("JSON input for the selected YNAB tool. Omit or pass an empty object for tools that take no input."),
|
|
1857
|
+
},
|
|
1858
|
+
},
|
|
1859
|
+
async ({ tool_name: toolName, input = {} }) => {
|
|
1860
|
+
if (toolName.startsWith("ynab_")) {
|
|
1861
|
+
return {
|
|
1862
|
+
isError: true,
|
|
1863
|
+
content: [{ type: "text", text: "Refusing to execute YNAB discovery helper tools recursively." }],
|
|
1864
|
+
};
|
|
1865
|
+
}
|
|
1866
|
+
if (WRITE_TOOL_METADATA[toolName]) {
|
|
1867
|
+
return {
|
|
1868
|
+
isError: true,
|
|
1869
|
+
content: [{ type: "text", text: `${toolName} is a write-capable YNAB tool. Set YNAB_ALLOW_WRITES=1 and call it directly, or use ynab_write_tool_execute.` }],
|
|
1870
|
+
};
|
|
1871
|
+
}
|
|
1872
|
+
const tool = registeredTools.get(toolName);
|
|
1873
|
+
if (!tool) {
|
|
1874
|
+
return {
|
|
1875
|
+
isError: true,
|
|
1876
|
+
content: [{ type: "text", text: `Unknown YNAB tool: ${toolName}` }],
|
|
1877
|
+
};
|
|
1878
|
+
}
|
|
1879
|
+
return tool.handler(input);
|
|
1880
|
+
}
|
|
1881
|
+
);
|
|
1882
|
+
|
|
1883
|
+
registerTool(
|
|
1884
|
+
"ynab_write_tool_execute",
|
|
1885
|
+
{
|
|
1886
|
+
title: "Execute YNAB Write Tool",
|
|
1887
|
+
description: "Execute an existing write-capable YNAB MCP tool by name. This tool is registered only when YNAB_ALLOW_WRITES=1 and should be used only after explicit user confirmation.",
|
|
1888
|
+
inputSchema: {
|
|
1889
|
+
tool_name: z.string().describe("Existing write-capable YNAB tool name, such as update_transaction, update_transactions, approve_transactions, create_transaction, or delete_transaction."),
|
|
1890
|
+
input: z.record(z.string(), z.any()).optional().describe("JSON input for the selected YNAB write tool."),
|
|
1891
|
+
},
|
|
1892
|
+
},
|
|
1893
|
+
async ({ tool_name: toolName, input = {} }) => {
|
|
1894
|
+
if (toolName.startsWith("ynab_")) {
|
|
1895
|
+
return {
|
|
1896
|
+
isError: true,
|
|
1897
|
+
content: [{ type: "text", text: "Refusing to execute YNAB discovery helper tools recursively." }],
|
|
1898
|
+
};
|
|
1899
|
+
}
|
|
1900
|
+
if (!WRITE_TOOL_METADATA[toolName]) {
|
|
1901
|
+
return {
|
|
1902
|
+
isError: true,
|
|
1903
|
+
content: [{ type: "text", text: `${toolName} is not a write-capable YNAB tool. Use ynab_tool_execute for read-only tools.` }],
|
|
1904
|
+
};
|
|
1905
|
+
}
|
|
1906
|
+
const tool = registeredTools.get(toolName);
|
|
1907
|
+
if (!tool) {
|
|
1908
|
+
return writeDisabledResult(toolName);
|
|
1909
|
+
}
|
|
1910
|
+
return tool.handler(input);
|
|
1911
|
+
}
|
|
1912
|
+
);
|
|
1913
|
+
|
|
1369
1914
|
// --- Start ---
|
|
1370
1915
|
|
|
1371
1916
|
process.on("uncaughtException", (err) => {
|