@oliverames/ynab-mcp-server 2.1.0 → 3.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +174 -51
- package/docs/privacy.md +51 -0
- package/index.js +663 -117
- package/package.json +13 -6
- package/scripts/build-mcpb.mjs +5 -5
- package/scripts/check-release-consistency.mjs +12 -4
- package/scripts/smoke-list-tools.mjs +6 -1
- package/scripts/test-safety-model.mjs +132 -3
package/index.js
CHANGED
|
@@ -2,6 +2,8 @@
|
|
|
2
2
|
|
|
3
3
|
import { execFileSync } from "node:child_process";
|
|
4
4
|
import { readFileSync } from "node:fs";
|
|
5
|
+
import { homedir } from "node:os";
|
|
6
|
+
import path from "node:path";
|
|
5
7
|
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
6
8
|
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
|
|
7
9
|
import { z } from "zod";
|
|
@@ -13,44 +15,360 @@ const BASE_URL = "https://api.ynab.com/v1";
|
|
|
13
15
|
const YNAB_API_HOST = "api.ynab.com";
|
|
14
16
|
const MAX_TOKEN_FILE_BYTES = 4096;
|
|
15
17
|
const MAX_RESPONSE_BYTES = Number.parseInt(process.env.YNAB_MAX_RESPONSE_BYTES || "8388608", 10);
|
|
18
|
+
const YNAB_RUNTIME_KEYS = [
|
|
19
|
+
"YNAB_API_TOKEN",
|
|
20
|
+
"YNAB_API_TOKEN_FILE",
|
|
21
|
+
"YNAB_OP_PATH",
|
|
22
|
+
"YNAB_BUDGET_ID",
|
|
23
|
+
"YNAB_ALLOW_WRITES",
|
|
24
|
+
];
|
|
16
25
|
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
26
|
+
const runtimeConfig = resolveYnabRuntimeConfig();
|
|
27
|
+
const API_TOKEN = runtimeConfig.apiToken;
|
|
28
|
+
const tokenLookupError = runtimeConfig.tokenLookupError;
|
|
29
|
+
const DEFAULT_BUDGET_ID = runtimeConfig.values.YNAB_BUDGET_ID?.value;
|
|
30
|
+
if (!API_TOKEN) {
|
|
31
|
+
const fallbackMessage = tokenLookupError
|
|
32
|
+
? ` ${tokenLookupError}.`
|
|
33
|
+
: " Add YNAB_API_TOKEN to the agent config file, set YNAB_API_TOKEN_FILE, or set YNAB_OP_PATH.";
|
|
34
|
+
console.error(`YNAB_API_TOKEN is required.${fallbackMessage} Starting MCP Server for YNAB in discovery-only mode.`);
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
const ynabRateLimit = createYnabRateLimiter();
|
|
38
|
+
const effectiveApiToken = API_TOKEN || "missing-token-for-tool-discovery";
|
|
39
|
+
const api = new ynab.API(effectiveApiToken, BASE_URL);
|
|
40
|
+
api._configuration.config = { accessToken: effectiveApiToken, basePath: BASE_URL, fetchApi: secureFetch };
|
|
41
|
+
|
|
42
|
+
// --- Helpers ---
|
|
43
|
+
|
|
44
|
+
function resolveYnabRuntimeConfig() {
|
|
45
|
+
const sources = loadYnabSettingSources();
|
|
46
|
+
const values = {};
|
|
47
|
+
|
|
48
|
+
for (const key of YNAB_RUNTIME_KEYS) {
|
|
49
|
+
const source = sources.find((candidate) => hasNonEmptyString(candidate.values[key]));
|
|
50
|
+
if (source) {
|
|
51
|
+
values[key] = {
|
|
52
|
+
value: source.values[key].trim(),
|
|
53
|
+
source: source.id,
|
|
54
|
+
source_label: source.label,
|
|
55
|
+
path: source.path,
|
|
56
|
+
section: source.section,
|
|
57
|
+
};
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
const lookupErrors = sources
|
|
62
|
+
.flatMap((source) => source.errors || [])
|
|
63
|
+
.filter(Boolean);
|
|
64
|
+
|
|
65
|
+
let apiToken = values.YNAB_API_TOKEN?.value;
|
|
66
|
+
let tokenSource = values.YNAB_API_TOKEN || null;
|
|
67
|
+
|
|
68
|
+
if (!apiToken && values.YNAB_API_TOKEN_FILE?.value) {
|
|
69
|
+
try {
|
|
70
|
+
const tokenFileContents = readFileSync(values.YNAB_API_TOKEN_FILE.value, "utf8");
|
|
71
|
+
if (Buffer.byteLength(tokenFileContents, "utf8") > MAX_TOKEN_FILE_BYTES) {
|
|
72
|
+
throw new Error(`token file exceeds ${MAX_TOKEN_FILE_BYTES} bytes`);
|
|
73
|
+
}
|
|
74
|
+
apiToken = tokenFileContents.trim();
|
|
75
|
+
tokenSource = {
|
|
76
|
+
source: "token_file",
|
|
77
|
+
source_label: "YNAB_API_TOKEN_FILE",
|
|
78
|
+
path: values.YNAB_API_TOKEN_FILE.value,
|
|
79
|
+
};
|
|
80
|
+
} catch (e) {
|
|
81
|
+
lookupErrors.push(`Could not read YNAB_API_TOKEN_FILE: ${e.message || String(e)}`);
|
|
24
82
|
}
|
|
25
|
-
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
if (!apiToken && values.YNAB_OP_PATH?.value) {
|
|
86
|
+
try {
|
|
87
|
+
apiToken = execFileSync(
|
|
88
|
+
"op", ["read", values.YNAB_OP_PATH.value],
|
|
89
|
+
{ encoding: "utf-8", timeout: 10000, stdio: ["pipe", "pipe", "pipe"] }
|
|
90
|
+
).trim();
|
|
91
|
+
tokenSource = {
|
|
92
|
+
source: "onepassword_cli",
|
|
93
|
+
source_label: "YNAB_OP_PATH via 1Password CLI",
|
|
94
|
+
};
|
|
95
|
+
} catch (e) {
|
|
96
|
+
lookupErrors.push(`Could not read YNAB_OP_PATH via 1Password CLI: ${e.stderr?.toString().trim() || e.message || "unknown 1Password CLI error"}`);
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
return {
|
|
101
|
+
apiToken,
|
|
102
|
+
tokenSource,
|
|
103
|
+
values,
|
|
104
|
+
sources_checked: sources.map((source) => ({
|
|
105
|
+
id: source.id,
|
|
106
|
+
label: source.label,
|
|
107
|
+
path: source.path,
|
|
108
|
+
section: source.section,
|
|
109
|
+
available: source.available,
|
|
110
|
+
keys_found: source.keysFound,
|
|
111
|
+
})),
|
|
112
|
+
config_fallback_disabled: process.env.YNAB_DISABLE_AGENT_CONFIG_FALLBACK === "1",
|
|
113
|
+
detected_agent: detectAgentRuntime(),
|
|
114
|
+
tokenLookupError: lookupErrors[lookupErrors.length - 1],
|
|
115
|
+
lookup_errors: lookupErrors,
|
|
116
|
+
};
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
function loadYnabSettingSources() {
|
|
120
|
+
const finalize = (sources) => sources.map((source) => ({
|
|
121
|
+
...source,
|
|
122
|
+
keysFound: YNAB_RUNTIME_KEYS.filter((key) => hasNonEmptyString(source.values[key])),
|
|
123
|
+
}));
|
|
124
|
+
|
|
125
|
+
const sources = [{
|
|
126
|
+
id: "process_env",
|
|
127
|
+
label: "process environment",
|
|
128
|
+
path: null,
|
|
129
|
+
section: null,
|
|
130
|
+
available: true,
|
|
131
|
+
values: pickYnabValues(process.env),
|
|
132
|
+
errors: [],
|
|
133
|
+
}];
|
|
134
|
+
|
|
135
|
+
if (process.env.YNAB_DISABLE_AGENT_CONFIG_FALLBACK === "1") {
|
|
136
|
+
return finalize(sources);
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
const codexSources = loadCodexConfigSources();
|
|
140
|
+
const claudeSource = loadClaudeSettingsSource();
|
|
141
|
+
if (detectAgentRuntime() === "claude") {
|
|
142
|
+
sources.push(claudeSource, ...codexSources);
|
|
143
|
+
} else {
|
|
144
|
+
sources.push(...codexSources, claudeSource);
|
|
145
|
+
}
|
|
146
|
+
return finalize(sources);
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
function loadCodexConfigSources() {
|
|
150
|
+
const configPath = path.join(userHomeDir(), ".codex", "config.toml");
|
|
151
|
+
const emptySources = [
|
|
152
|
+
configSource("codex_shell_environment", "Codex shell_environment_policy.set", configPath, "shell_environment_policy.set"),
|
|
153
|
+
configSource("codex_mcp_env", "Codex mcp_servers.ynab.env", configPath, "mcp_servers.ynab.env"),
|
|
154
|
+
];
|
|
155
|
+
|
|
156
|
+
let text;
|
|
157
|
+
try {
|
|
158
|
+
text = readFileSync(configPath, "utf8");
|
|
26
159
|
} catch (e) {
|
|
27
|
-
|
|
160
|
+
return emptySources.map((source) => ({
|
|
161
|
+
...source,
|
|
162
|
+
available: false,
|
|
163
|
+
errors: isMissingFileError(e) ? [] : [`Could not read ${configPath}: ${e.message || String(e)}`],
|
|
164
|
+
}));
|
|
28
165
|
}
|
|
166
|
+
|
|
167
|
+
const sections = parseSimpleTomlSections(text);
|
|
168
|
+
return emptySources.map((source) => ({
|
|
169
|
+
...source,
|
|
170
|
+
available: true,
|
|
171
|
+
values: pickYnabValues(sections[source.section] || {}),
|
|
172
|
+
errors: [],
|
|
173
|
+
}));
|
|
29
174
|
}
|
|
30
|
-
|
|
175
|
+
|
|
176
|
+
function loadClaudeSettingsSource() {
|
|
177
|
+
const settingsPath = path.join(userHomeDir(), ".claude", "settings.json");
|
|
178
|
+
const source = configSource("claude_settings_env", "Claude settings.json env", settingsPath, "env");
|
|
179
|
+
|
|
180
|
+
let settings;
|
|
31
181
|
try {
|
|
32
|
-
|
|
33
|
-
"op", ["read", process.env.YNAB_OP_PATH],
|
|
34
|
-
{ encoding: "utf-8", timeout: 10000, stdio: ["pipe", "pipe", "pipe"] }
|
|
35
|
-
).trim();
|
|
182
|
+
settings = JSON.parse(readFileSync(settingsPath, "utf8"));
|
|
36
183
|
} catch (e) {
|
|
37
|
-
|
|
184
|
+
return {
|
|
185
|
+
...source,
|
|
186
|
+
available: false,
|
|
187
|
+
errors: isMissingFileError(e) ? [] : [`Could not read ${settingsPath}: ${e.message || String(e)}`],
|
|
188
|
+
};
|
|
38
189
|
}
|
|
190
|
+
|
|
191
|
+
return {
|
|
192
|
+
...source,
|
|
193
|
+
available: true,
|
|
194
|
+
values: pickYnabValues(settings?.env || {}),
|
|
195
|
+
errors: [],
|
|
196
|
+
};
|
|
39
197
|
}
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
198
|
+
|
|
199
|
+
function configSource(id, label, filePath, section) {
|
|
200
|
+
return {
|
|
201
|
+
id,
|
|
202
|
+
label,
|
|
203
|
+
path: filePath,
|
|
204
|
+
section,
|
|
205
|
+
available: false,
|
|
206
|
+
values: {},
|
|
207
|
+
errors: [],
|
|
208
|
+
};
|
|
46
209
|
}
|
|
47
210
|
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
211
|
+
function pickYnabValues(source) {
|
|
212
|
+
return Object.fromEntries(
|
|
213
|
+
YNAB_RUNTIME_KEYS
|
|
214
|
+
.filter((key) => hasNonEmptyString(source?.[key]))
|
|
215
|
+
.map((key) => [key, String(source[key])])
|
|
216
|
+
);
|
|
217
|
+
}
|
|
52
218
|
|
|
53
|
-
|
|
219
|
+
function parseSimpleTomlSections(text) {
|
|
220
|
+
const sections = {};
|
|
221
|
+
let currentSection = "";
|
|
222
|
+
|
|
223
|
+
for (const rawLine of text.split(/\r?\n/)) {
|
|
224
|
+
const line = rawLine.trim();
|
|
225
|
+
if (!line || line.startsWith("#")) continue;
|
|
226
|
+
|
|
227
|
+
const sectionMatch = line.match(/^\[([^\]]+)\]$/);
|
|
228
|
+
if (sectionMatch) {
|
|
229
|
+
currentSection = sectionMatch[1].trim();
|
|
230
|
+
sections[currentSection] ||= {};
|
|
231
|
+
continue;
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
const assignmentMatch = line.match(/^([A-Za-z0-9_]+)\s*=\s*(.+)$/);
|
|
235
|
+
if (!assignmentMatch || !currentSection) continue;
|
|
236
|
+
|
|
237
|
+
const [, key, rawValue] = assignmentMatch;
|
|
238
|
+
const value = parseSimpleTomlString(rawValue);
|
|
239
|
+
if (value !== undefined) {
|
|
240
|
+
sections[currentSection] ||= {};
|
|
241
|
+
sections[currentSection][key] = value;
|
|
242
|
+
}
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
return sections;
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
function parseSimpleTomlString(rawValue) {
|
|
249
|
+
const value = stripTomlComment(rawValue).trim();
|
|
250
|
+
if (!value) return undefined;
|
|
251
|
+
if (value.startsWith("\"") && value.endsWith("\"")) {
|
|
252
|
+
try {
|
|
253
|
+
return JSON.parse(value);
|
|
254
|
+
} catch {
|
|
255
|
+
return value.slice(1, -1);
|
|
256
|
+
}
|
|
257
|
+
}
|
|
258
|
+
if (value.startsWith("'") && value.endsWith("'")) {
|
|
259
|
+
return value.slice(1, -1);
|
|
260
|
+
}
|
|
261
|
+
return value;
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
function stripTomlComment(value) {
|
|
265
|
+
let quote = null;
|
|
266
|
+
let escaped = false;
|
|
267
|
+
for (let i = 0; i < value.length; i += 1) {
|
|
268
|
+
const char = value[i];
|
|
269
|
+
if (escaped) {
|
|
270
|
+
escaped = false;
|
|
271
|
+
continue;
|
|
272
|
+
}
|
|
273
|
+
if (char === "\\") {
|
|
274
|
+
escaped = true;
|
|
275
|
+
continue;
|
|
276
|
+
}
|
|
277
|
+
if (quote) {
|
|
278
|
+
if (char === quote) quote = null;
|
|
279
|
+
continue;
|
|
280
|
+
}
|
|
281
|
+
if (char === "\"" || char === "'") {
|
|
282
|
+
quote = char;
|
|
283
|
+
continue;
|
|
284
|
+
}
|
|
285
|
+
if (char === "#") {
|
|
286
|
+
return value.slice(0, i);
|
|
287
|
+
}
|
|
288
|
+
}
|
|
289
|
+
return value;
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
function userHomeDir() {
|
|
293
|
+
return process.env.HOME || homedir();
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
function hasNonEmptyString(value) {
|
|
297
|
+
return typeof value === "string" && value.trim().length > 0;
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
function isMissingFileError(error) {
|
|
301
|
+
return error?.code === "ENOENT" || error?.code === "ENOTDIR";
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
function detectAgentRuntime() {
|
|
305
|
+
if (Object.keys(process.env).some((key) => key.startsWith("CODEX_"))) return "codex";
|
|
306
|
+
if (Object.keys(process.env).some((key) => key.startsWith("CLAUDE_"))) return "claude";
|
|
307
|
+
return "unknown";
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
function publicYnabRuntimeSettings() {
|
|
311
|
+
return Object.fromEntries(
|
|
312
|
+
Object.entries(runtimeConfig.values)
|
|
313
|
+
.filter(([key]) => key !== "YNAB_API_TOKEN")
|
|
314
|
+
.map(([key, entry]) => [key, {
|
|
315
|
+
configured: true,
|
|
316
|
+
source: entry.source,
|
|
317
|
+
source_label: entry.source_label,
|
|
318
|
+
}])
|
|
319
|
+
);
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
function ynabAuthSetupGuide() {
|
|
323
|
+
const agent = runtimeConfig.detected_agent;
|
|
324
|
+
const codexPath = path.join(userHomeDir(), ".codex", "config.toml");
|
|
325
|
+
const claudePath = path.join(userHomeDir(), ".claude", "settings.json");
|
|
326
|
+
const shouldShowCodex = agent === "codex" || agent === "unknown";
|
|
327
|
+
const shouldShowClaude = agent === "claude" || agent === "unknown";
|
|
328
|
+
const manualTargets = [];
|
|
329
|
+
const passwordManagerTargets = [];
|
|
330
|
+
|
|
331
|
+
if (shouldShowCodex) {
|
|
332
|
+
manualTargets.push({
|
|
333
|
+
agent: "codex",
|
|
334
|
+
path: codexPath,
|
|
335
|
+
section: "shell_environment_policy.set",
|
|
336
|
+
snippet: "[shell_environment_policy.set]\nYNAB_API_TOKEN = \"your-token-here\"",
|
|
337
|
+
});
|
|
338
|
+
passwordManagerTargets.push({
|
|
339
|
+
agent: "codex",
|
|
340
|
+
path: codexPath,
|
|
341
|
+
section: "shell_environment_policy.set",
|
|
342
|
+
snippet: "[shell_environment_policy.set]\nYNAB_OP_PATH = \"op://Personal/YNAB API Token/credential\"",
|
|
343
|
+
note: "Ask the user for permission before editing this config, and confirm the 1Password item path they want to use.",
|
|
344
|
+
});
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
if (shouldShowClaude) {
|
|
348
|
+
manualTargets.push({
|
|
349
|
+
agent: "claude",
|
|
350
|
+
path: claudePath,
|
|
351
|
+
section: "env",
|
|
352
|
+
snippet: "{\n \"env\": {\n \"YNAB_API_TOKEN\": \"your-token-here\"\n }\n}",
|
|
353
|
+
});
|
|
354
|
+
passwordManagerTargets.push({
|
|
355
|
+
agent: "claude",
|
|
356
|
+
path: claudePath,
|
|
357
|
+
section: "env",
|
|
358
|
+
snippet: "{\n \"env\": {\n \"YNAB_OP_PATH\": \"op://Personal/YNAB API Token/credential\"\n }\n}",
|
|
359
|
+
note: "Ask the user for permission before editing this config, and confirm the 1Password item path they want to use.",
|
|
360
|
+
});
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
return {
|
|
364
|
+
prompt_for_agent: "Ask the user whether they already have a YNAB API key in a password manager such as 1Password. If yes, ask permission to configure YNAB_OP_PATH for this agent. If no, ask them to add YNAB_API_TOKEN to the correct agent config file, then restart the MCP server.",
|
|
365
|
+
detected_agent: agent,
|
|
366
|
+
manual_api_key_targets: manualTargets,
|
|
367
|
+
password_manager_targets: passwordManagerTargets,
|
|
368
|
+
token_file_option: "Alternatively, set YNAB_API_TOKEN_FILE to a small local file containing only the token.",
|
|
369
|
+
restart_required: true,
|
|
370
|
+
};
|
|
371
|
+
}
|
|
54
372
|
|
|
55
373
|
function resolveBudgetId(input) {
|
|
56
374
|
return input || DEFAULT_BUDGET_ID || "last-used";
|
|
@@ -225,7 +543,49 @@ function collection(data, key, items, lastKnowledgeOfServer) {
|
|
|
225
543
|
: { [key]: items, server_knowledge: data.server_knowledge };
|
|
226
544
|
}
|
|
227
545
|
|
|
546
|
+
function pathSegment(value) {
|
|
547
|
+
return encodeURIComponent(String(value));
|
|
548
|
+
}
|
|
549
|
+
|
|
550
|
+
function allHistorySinceDate() {
|
|
551
|
+
return "1970-01-01";
|
|
552
|
+
}
|
|
553
|
+
|
|
554
|
+
function buildTransactionListPath({ budgetId, accountId, categoryId, payeeId, month }) {
|
|
555
|
+
const bid = pathSegment(resolveBudgetId(budgetId));
|
|
556
|
+
if (accountId) return `/plans/${bid}/accounts/${pathSegment(accountId)}/transactions`;
|
|
557
|
+
if (categoryId) return `/plans/${bid}/categories/${pathSegment(categoryId)}/transactions`;
|
|
558
|
+
if (payeeId) return `/plans/${bid}/payees/${pathSegment(payeeId)}/transactions`;
|
|
559
|
+
if (month) return `/plans/${bid}/months/${pathSegment(month)}/transactions`;
|
|
560
|
+
return `/plans/${bid}/transactions`;
|
|
561
|
+
}
|
|
562
|
+
|
|
563
|
+
async function fetchTransactions({
|
|
564
|
+
budgetId,
|
|
565
|
+
sinceDate,
|
|
566
|
+
untilDate,
|
|
567
|
+
type,
|
|
568
|
+
accountId,
|
|
569
|
+
categoryId,
|
|
570
|
+
payeeId,
|
|
571
|
+
month,
|
|
572
|
+
lastKnowledgeOfServer,
|
|
573
|
+
}) {
|
|
574
|
+
return ynabFetch(buildTransactionListPath({ budgetId, accountId, categoryId, payeeId, month }), {
|
|
575
|
+
query: {
|
|
576
|
+
since_date: sinceDate,
|
|
577
|
+
until_date: untilDate,
|
|
578
|
+
type,
|
|
579
|
+
last_knowledge_of_server: lastKnowledgeOfServer,
|
|
580
|
+
},
|
|
581
|
+
});
|
|
582
|
+
}
|
|
583
|
+
|
|
228
584
|
async function run(fn) {
|
|
585
|
+
if (!API_TOKEN) {
|
|
586
|
+
return missingCredentialsResult();
|
|
587
|
+
}
|
|
588
|
+
|
|
229
589
|
try {
|
|
230
590
|
return await fn();
|
|
231
591
|
} catch (e) {
|
|
@@ -238,6 +598,17 @@ async function run(fn) {
|
|
|
238
598
|
}
|
|
239
599
|
}
|
|
240
600
|
|
|
601
|
+
function missingCredentialsResult() {
|
|
602
|
+
return {
|
|
603
|
+
content: [{ type: "text", text: JSON.stringify({
|
|
604
|
+
error: "missing_credentials",
|
|
605
|
+
message: "YNAB API credentials are not configured for this MCP server process.",
|
|
606
|
+
auth: ynabAuthStatus(),
|
|
607
|
+
}, null, 2) }],
|
|
608
|
+
isError: true,
|
|
609
|
+
};
|
|
610
|
+
}
|
|
611
|
+
|
|
241
612
|
function sanitizeErrorMessage(value) {
|
|
242
613
|
let message = String(value ?? "");
|
|
243
614
|
if (API_TOKEN) {
|
|
@@ -320,7 +691,7 @@ async function ynabFetch(path, { method = "GET", body, query } = {}) {
|
|
|
320
691
|
}
|
|
321
692
|
const opts = {
|
|
322
693
|
method,
|
|
323
|
-
headers: { Authorization: `Bearer ${
|
|
694
|
+
headers: { Authorization: `Bearer ${effectiveApiToken}`, "Content-Type": "application/json" },
|
|
324
695
|
};
|
|
325
696
|
if (body) opts.body = JSON.stringify(body);
|
|
326
697
|
const res = await secureFetch(url, opts);
|
|
@@ -344,10 +715,46 @@ async function ynabFetch(path, { method = "GET", body, query } = {}) {
|
|
|
344
715
|
// --- Server ---
|
|
345
716
|
|
|
346
717
|
const server = new McpServer({
|
|
347
|
-
name: "
|
|
348
|
-
version: "
|
|
718
|
+
name: "mcp-server-for-ynab",
|
|
719
|
+
version: "3.0.0",
|
|
349
720
|
});
|
|
350
721
|
|
|
722
|
+
const registeredTools = new Map();
|
|
723
|
+
const toolCatalog = new Map();
|
|
724
|
+
|
|
725
|
+
function registerTool(name, config, handler) {
|
|
726
|
+
const registration = server.registerTool(name, config, handler);
|
|
727
|
+
toolCatalog.set(name, { config });
|
|
728
|
+
if (registration !== undefined) {
|
|
729
|
+
registeredTools.set(name, { config, handler });
|
|
730
|
+
}
|
|
731
|
+
return registration;
|
|
732
|
+
}
|
|
733
|
+
|
|
734
|
+
function listRegisteredYnabTools() {
|
|
735
|
+
return [...toolCatalog.entries()]
|
|
736
|
+
.filter(([name]) => !name.startsWith("ynab_"))
|
|
737
|
+
.map(([name, { config }]) => {
|
|
738
|
+
const writeMetadata = WRITE_TOOL_METADATA[name];
|
|
739
|
+
const isWrite = !!writeMetadata;
|
|
740
|
+
let status = "available";
|
|
741
|
+
if (isWrite && !writesEnabled()) {
|
|
742
|
+
status = "hidden_requires_YNAB_ALLOW_WRITES_1";
|
|
743
|
+
} else if (!API_TOKEN) {
|
|
744
|
+
status = "discoverable_requires_credentials";
|
|
745
|
+
}
|
|
746
|
+
return {
|
|
747
|
+
name,
|
|
748
|
+
title: config?.title ?? name,
|
|
749
|
+
description: isWrite ? withWriteGateDescription(config?.description ?? "") : config?.description ?? "",
|
|
750
|
+
has_input_schema: !!config?.inputSchema,
|
|
751
|
+
is_write: isWrite,
|
|
752
|
+
registered: registeredTools.has(name),
|
|
753
|
+
status,
|
|
754
|
+
};
|
|
755
|
+
});
|
|
756
|
+
}
|
|
757
|
+
|
|
351
758
|
const WRITE_TOOL_METADATA = {
|
|
352
759
|
create_account: { destructiveHint: false, idempotentHint: false },
|
|
353
760
|
update_month_category: { destructiveHint: false, idempotentHint: true },
|
|
@@ -364,6 +771,7 @@ const WRITE_TOOL_METADATA = {
|
|
|
364
771
|
update_transactions: { destructiveHint: false, idempotentHint: true },
|
|
365
772
|
approve_transactions: { destructiveHint: false, idempotentHint: true },
|
|
366
773
|
reassign_payee_transactions: { destructiveHint: false, idempotentHint: true },
|
|
774
|
+
ynab_write_tool_execute: { destructiveHint: false, idempotentHint: false },
|
|
367
775
|
import_transactions: { destructiveHint: false, idempotentHint: false },
|
|
368
776
|
create_scheduled_transaction: { destructiveHint: false, idempotentHint: false },
|
|
369
777
|
update_scheduled_transaction: { destructiveHint: false, idempotentHint: true },
|
|
@@ -371,7 +779,29 @@ const WRITE_TOOL_METADATA = {
|
|
|
371
779
|
};
|
|
372
780
|
|
|
373
781
|
function writesEnabled() {
|
|
374
|
-
return
|
|
782
|
+
return runtimeConfig.values.YNAB_ALLOW_WRITES?.value === "1";
|
|
783
|
+
}
|
|
784
|
+
|
|
785
|
+
function ynabAuthStatus() {
|
|
786
|
+
const authenticated = !!API_TOKEN;
|
|
787
|
+
const writeToolsAvailable = authenticated && writesEnabled();
|
|
788
|
+
return {
|
|
789
|
+
authenticated,
|
|
790
|
+
default_budget_id_configured: !!DEFAULT_BUDGET_ID,
|
|
791
|
+
writes_enabled: writesEnabled(),
|
|
792
|
+
write_tools_available: writeToolsAvailable,
|
|
793
|
+
credential_source: runtimeConfig.tokenSource?.source ?? null,
|
|
794
|
+
credential_source_label: runtimeConfig.tokenSource?.source_label ?? null,
|
|
795
|
+
detected_agent: runtimeConfig.detected_agent,
|
|
796
|
+
config_fallback_disabled: runtimeConfig.config_fallback_disabled,
|
|
797
|
+
configured_settings: publicYnabRuntimeSettings(),
|
|
798
|
+
credential_sources_checked: runtimeConfig.sources_checked,
|
|
799
|
+
lookup_error: authenticated ? null : tokenLookupError ?? null,
|
|
800
|
+
setup: authenticated ? null : ynabAuthSetupGuide(),
|
|
801
|
+
message: authenticated
|
|
802
|
+
? "MCP Server for YNAB has an API token configured."
|
|
803
|
+
: "MCP Server for YNAB is running in discovery-only mode. Check setup.prompt_for_agent for the safe credential setup flow, then restart the MCP server before calling API tools.",
|
|
804
|
+
};
|
|
375
805
|
}
|
|
376
806
|
|
|
377
807
|
function writeDisabledResult(name) {
|
|
@@ -428,7 +858,7 @@ server.registerTool = (name, config, handler) => {
|
|
|
428
858
|
|
|
429
859
|
// ==================== User & Budgets ====================
|
|
430
860
|
|
|
431
|
-
|
|
861
|
+
registerTool(
|
|
432
862
|
"get_user",
|
|
433
863
|
{ description: "Get the authenticated user" },
|
|
434
864
|
() =>
|
|
@@ -438,29 +868,31 @@ server.registerTool(
|
|
|
438
868
|
})
|
|
439
869
|
);
|
|
440
870
|
|
|
441
|
-
|
|
871
|
+
registerTool(
|
|
442
872
|
"list_budgets",
|
|
443
873
|
{ description: "List all budgets. Use a budget ID from the results in other tools, or omit budgetId to use the last-used budget." },
|
|
444
874
|
() =>
|
|
445
875
|
run(async () => {
|
|
446
|
-
const { data } = await api.
|
|
876
|
+
const { data } = await api.plans.getPlans();
|
|
877
|
+
const plans = data.plans || data.budgets || [];
|
|
878
|
+
const defaultPlan = data.default_plan || data.default_budget;
|
|
447
879
|
const result = {
|
|
448
|
-
budgets:
|
|
880
|
+
budgets: plans.map((b) => ({ id: b.id, name: b.name, last_modified_on: b.last_modified_on, first_month: b.first_month, last_month: b.last_month, date_format: b.date_format, currency_format: b.currency_format })),
|
|
449
881
|
};
|
|
450
|
-
if (
|
|
451
|
-
result.default_budget = { id:
|
|
882
|
+
if (defaultPlan) {
|
|
883
|
+
result.default_budget = { id: defaultPlan.id, name: defaultPlan.name };
|
|
452
884
|
}
|
|
453
885
|
return ok(result);
|
|
454
886
|
})
|
|
455
887
|
);
|
|
456
888
|
|
|
457
|
-
|
|
889
|
+
registerTool(
|
|
458
890
|
"get_budget",
|
|
459
891
|
{ 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)") } },
|
|
460
892
|
({ budgetId }) =>
|
|
461
893
|
run(async () => {
|
|
462
|
-
const { data } = await api.
|
|
463
|
-
const b = data.budget;
|
|
894
|
+
const { data } = await api.plans.getPlanById(resolveBudgetId(budgetId));
|
|
895
|
+
const b = data.plan || data.budget;
|
|
464
896
|
return ok({
|
|
465
897
|
id: b.id,
|
|
466
898
|
name: b.name,
|
|
@@ -476,12 +908,12 @@ server.registerTool(
|
|
|
476
908
|
})
|
|
477
909
|
);
|
|
478
910
|
|
|
479
|
-
|
|
911
|
+
registerTool(
|
|
480
912
|
"get_budget_settings",
|
|
481
913
|
{ description: "Get budget settings (currency format, date format)", inputSchema: { budgetId: z.string().optional().describe("Budget ID (uses default if not provided)") } },
|
|
482
914
|
({ budgetId }) =>
|
|
483
915
|
run(async () => {
|
|
484
|
-
const { data } = await api.
|
|
916
|
+
const { data } = await api.plans.getPlanSettingsById(resolveBudgetId(budgetId));
|
|
485
917
|
return ok(data.settings);
|
|
486
918
|
})
|
|
487
919
|
);
|
|
@@ -512,7 +944,7 @@ function formatAccount(a) {
|
|
|
512
944
|
return withCurrencyFields(out, a, ["balance", "cleared_balance", "uncleared_balance"]);
|
|
513
945
|
}
|
|
514
946
|
|
|
515
|
-
|
|
947
|
+
registerTool(
|
|
516
948
|
"list_accounts",
|
|
517
949
|
{ description: "List all accounts in a budget", inputSchema: {
|
|
518
950
|
budgetId: z.string().optional().describe("Budget ID (uses default if not provided)"),
|
|
@@ -526,7 +958,7 @@ server.registerTool(
|
|
|
526
958
|
})
|
|
527
959
|
);
|
|
528
960
|
|
|
529
|
-
|
|
961
|
+
registerTool(
|
|
530
962
|
"get_account",
|
|
531
963
|
{ description: "Get details for a specific account", inputSchema: {
|
|
532
964
|
budgetId: z.string().optional().describe("Budget ID (uses default if not provided)"),
|
|
@@ -539,7 +971,7 @@ server.registerTool(
|
|
|
539
971
|
})
|
|
540
972
|
);
|
|
541
973
|
|
|
542
|
-
|
|
974
|
+
registerTool(
|
|
543
975
|
"create_account",
|
|
544
976
|
{ description: "Create a new account", inputSchema: {
|
|
545
977
|
budgetId: z.string().optional().describe("Budget ID (uses default if not provided)"),
|
|
@@ -598,7 +1030,7 @@ function formatCategory(c) {
|
|
|
598
1030
|
]);
|
|
599
1031
|
}
|
|
600
1032
|
|
|
601
|
-
|
|
1033
|
+
registerTool(
|
|
602
1034
|
"list_categories",
|
|
603
1035
|
{ description: "List all category groups and their categories", inputSchema: {
|
|
604
1036
|
budgetId: z.string().optional().describe("Budget ID (uses default if not provided)"),
|
|
@@ -634,7 +1066,7 @@ server.registerTool(
|
|
|
634
1066
|
})
|
|
635
1067
|
);
|
|
636
1068
|
|
|
637
|
-
|
|
1069
|
+
registerTool(
|
|
638
1070
|
"get_category",
|
|
639
1071
|
{ description: "Get a specific category", inputSchema: {
|
|
640
1072
|
budgetId: z.string().optional().describe("Budget ID (uses default if not provided)"),
|
|
@@ -647,7 +1079,7 @@ server.registerTool(
|
|
|
647
1079
|
})
|
|
648
1080
|
);
|
|
649
1081
|
|
|
650
|
-
|
|
1082
|
+
registerTool(
|
|
651
1083
|
"get_month_category",
|
|
652
1084
|
{ description: "Get category budget for a specific month", inputSchema: {
|
|
653
1085
|
budgetId: z.string().optional().describe("Budget ID (uses default if not provided)"),
|
|
@@ -661,7 +1093,7 @@ server.registerTool(
|
|
|
661
1093
|
})
|
|
662
1094
|
);
|
|
663
1095
|
|
|
664
|
-
|
|
1096
|
+
registerTool(
|
|
665
1097
|
"update_month_category",
|
|
666
1098
|
{ description: "Set the budgeted amount for a category in a specific month", inputSchema: {
|
|
667
1099
|
budgetId: z.string().optional().describe("Budget ID (uses default if not provided)"),
|
|
@@ -678,7 +1110,7 @@ server.registerTool(
|
|
|
678
1110
|
})
|
|
679
1111
|
);
|
|
680
1112
|
|
|
681
|
-
|
|
1113
|
+
registerTool(
|
|
682
1114
|
"update_category",
|
|
683
1115
|
{ description: "Update a category's name, note, goal target, or move it to a different group", inputSchema: {
|
|
684
1116
|
budgetId: z.string().optional().describe("Budget ID (uses default if not provided)"),
|
|
@@ -708,7 +1140,7 @@ server.registerTool(
|
|
|
708
1140
|
})
|
|
709
1141
|
);
|
|
710
1142
|
|
|
711
|
-
|
|
1143
|
+
registerTool(
|
|
712
1144
|
"create_category",
|
|
713
1145
|
{ description: "Create a new category in a category group", inputSchema: {
|
|
714
1146
|
budgetId: z.string().optional().describe("Budget ID (uses default if not provided)"),
|
|
@@ -735,7 +1167,7 @@ server.registerTool(
|
|
|
735
1167
|
})
|
|
736
1168
|
);
|
|
737
1169
|
|
|
738
|
-
|
|
1170
|
+
registerTool(
|
|
739
1171
|
"create_category_group",
|
|
740
1172
|
{ description: "Create a new category group", inputSchema: {
|
|
741
1173
|
budgetId: z.string().optional().describe("Budget ID (uses default if not provided)"),
|
|
@@ -751,7 +1183,7 @@ server.registerTool(
|
|
|
751
1183
|
})
|
|
752
1184
|
);
|
|
753
1185
|
|
|
754
|
-
|
|
1186
|
+
registerTool(
|
|
755
1187
|
"update_category_group",
|
|
756
1188
|
{ description: "Rename a category group", inputSchema: {
|
|
757
1189
|
budgetId: z.string().optional().describe("Budget ID (uses default if not provided)"),
|
|
@@ -770,7 +1202,7 @@ server.registerTool(
|
|
|
770
1202
|
|
|
771
1203
|
// ==================== Payees ====================
|
|
772
1204
|
|
|
773
|
-
|
|
1205
|
+
registerTool(
|
|
774
1206
|
"list_payees",
|
|
775
1207
|
{ description: "List all payees", inputSchema: {
|
|
776
1208
|
budgetId: z.string().optional().describe("Budget ID (uses default if not provided)"),
|
|
@@ -784,7 +1216,7 @@ server.registerTool(
|
|
|
784
1216
|
})
|
|
785
1217
|
);
|
|
786
1218
|
|
|
787
|
-
|
|
1219
|
+
registerTool(
|
|
788
1220
|
"get_payee",
|
|
789
1221
|
{ description: "Get a specific payee", inputSchema: {
|
|
790
1222
|
budgetId: z.string().optional().describe("Budget ID (uses default if not provided)"),
|
|
@@ -797,7 +1229,7 @@ server.registerTool(
|
|
|
797
1229
|
})
|
|
798
1230
|
);
|
|
799
1231
|
|
|
800
|
-
|
|
1232
|
+
registerTool(
|
|
801
1233
|
"update_payee",
|
|
802
1234
|
{ description: "Rename a payee", inputSchema: {
|
|
803
1235
|
budgetId: z.string().optional().describe("Budget ID (uses default if not provided)"),
|
|
@@ -813,7 +1245,7 @@ server.registerTool(
|
|
|
813
1245
|
})
|
|
814
1246
|
);
|
|
815
1247
|
|
|
816
|
-
|
|
1248
|
+
registerTool(
|
|
817
1249
|
"create_payee",
|
|
818
1250
|
{ description: "Create a new payee", inputSchema: {
|
|
819
1251
|
budgetId: z.string().optional().describe("Budget ID (uses default if not provided)"),
|
|
@@ -831,7 +1263,7 @@ server.registerTool(
|
|
|
831
1263
|
|
|
832
1264
|
// ==================== Payee Locations ====================
|
|
833
1265
|
|
|
834
|
-
|
|
1266
|
+
registerTool(
|
|
835
1267
|
"list_payee_locations",
|
|
836
1268
|
{ description: "List all payee locations (GPS coordinates where transactions occurred)", inputSchema: { budgetId: z.string().optional().describe("Budget ID (uses default if not provided)") } },
|
|
837
1269
|
({ budgetId }) =>
|
|
@@ -841,7 +1273,7 @@ server.registerTool(
|
|
|
841
1273
|
})
|
|
842
1274
|
);
|
|
843
1275
|
|
|
844
|
-
|
|
1276
|
+
registerTool(
|
|
845
1277
|
"get_payee_location",
|
|
846
1278
|
{ description: "Get a specific payee location", inputSchema: {
|
|
847
1279
|
budgetId: z.string().optional().describe("Budget ID (uses default if not provided)"),
|
|
@@ -854,7 +1286,7 @@ server.registerTool(
|
|
|
854
1286
|
})
|
|
855
1287
|
);
|
|
856
1288
|
|
|
857
|
-
|
|
1289
|
+
registerTool(
|
|
858
1290
|
"get_payee_locations_by_payee",
|
|
859
1291
|
{ description: "Get all locations for a specific payee", inputSchema: {
|
|
860
1292
|
budgetId: z.string().optional().describe("Budget ID (uses default if not provided)"),
|
|
@@ -869,7 +1301,7 @@ server.registerTool(
|
|
|
869
1301
|
|
|
870
1302
|
// ==================== Months ====================
|
|
871
1303
|
|
|
872
|
-
|
|
1304
|
+
registerTool(
|
|
873
1305
|
"list_months",
|
|
874
1306
|
{ description: "List all budget months", inputSchema: {
|
|
875
1307
|
budgetId: z.string().optional().describe("Budget ID (uses default if not provided)"),
|
|
@@ -877,7 +1309,7 @@ server.registerTool(
|
|
|
877
1309
|
} },
|
|
878
1310
|
({ budgetId, lastKnowledgeOfServer }) =>
|
|
879
1311
|
run(async () => {
|
|
880
|
-
const { data } = await api.months.
|
|
1312
|
+
const { data } = await api.months.getPlanMonths(resolveBudgetId(budgetId), lastKnowledgeOfServer);
|
|
881
1313
|
const months = data.months.map((m) =>
|
|
882
1314
|
withCurrencyFields(
|
|
883
1315
|
{
|
|
@@ -898,7 +1330,7 @@ server.registerTool(
|
|
|
898
1330
|
})
|
|
899
1331
|
);
|
|
900
1332
|
|
|
901
|
-
|
|
1333
|
+
registerTool(
|
|
902
1334
|
"get_month",
|
|
903
1335
|
{ description: "Get budget month detail with per-category breakdown", inputSchema: {
|
|
904
1336
|
budgetId: z.string().optional().describe("Budget ID (uses default if not provided)"),
|
|
@@ -906,7 +1338,7 @@ server.registerTool(
|
|
|
906
1338
|
} },
|
|
907
1339
|
({ budgetId, month }) =>
|
|
908
1340
|
run(async () => {
|
|
909
|
-
const { data } = await api.months.
|
|
1341
|
+
const { data } = await api.months.getPlanMonth(resolveBudgetId(budgetId), month);
|
|
910
1342
|
const m = data.month;
|
|
911
1343
|
const out = {
|
|
912
1344
|
month: m.month,
|
|
@@ -962,7 +1394,7 @@ function formatMoneyMovement(m) {
|
|
|
962
1394
|
}, m, ["amount"]);
|
|
963
1395
|
}
|
|
964
1396
|
|
|
965
|
-
|
|
1397
|
+
registerTool(
|
|
966
1398
|
"list_money_movements",
|
|
967
1399
|
{ description: "List all money movements (budget re-allocations between categories)", inputSchema: { budgetId: z.string().optional().describe("Budget ID (uses default if not provided)") } },
|
|
968
1400
|
({ budgetId }) =>
|
|
@@ -972,7 +1404,7 @@ server.registerTool(
|
|
|
972
1404
|
})
|
|
973
1405
|
);
|
|
974
1406
|
|
|
975
|
-
|
|
1407
|
+
registerTool(
|
|
976
1408
|
"get_money_movements_by_month",
|
|
977
1409
|
{ description: "Get money movements for a specific month", inputSchema: {
|
|
978
1410
|
budgetId: z.string().optional().describe("Budget ID (uses default if not provided)"),
|
|
@@ -985,7 +1417,7 @@ server.registerTool(
|
|
|
985
1417
|
})
|
|
986
1418
|
);
|
|
987
1419
|
|
|
988
|
-
|
|
1420
|
+
registerTool(
|
|
989
1421
|
"list_money_movement_groups",
|
|
990
1422
|
{ description: "List all money movement groups (batches of related money movements)", inputSchema: { budgetId: z.string().optional().describe("Budget ID (uses default if not provided)") } },
|
|
991
1423
|
({ budgetId }) =>
|
|
@@ -995,7 +1427,7 @@ server.registerTool(
|
|
|
995
1427
|
})
|
|
996
1428
|
);
|
|
997
1429
|
|
|
998
|
-
|
|
1430
|
+
registerTool(
|
|
999
1431
|
"get_money_movement_groups_by_month",
|
|
1000
1432
|
{ description: "Get money movement groups for a specific month", inputSchema: {
|
|
1001
1433
|
budgetId: z.string().optional().describe("Budget ID (uses default if not provided)"),
|
|
@@ -1057,11 +1489,12 @@ function formatTransaction(t) {
|
|
|
1057
1489
|
return withCurrencyFields(out, t, ["amount"]);
|
|
1058
1490
|
}
|
|
1059
1491
|
|
|
1060
|
-
|
|
1492
|
+
registerTool(
|
|
1061
1493
|
"get_transactions",
|
|
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: {
|
|
1494
|
+
{ 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. YNAB now defaults omitted sinceDate to one year ago; pass an explicit older sinceDate to retrieve older history. Note: large date ranges (6+ months on a busy budget) can return 50KB+ of data; narrow with categoryId/payeeId/month/sinceDate/untilDate filters when possible.", inputSchema: {
|
|
1063
1495
|
budgetId: z.string().optional().describe("Budget ID (uses default if not provided)"),
|
|
1064
|
-
sinceDate: z.string().optional().describe("Only return transactions on or after this date (YYYY-MM-DD)"),
|
|
1496
|
+
sinceDate: z.string().optional().describe("Only return transactions on or after this date (YYYY-MM-DD). If omitted, YNAB defaults to one year ago."),
|
|
1497
|
+
untilDate: z.string().optional().describe("Only return transactions on or before this date (YYYY-MM-DD)"),
|
|
1065
1498
|
type: z.enum(["unapproved", "uncategorized"]).optional().describe("Filter by approval/categorization status"),
|
|
1066
1499
|
accountId: z.string().optional().describe("Filter by account ID"),
|
|
1067
1500
|
categoryId: z.string().optional().describe("Filter by category ID"),
|
|
@@ -1069,38 +1502,30 @@ server.registerTool(
|
|
|
1069
1502
|
month: z.string().optional().describe("Filter by month (YYYY-MM-DD, first of month)"),
|
|
1070
1503
|
lastKnowledgeOfServer: z.number().int().nonnegative().optional().describe("Delta request server knowledge. When provided, returns { transactions, server_knowledge }."),
|
|
1071
1504
|
} },
|
|
1072
|
-
({ budgetId, sinceDate, type, accountId, categoryId, payeeId, month, lastKnowledgeOfServer }) =>
|
|
1505
|
+
({ budgetId, sinceDate, untilDate, type, accountId, categoryId, payeeId, month, lastKnowledgeOfServer }) =>
|
|
1073
1506
|
run(async () => {
|
|
1074
|
-
const bid = resolveBudgetId(budgetId);
|
|
1075
|
-
let transactions;
|
|
1076
|
-
let data;
|
|
1077
1507
|
const resourceFilters = [accountId, categoryId, payeeId, month].filter((value) => value !== undefined && value !== null && value !== "");
|
|
1078
1508
|
if (resourceFilters.length > 1) {
|
|
1079
1509
|
throw new Error("Provide only one of accountId, categoryId, payeeId, or month.");
|
|
1080
1510
|
}
|
|
1081
1511
|
|
|
1082
|
-
|
|
1083
|
-
|
|
1084
|
-
|
|
1085
|
-
|
|
1086
|
-
|
|
1087
|
-
|
|
1088
|
-
|
|
1089
|
-
|
|
1090
|
-
|
|
1091
|
-
|
|
1092
|
-
|
|
1093
|
-
|
|
1094
|
-
} else {
|
|
1095
|
-
({ data } = await api.transactions.getTransactions(bid, sinceDate, type, lastKnowledgeOfServer));
|
|
1096
|
-
transactions = data.transactions;
|
|
1097
|
-
}
|
|
1098
|
-
|
|
1512
|
+
const data = await fetchTransactions({
|
|
1513
|
+
budgetId,
|
|
1514
|
+
sinceDate,
|
|
1515
|
+
untilDate,
|
|
1516
|
+
type,
|
|
1517
|
+
accountId,
|
|
1518
|
+
categoryId,
|
|
1519
|
+
payeeId,
|
|
1520
|
+
month,
|
|
1521
|
+
lastKnowledgeOfServer,
|
|
1522
|
+
});
|
|
1523
|
+
const transactions = data.transactions;
|
|
1099
1524
|
return ok(collection(data, "transactions", transactions.map(formatTransaction), lastKnowledgeOfServer));
|
|
1100
1525
|
})
|
|
1101
1526
|
);
|
|
1102
1527
|
|
|
1103
|
-
|
|
1528
|
+
registerTool(
|
|
1104
1529
|
"get_transaction",
|
|
1105
1530
|
{ 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: {
|
|
1106
1531
|
budgetId: z.string().optional().describe("Budget ID (uses default if not provided)"),
|
|
@@ -1140,7 +1565,7 @@ server.registerTool(
|
|
|
1140
1565
|
})
|
|
1141
1566
|
);
|
|
1142
1567
|
|
|
1143
|
-
|
|
1568
|
+
registerTool(
|
|
1144
1569
|
"create_transaction",
|
|
1145
1570
|
{ 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: {
|
|
1146
1571
|
budgetId: z.string().optional().describe("Budget ID (uses default if not provided)"),
|
|
@@ -1172,7 +1597,7 @@ server.registerTool(
|
|
|
1172
1597
|
})
|
|
1173
1598
|
);
|
|
1174
1599
|
|
|
1175
|
-
|
|
1600
|
+
registerTool(
|
|
1176
1601
|
"create_transactions",
|
|
1177
1602
|
{ 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: {
|
|
1178
1603
|
budgetId: z.string().optional().describe("Budget ID (uses default if not provided)"),
|
|
@@ -1209,7 +1634,7 @@ server.registerTool(
|
|
|
1209
1634
|
})
|
|
1210
1635
|
);
|
|
1211
1636
|
|
|
1212
|
-
|
|
1637
|
+
registerTool(
|
|
1213
1638
|
"update_transaction",
|
|
1214
1639
|
{ description: "Update an existing transaction. Only provided fields are changed. Amounts in dollars.", inputSchema: {
|
|
1215
1640
|
budgetId: z.string().optional().describe("Budget ID (uses default if not provided)"),
|
|
@@ -1234,7 +1659,7 @@ server.registerTool(
|
|
|
1234
1659
|
})
|
|
1235
1660
|
);
|
|
1236
1661
|
|
|
1237
|
-
|
|
1662
|
+
registerTool(
|
|
1238
1663
|
"delete_transaction",
|
|
1239
1664
|
{ description: "Delete a transaction", inputSchema: {
|
|
1240
1665
|
budgetId: z.string().optional().describe("Budget ID (uses default if not provided)"),
|
|
@@ -1247,7 +1672,7 @@ server.registerTool(
|
|
|
1247
1672
|
})
|
|
1248
1673
|
);
|
|
1249
1674
|
|
|
1250
|
-
|
|
1675
|
+
registerTool(
|
|
1251
1676
|
"update_transactions",
|
|
1252
1677
|
{ 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: {
|
|
1253
1678
|
budgetId: z.string().optional().describe("Budget ID (uses default if not provided)"),
|
|
@@ -1307,19 +1732,28 @@ server.registerTool(
|
|
|
1307
1732
|
})
|
|
1308
1733
|
);
|
|
1309
1734
|
|
|
1310
|
-
|
|
1735
|
+
registerTool(
|
|
1311
1736
|
"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.
|
|
1737
|
+
{ 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. Requires confirmed:true after explicit user confirmation.", inputSchema: {
|
|
1313
1738
|
budgetId: z.string().optional().describe("Budget ID (uses default if not provided)"),
|
|
1739
|
+
confirmed: z.literal(true).describe("Required. Pass true only after the user explicitly confirms this approval action."),
|
|
1740
|
+
expectedMatchedCount: z.number().int().nonnegative().optional().describe("Optional safety check. If provided and the current match count differs, no transactions are approved."),
|
|
1741
|
+
sinceDate: z.string().optional().describe("Only inspect unapproved transactions on or after this date (YYYY-MM-DD). Defaults to all history."),
|
|
1742
|
+
untilDate: z.string().optional().describe("Only inspect unapproved transactions on or before this date (YYYY-MM-DD)."),
|
|
1314
1743
|
payeeId: z.string().optional().describe("Only approve unapproved transactions for this payee"),
|
|
1315
1744
|
categoryId: z.string().optional().describe("Only approve unapproved transactions in this category"),
|
|
1316
1745
|
accountId: z.string().optional().describe("Only approve unapproved transactions in this account"),
|
|
1317
1746
|
includeUncategorized: z.boolean().optional().describe("If true, also approve transactions with no category (default false — uncategorized are skipped for safety)"),
|
|
1318
1747
|
} },
|
|
1319
|
-
({ budgetId, payeeId, categoryId, accountId, includeUncategorized }) =>
|
|
1748
|
+
({ budgetId, expectedMatchedCount, sinceDate, untilDate, payeeId, categoryId, accountId, includeUncategorized }) =>
|
|
1320
1749
|
run(async () => {
|
|
1321
1750
|
const bid = resolveBudgetId(budgetId);
|
|
1322
|
-
const
|
|
1751
|
+
const data = await fetchTransactions({
|
|
1752
|
+
budgetId: bid,
|
|
1753
|
+
sinceDate: sinceDate || allHistorySinceDate(),
|
|
1754
|
+
untilDate,
|
|
1755
|
+
type: "unapproved",
|
|
1756
|
+
});
|
|
1323
1757
|
let txns = data.transactions.filter((t) => !t.deleted);
|
|
1324
1758
|
if (payeeId) txns = txns.filter((t) => t.payee_id === payeeId);
|
|
1325
1759
|
if (categoryId) txns = txns.filter((t) => t.category_id === categoryId);
|
|
@@ -1327,6 +1761,9 @@ server.registerTool(
|
|
|
1327
1761
|
if (!includeUncategorized) {
|
|
1328
1762
|
txns = txns.filter((t) => (t.category_id && t.category_name !== "Uncategorized") || t.transfer_account_id);
|
|
1329
1763
|
}
|
|
1764
|
+
if (expectedMatchedCount !== undefined && expectedMatchedCount !== txns.length) {
|
|
1765
|
+
throw new Error(`approve_transactions matched ${txns.length} transactions, but expectedMatchedCount was ${expectedMatchedCount}; no transactions were approved.`);
|
|
1766
|
+
}
|
|
1330
1767
|
if (txns.length === 0) {
|
|
1331
1768
|
return ok({ approved_count: 0, matched: 0, message: "No matching unapproved transactions to approve." });
|
|
1332
1769
|
}
|
|
@@ -1350,19 +1787,30 @@ server.registerTool(
|
|
|
1350
1787
|
})
|
|
1351
1788
|
);
|
|
1352
1789
|
|
|
1353
|
-
|
|
1790
|
+
registerTool(
|
|
1354
1791
|
"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: {
|
|
1792
|
+
{ 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. Requires confirmed:true after explicit user confirmation.", inputSchema: {
|
|
1356
1793
|
budgetId: z.string().optional().describe("Budget ID (uses default if not provided)"),
|
|
1794
|
+
confirmed: z.literal(true).describe("Required. Pass true only after the user explicitly confirms this payee reassignment."),
|
|
1795
|
+
expectedMatchedCount: z.number().int().nonnegative().optional().describe("Optional safety check. If provided and the current match count differs, no transactions are reassigned."),
|
|
1357
1796
|
fromPayeeId: z.string().describe("Payee whose transactions will be moved"),
|
|
1358
1797
|
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);
|
|
1798
|
+
sinceDate: z.string().optional().describe("Only move transactions on or after this date (YYYY-MM-DD); defaults to all history"),
|
|
1799
|
+
untilDate: z.string().optional().describe("Only move transactions on or before this date (YYYY-MM-DD)"),
|
|
1360
1800
|
} },
|
|
1361
|
-
({ budgetId, fromPayeeId, toPayeeId, sinceDate }) =>
|
|
1801
|
+
({ budgetId, expectedMatchedCount, fromPayeeId, toPayeeId, sinceDate, untilDate }) =>
|
|
1362
1802
|
run(async () => {
|
|
1363
1803
|
const bid = resolveBudgetId(budgetId);
|
|
1364
|
-
const
|
|
1804
|
+
const data = await fetchTransactions({
|
|
1805
|
+
budgetId: bid,
|
|
1806
|
+
payeeId: fromPayeeId,
|
|
1807
|
+
sinceDate: sinceDate || allHistorySinceDate(),
|
|
1808
|
+
untilDate,
|
|
1809
|
+
});
|
|
1365
1810
|
const txns = data.transactions.filter((t) => !t.deleted);
|
|
1811
|
+
if (expectedMatchedCount !== undefined && expectedMatchedCount !== txns.length) {
|
|
1812
|
+
throw new Error(`reassign_payee_transactions matched ${txns.length} transactions, but expectedMatchedCount was ${expectedMatchedCount}; no transactions were reassigned.`);
|
|
1813
|
+
}
|
|
1366
1814
|
if (txns.length === 0) {
|
|
1367
1815
|
return ok({ reassigned_count: 0, message: "No transactions found for fromPayeeId in the given range." });
|
|
1368
1816
|
}
|
|
@@ -1387,7 +1835,7 @@ server.registerTool(
|
|
|
1387
1835
|
})
|
|
1388
1836
|
);
|
|
1389
1837
|
|
|
1390
|
-
|
|
1838
|
+
registerTool(
|
|
1391
1839
|
"import_transactions",
|
|
1392
1840
|
{ description: "Trigger import of linked account transactions", inputSchema: { budgetId: z.string().optional().describe("Budget ID (uses default if not provided)") } },
|
|
1393
1841
|
({ budgetId }) =>
|
|
@@ -1439,7 +1887,7 @@ function formatScheduledTransaction(t) {
|
|
|
1439
1887
|
return withCurrencyFields(out, t, ["amount"]);
|
|
1440
1888
|
}
|
|
1441
1889
|
|
|
1442
|
-
|
|
1890
|
+
registerTool(
|
|
1443
1891
|
"list_scheduled_transactions",
|
|
1444
1892
|
{ 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: {
|
|
1445
1893
|
budgetId: z.string().optional().describe("Budget ID (uses default if not provided)"),
|
|
@@ -1453,7 +1901,7 @@ server.registerTool(
|
|
|
1453
1901
|
})
|
|
1454
1902
|
);
|
|
1455
1903
|
|
|
1456
|
-
|
|
1904
|
+
registerTool(
|
|
1457
1905
|
"get_scheduled_transaction",
|
|
1458
1906
|
{ description: "Get a specific scheduled transaction", inputSchema: {
|
|
1459
1907
|
budgetId: z.string().optional().describe("Budget ID (uses default if not provided)"),
|
|
@@ -1466,7 +1914,7 @@ server.registerTool(
|
|
|
1466
1914
|
})
|
|
1467
1915
|
);
|
|
1468
1916
|
|
|
1469
|
-
|
|
1917
|
+
registerTool(
|
|
1470
1918
|
"create_scheduled_transaction",
|
|
1471
1919
|
{ description: "Create a new scheduled (recurring) transaction", inputSchema: {
|
|
1472
1920
|
budgetId: z.string().optional().describe("Budget ID (uses default if not provided)"),
|
|
@@ -1499,7 +1947,7 @@ server.registerTool(
|
|
|
1499
1947
|
})
|
|
1500
1948
|
);
|
|
1501
1949
|
|
|
1502
|
-
|
|
1950
|
+
registerTool(
|
|
1503
1951
|
"update_scheduled_transaction",
|
|
1504
1952
|
{ description: "Update an existing scheduled transaction. Only provided fields are changed. Amounts in dollars.", inputSchema: {
|
|
1505
1953
|
budgetId: z.string().optional().describe("Budget ID (uses default if not provided)"),
|
|
@@ -1542,7 +1990,7 @@ server.registerTool(
|
|
|
1542
1990
|
})
|
|
1543
1991
|
);
|
|
1544
1992
|
|
|
1545
|
-
|
|
1993
|
+
registerTool(
|
|
1546
1994
|
"delete_scheduled_transaction",
|
|
1547
1995
|
{ description: "Delete a scheduled transaction", inputSchema: {
|
|
1548
1996
|
budgetId: z.string().optional().describe("Budget ID (uses default if not provided)"),
|
|
@@ -1557,7 +2005,7 @@ server.registerTool(
|
|
|
1557
2005
|
|
|
1558
2006
|
// ==================== Convenience Tools ====================
|
|
1559
2007
|
|
|
1560
|
-
|
|
2008
|
+
registerTool(
|
|
1561
2009
|
"search_categories",
|
|
1562
2010
|
{ description: "Search categories by partial name match (case-insensitive). Useful for finding category IDs when you only know part of the name.", inputSchema: {
|
|
1563
2011
|
budgetId: z.string().optional().describe("Budget ID (uses default if not provided)"),
|
|
@@ -1589,7 +2037,7 @@ server.registerTool(
|
|
|
1589
2037
|
})
|
|
1590
2038
|
);
|
|
1591
2039
|
|
|
1592
|
-
|
|
2040
|
+
registerTool(
|
|
1593
2041
|
"search_payees",
|
|
1594
2042
|
{ description: "Search payees by partial name match (case-insensitive). Useful for finding payee IDs.", inputSchema: {
|
|
1595
2043
|
budgetId: z.string().optional().describe("Budget ID (uses default if not provided)"),
|
|
@@ -1607,7 +2055,7 @@ server.registerTool(
|
|
|
1607
2055
|
})
|
|
1608
2056
|
);
|
|
1609
2057
|
|
|
1610
|
-
|
|
2058
|
+
registerTool(
|
|
1611
2059
|
"review_unapproved",
|
|
1612
2060
|
{ 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
2061
|
budgetId: z.string().optional().describe("Budget ID (uses default if not provided)"),
|
|
@@ -1747,7 +2195,7 @@ server.registerTool(
|
|
|
1747
2195
|
})
|
|
1748
2196
|
);
|
|
1749
2197
|
|
|
1750
|
-
|
|
2198
|
+
registerTool(
|
|
1751
2199
|
"get_overspent_categories",
|
|
1752
2200
|
{ 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: {
|
|
1753
2201
|
budgetId: z.string().optional().describe("Budget ID (uses default if not provided)"),
|
|
@@ -1755,7 +2203,7 @@ server.registerTool(
|
|
|
1755
2203
|
} },
|
|
1756
2204
|
({ budgetId, month }) =>
|
|
1757
2205
|
run(async () => {
|
|
1758
|
-
const { data } = await api.months.
|
|
2206
|
+
const { data } = await api.months.getPlanMonth(resolveBudgetId(budgetId), month);
|
|
1759
2207
|
const overspent = (data.month.categories || [])
|
|
1760
2208
|
.filter((c) => !c.deleted && c.balance < 0 && c.category_group_name !== "Internal Master Category")
|
|
1761
2209
|
.map((c) => ({
|
|
@@ -1776,6 +2224,104 @@ server.registerTool(
|
|
|
1776
2224
|
})
|
|
1777
2225
|
);
|
|
1778
2226
|
|
|
2227
|
+
registerTool(
|
|
2228
|
+
"ynab_auth_status",
|
|
2229
|
+
{
|
|
2230
|
+
title: "YNAB Auth Status",
|
|
2231
|
+
description: "Check whether the YNAB MCP server has credentials configured and whether write tools are enabled.",
|
|
2232
|
+
inputSchema: {},
|
|
2233
|
+
},
|
|
2234
|
+
() => ok(ynabAuthStatus())
|
|
2235
|
+
);
|
|
2236
|
+
|
|
2237
|
+
registerTool(
|
|
2238
|
+
"ynab_tool_index",
|
|
2239
|
+
{
|
|
2240
|
+
title: "YNAB Tool Index",
|
|
2241
|
+
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.",
|
|
2242
|
+
inputSchema: {},
|
|
2243
|
+
},
|
|
2244
|
+
() => ok({
|
|
2245
|
+
server: "mcp-server-for-ynab",
|
|
2246
|
+
package: "@oliverames/ynab-mcp-server",
|
|
2247
|
+
auth: ynabAuthStatus(),
|
|
2248
|
+
writes_enabled: writesEnabled(),
|
|
2249
|
+
writes_available: writesEnabled() && !!API_TOKEN,
|
|
2250
|
+
tools: listRegisteredYnabTools(),
|
|
2251
|
+
execute_with: "ynab_tool_execute",
|
|
2252
|
+
write_execute_with: writesEnabled() && !!API_TOKEN ? "ynab_write_tool_execute" : null,
|
|
2253
|
+
})
|
|
2254
|
+
);
|
|
2255
|
+
|
|
2256
|
+
registerTool(
|
|
2257
|
+
"ynab_tool_execute",
|
|
2258
|
+
{
|
|
2259
|
+
title: "Execute YNAB Tool",
|
|
2260
|
+
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.",
|
|
2261
|
+
inputSchema: {
|
|
2262
|
+
tool_name: z.string().describe("Existing read-only YNAB tool name, such as review_unapproved, get_transactions, list_categories, search_categories, or search_payees."),
|
|
2263
|
+
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."),
|
|
2264
|
+
},
|
|
2265
|
+
},
|
|
2266
|
+
async ({ tool_name: toolName, input = {} }) => {
|
|
2267
|
+
if (toolName.startsWith("ynab_")) {
|
|
2268
|
+
return {
|
|
2269
|
+
isError: true,
|
|
2270
|
+
content: [{ type: "text", text: "Refusing to execute YNAB discovery helper tools recursively." }],
|
|
2271
|
+
};
|
|
2272
|
+
}
|
|
2273
|
+
if (WRITE_TOOL_METADATA[toolName]) {
|
|
2274
|
+
return {
|
|
2275
|
+
isError: true,
|
|
2276
|
+
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.` }],
|
|
2277
|
+
};
|
|
2278
|
+
}
|
|
2279
|
+
const tool = registeredTools.get(toolName);
|
|
2280
|
+
if (!tool) {
|
|
2281
|
+
return {
|
|
2282
|
+
isError: true,
|
|
2283
|
+
content: [{ type: "text", text: `Unknown YNAB tool: ${toolName}` }],
|
|
2284
|
+
};
|
|
2285
|
+
}
|
|
2286
|
+
return tool.handler(input);
|
|
2287
|
+
}
|
|
2288
|
+
);
|
|
2289
|
+
|
|
2290
|
+
registerTool(
|
|
2291
|
+
"ynab_write_tool_execute",
|
|
2292
|
+
{
|
|
2293
|
+
title: "Execute YNAB Write Tool",
|
|
2294
|
+
description: "Execute an existing write-capable YNAB MCP tool by name. This tool is registered only when YNAB_ALLOW_WRITES=1 and requires confirmed:true after explicit user confirmation.",
|
|
2295
|
+
inputSchema: {
|
|
2296
|
+
confirmed: z.literal(true).describe("Required. Pass true only after the user explicitly confirms this write action."),
|
|
2297
|
+
tool_name: z.string().describe("Existing write-capable YNAB tool name, such as update_transaction, update_transactions, approve_transactions, create_transaction, or delete_transaction."),
|
|
2298
|
+
input: z.record(z.string(), z.any()).optional().describe("JSON input for the selected YNAB write tool."),
|
|
2299
|
+
},
|
|
2300
|
+
},
|
|
2301
|
+
async ({ tool_name: toolName, input = {} }) => {
|
|
2302
|
+
if (toolName.startsWith("ynab_")) {
|
|
2303
|
+
return {
|
|
2304
|
+
isError: true,
|
|
2305
|
+
content: [{ type: "text", text: "Refusing to execute YNAB discovery helper tools recursively." }],
|
|
2306
|
+
};
|
|
2307
|
+
}
|
|
2308
|
+
if (!WRITE_TOOL_METADATA[toolName]) {
|
|
2309
|
+
return {
|
|
2310
|
+
isError: true,
|
|
2311
|
+
content: [{ type: "text", text: `${toolName} is not a write-capable YNAB tool. Use ynab_tool_execute for read-only tools.` }],
|
|
2312
|
+
};
|
|
2313
|
+
}
|
|
2314
|
+
const tool = registeredTools.get(toolName);
|
|
2315
|
+
if (!tool) {
|
|
2316
|
+
return writeDisabledResult(toolName);
|
|
2317
|
+
}
|
|
2318
|
+
const confirmedInput = ["approve_transactions", "reassign_payee_transactions"].includes(toolName) && input.confirmed === undefined
|
|
2319
|
+
? { ...input, confirmed: true }
|
|
2320
|
+
: input;
|
|
2321
|
+
return tool.handler(confirmedInput);
|
|
2322
|
+
}
|
|
2323
|
+
);
|
|
2324
|
+
|
|
1779
2325
|
// --- Start ---
|
|
1780
2326
|
|
|
1781
2327
|
process.on("uncaughtException", (err) => {
|