@oliverames/ynab-mcp-server 2.1.1 → 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 +489 -78
- package/package.json +13 -6
- package/scripts/build-mcpb.mjs +5 -5
- package/scripts/check-release-consistency.mjs +3 -3
- package/scripts/test-safety-model.mjs +120 -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,45 +15,361 @@ 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
|
-
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
|
-
}
|
|
30
|
-
if (!API_TOKEN && process.env.YNAB_OP_PATH) {
|
|
31
|
-
try {
|
|
32
|
-
API_TOKEN = execFileSync(
|
|
33
|
-
"op", ["read", process.env.YNAB_OP_PATH],
|
|
34
|
-
{ encoding: "utf-8", timeout: 10000, stdio: ["pipe", "pipe", "pipe"] }
|
|
35
|
-
).trim();
|
|
36
|
-
} catch (e) {
|
|
37
|
-
tokenLookupError = `Could not read YNAB_OP_PATH via 1Password CLI: ${e.stderr?.toString().trim() || e.message || "unknown 1Password CLI error"}`;
|
|
38
|
-
}
|
|
39
|
-
}
|
|
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;
|
|
40
30
|
if (!API_TOKEN) {
|
|
41
31
|
const fallbackMessage = tokenLookupError
|
|
42
32
|
? ` ${tokenLookupError}.`
|
|
43
|
-
: "
|
|
44
|
-
console.error(`YNAB_API_TOKEN
|
|
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.`);
|
|
45
35
|
}
|
|
46
36
|
|
|
47
37
|
const ynabRateLimit = createYnabRateLimiter();
|
|
48
38
|
const effectiveApiToken = API_TOKEN || "missing-token-for-tool-discovery";
|
|
49
39
|
const api = new ynab.API(effectiveApiToken, BASE_URL);
|
|
50
40
|
api._configuration.config = { accessToken: effectiveApiToken, basePath: BASE_URL, fetchApi: secureFetch };
|
|
51
|
-
const DEFAULT_BUDGET_ID = process.env.YNAB_BUDGET_ID;
|
|
52
41
|
|
|
53
42
|
// --- Helpers ---
|
|
54
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)}`);
|
|
82
|
+
}
|
|
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");
|
|
159
|
+
} catch (e) {
|
|
160
|
+
return emptySources.map((source) => ({
|
|
161
|
+
...source,
|
|
162
|
+
available: false,
|
|
163
|
+
errors: isMissingFileError(e) ? [] : [`Could not read ${configPath}: ${e.message || String(e)}`],
|
|
164
|
+
}));
|
|
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
|
+
}));
|
|
174
|
+
}
|
|
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;
|
|
181
|
+
try {
|
|
182
|
+
settings = JSON.parse(readFileSync(settingsPath, "utf8"));
|
|
183
|
+
} catch (e) {
|
|
184
|
+
return {
|
|
185
|
+
...source,
|
|
186
|
+
available: false,
|
|
187
|
+
errors: isMissingFileError(e) ? [] : [`Could not read ${settingsPath}: ${e.message || String(e)}`],
|
|
188
|
+
};
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
return {
|
|
192
|
+
...source,
|
|
193
|
+
available: true,
|
|
194
|
+
values: pickYnabValues(settings?.env || {}),
|
|
195
|
+
errors: [],
|
|
196
|
+
};
|
|
197
|
+
}
|
|
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
|
+
};
|
|
209
|
+
}
|
|
210
|
+
|
|
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
|
+
}
|
|
218
|
+
|
|
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
|
+
}
|
|
372
|
+
|
|
55
373
|
function resolveBudgetId(input) {
|
|
56
374
|
return input || DEFAULT_BUDGET_ID || "last-used";
|
|
57
375
|
}
|
|
@@ -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) {
|
|
@@ -344,8 +715,8 @@ 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
|
|
|
351
722
|
const registeredTools = new Map();
|
|
@@ -366,6 +737,12 @@ function listRegisteredYnabTools() {
|
|
|
366
737
|
.map(([name, { config }]) => {
|
|
367
738
|
const writeMetadata = WRITE_TOOL_METADATA[name];
|
|
368
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
|
+
}
|
|
369
746
|
return {
|
|
370
747
|
name,
|
|
371
748
|
title: config?.title ?? name,
|
|
@@ -373,7 +750,7 @@ function listRegisteredYnabTools() {
|
|
|
373
750
|
has_input_schema: !!config?.inputSchema,
|
|
374
751
|
is_write: isWrite,
|
|
375
752
|
registered: registeredTools.has(name),
|
|
376
|
-
status
|
|
753
|
+
status,
|
|
377
754
|
};
|
|
378
755
|
});
|
|
379
756
|
}
|
|
@@ -402,17 +779,28 @@ const WRITE_TOOL_METADATA = {
|
|
|
402
779
|
};
|
|
403
780
|
|
|
404
781
|
function writesEnabled() {
|
|
405
|
-
return
|
|
782
|
+
return runtimeConfig.values.YNAB_ALLOW_WRITES?.value === "1";
|
|
406
783
|
}
|
|
407
784
|
|
|
408
785
|
function ynabAuthStatus() {
|
|
786
|
+
const authenticated = !!API_TOKEN;
|
|
787
|
+
const writeToolsAvailable = authenticated && writesEnabled();
|
|
409
788
|
return {
|
|
410
|
-
authenticated
|
|
789
|
+
authenticated,
|
|
411
790
|
default_budget_id_configured: !!DEFAULT_BUDGET_ID,
|
|
412
791
|
writes_enabled: writesEnabled(),
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
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.",
|
|
416
804
|
};
|
|
417
805
|
}
|
|
418
806
|
|
|
@@ -485,12 +873,14 @@ registerTool(
|
|
|
485
873
|
{ description: "List all budgets. Use a budget ID from the results in other tools, or omit budgetId to use the last-used budget." },
|
|
486
874
|
() =>
|
|
487
875
|
run(async () => {
|
|
488
|
-
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;
|
|
489
879
|
const result = {
|
|
490
|
-
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 })),
|
|
491
881
|
};
|
|
492
|
-
if (
|
|
493
|
-
result.default_budget = { id:
|
|
882
|
+
if (defaultPlan) {
|
|
883
|
+
result.default_budget = { id: defaultPlan.id, name: defaultPlan.name };
|
|
494
884
|
}
|
|
495
885
|
return ok(result);
|
|
496
886
|
})
|
|
@@ -501,8 +891,8 @@ registerTool(
|
|
|
501
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)") } },
|
|
502
892
|
({ budgetId }) =>
|
|
503
893
|
run(async () => {
|
|
504
|
-
const { data } = await api.
|
|
505
|
-
const b = data.budget;
|
|
894
|
+
const { data } = await api.plans.getPlanById(resolveBudgetId(budgetId));
|
|
895
|
+
const b = data.plan || data.budget;
|
|
506
896
|
return ok({
|
|
507
897
|
id: b.id,
|
|
508
898
|
name: b.name,
|
|
@@ -523,7 +913,7 @@ registerTool(
|
|
|
523
913
|
{ description: "Get budget settings (currency format, date format)", inputSchema: { budgetId: z.string().optional().describe("Budget ID (uses default if not provided)") } },
|
|
524
914
|
({ budgetId }) =>
|
|
525
915
|
run(async () => {
|
|
526
|
-
const { data } = await api.
|
|
916
|
+
const { data } = await api.plans.getPlanSettingsById(resolveBudgetId(budgetId));
|
|
527
917
|
return ok(data.settings);
|
|
528
918
|
})
|
|
529
919
|
);
|
|
@@ -919,7 +1309,7 @@ registerTool(
|
|
|
919
1309
|
} },
|
|
920
1310
|
({ budgetId, lastKnowledgeOfServer }) =>
|
|
921
1311
|
run(async () => {
|
|
922
|
-
const { data } = await api.months.
|
|
1312
|
+
const { data } = await api.months.getPlanMonths(resolveBudgetId(budgetId), lastKnowledgeOfServer);
|
|
923
1313
|
const months = data.months.map((m) =>
|
|
924
1314
|
withCurrencyFields(
|
|
925
1315
|
{
|
|
@@ -948,7 +1338,7 @@ registerTool(
|
|
|
948
1338
|
} },
|
|
949
1339
|
({ budgetId, month }) =>
|
|
950
1340
|
run(async () => {
|
|
951
|
-
const { data } = await api.months.
|
|
1341
|
+
const { data } = await api.months.getPlanMonth(resolveBudgetId(budgetId), month);
|
|
952
1342
|
const m = data.month;
|
|
953
1343
|
const out = {
|
|
954
1344
|
month: m.month,
|
|
@@ -1101,9 +1491,10 @@ function formatTransaction(t) {
|
|
|
1101
1491
|
|
|
1102
1492
|
registerTool(
|
|
1103
1493
|
"get_transactions",
|
|
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: {
|
|
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: {
|
|
1105
1495
|
budgetId: z.string().optional().describe("Budget ID (uses default if not provided)"),
|
|
1106
|
-
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)"),
|
|
1107
1498
|
type: z.enum(["unapproved", "uncategorized"]).optional().describe("Filter by approval/categorization status"),
|
|
1108
1499
|
accountId: z.string().optional().describe("Filter by account ID"),
|
|
1109
1500
|
categoryId: z.string().optional().describe("Filter by category ID"),
|
|
@@ -1111,33 +1502,25 @@ registerTool(
|
|
|
1111
1502
|
month: z.string().optional().describe("Filter by month (YYYY-MM-DD, first of month)"),
|
|
1112
1503
|
lastKnowledgeOfServer: z.number().int().nonnegative().optional().describe("Delta request server knowledge. When provided, returns { transactions, server_knowledge }."),
|
|
1113
1504
|
} },
|
|
1114
|
-
({ budgetId, sinceDate, type, accountId, categoryId, payeeId, month, lastKnowledgeOfServer }) =>
|
|
1505
|
+
({ budgetId, sinceDate, untilDate, type, accountId, categoryId, payeeId, month, lastKnowledgeOfServer }) =>
|
|
1115
1506
|
run(async () => {
|
|
1116
|
-
const bid = resolveBudgetId(budgetId);
|
|
1117
|
-
let transactions;
|
|
1118
|
-
let data;
|
|
1119
1507
|
const resourceFilters = [accountId, categoryId, payeeId, month].filter((value) => value !== undefined && value !== null && value !== "");
|
|
1120
1508
|
if (resourceFilters.length > 1) {
|
|
1121
1509
|
throw new Error("Provide only one of accountId, categoryId, payeeId, or month.");
|
|
1122
1510
|
}
|
|
1123
1511
|
|
|
1124
|
-
|
|
1125
|
-
|
|
1126
|
-
|
|
1127
|
-
|
|
1128
|
-
|
|
1129
|
-
|
|
1130
|
-
|
|
1131
|
-
|
|
1132
|
-
|
|
1133
|
-
|
|
1134
|
-
|
|
1135
|
-
|
|
1136
|
-
} else {
|
|
1137
|
-
({ data } = await api.transactions.getTransactions(bid, sinceDate, type, lastKnowledgeOfServer));
|
|
1138
|
-
transactions = data.transactions;
|
|
1139
|
-
}
|
|
1140
|
-
|
|
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;
|
|
1141
1524
|
return ok(collection(data, "transactions", transactions.map(formatTransaction), lastKnowledgeOfServer));
|
|
1142
1525
|
})
|
|
1143
1526
|
);
|
|
@@ -1351,17 +1734,26 @@ registerTool(
|
|
|
1351
1734
|
|
|
1352
1735
|
registerTool(
|
|
1353
1736
|
"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.
|
|
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: {
|
|
1355
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)."),
|
|
1356
1743
|
payeeId: z.string().optional().describe("Only approve unapproved transactions for this payee"),
|
|
1357
1744
|
categoryId: z.string().optional().describe("Only approve unapproved transactions in this category"),
|
|
1358
1745
|
accountId: z.string().optional().describe("Only approve unapproved transactions in this account"),
|
|
1359
1746
|
includeUncategorized: z.boolean().optional().describe("If true, also approve transactions with no category (default false — uncategorized are skipped for safety)"),
|
|
1360
1747
|
} },
|
|
1361
|
-
({ budgetId, payeeId, categoryId, accountId, includeUncategorized }) =>
|
|
1748
|
+
({ budgetId, expectedMatchedCount, sinceDate, untilDate, payeeId, categoryId, accountId, includeUncategorized }) =>
|
|
1362
1749
|
run(async () => {
|
|
1363
1750
|
const bid = resolveBudgetId(budgetId);
|
|
1364
|
-
const
|
|
1751
|
+
const data = await fetchTransactions({
|
|
1752
|
+
budgetId: bid,
|
|
1753
|
+
sinceDate: sinceDate || allHistorySinceDate(),
|
|
1754
|
+
untilDate,
|
|
1755
|
+
type: "unapproved",
|
|
1756
|
+
});
|
|
1365
1757
|
let txns = data.transactions.filter((t) => !t.deleted);
|
|
1366
1758
|
if (payeeId) txns = txns.filter((t) => t.payee_id === payeeId);
|
|
1367
1759
|
if (categoryId) txns = txns.filter((t) => t.category_id === categoryId);
|
|
@@ -1369,6 +1761,9 @@ registerTool(
|
|
|
1369
1761
|
if (!includeUncategorized) {
|
|
1370
1762
|
txns = txns.filter((t) => (t.category_id && t.category_name !== "Uncategorized") || t.transfer_account_id);
|
|
1371
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
|
+
}
|
|
1372
1767
|
if (txns.length === 0) {
|
|
1373
1768
|
return ok({ approved_count: 0, matched: 0, message: "No matching unapproved transactions to approve." });
|
|
1374
1769
|
}
|
|
@@ -1394,17 +1789,28 @@ registerTool(
|
|
|
1394
1789
|
|
|
1395
1790
|
registerTool(
|
|
1396
1791
|
"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: {
|
|
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: {
|
|
1398
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."),
|
|
1399
1796
|
fromPayeeId: z.string().describe("Payee whose transactions will be moved"),
|
|
1400
1797
|
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);
|
|
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)"),
|
|
1402
1800
|
} },
|
|
1403
|
-
({ budgetId, fromPayeeId, toPayeeId, sinceDate }) =>
|
|
1801
|
+
({ budgetId, expectedMatchedCount, fromPayeeId, toPayeeId, sinceDate, untilDate }) =>
|
|
1404
1802
|
run(async () => {
|
|
1405
1803
|
const bid = resolveBudgetId(budgetId);
|
|
1406
|
-
const
|
|
1804
|
+
const data = await fetchTransactions({
|
|
1805
|
+
budgetId: bid,
|
|
1806
|
+
payeeId: fromPayeeId,
|
|
1807
|
+
sinceDate: sinceDate || allHistorySinceDate(),
|
|
1808
|
+
untilDate,
|
|
1809
|
+
});
|
|
1407
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
|
+
}
|
|
1408
1814
|
if (txns.length === 0) {
|
|
1409
1815
|
return ok({ reassigned_count: 0, message: "No transactions found for fromPayeeId in the given range." });
|
|
1410
1816
|
}
|
|
@@ -1797,7 +2203,7 @@ registerTool(
|
|
|
1797
2203
|
} },
|
|
1798
2204
|
({ budgetId, month }) =>
|
|
1799
2205
|
run(async () => {
|
|
1800
|
-
const { data } = await api.months.
|
|
2206
|
+
const { data } = await api.months.getPlanMonth(resolveBudgetId(budgetId), month);
|
|
1801
2207
|
const overspent = (data.month.categories || [])
|
|
1802
2208
|
.filter((c) => !c.deleted && c.balance < 0 && c.category_group_name !== "Internal Master Category")
|
|
1803
2209
|
.map((c) => ({
|
|
@@ -1836,13 +2242,14 @@ registerTool(
|
|
|
1836
2242
|
inputSchema: {},
|
|
1837
2243
|
},
|
|
1838
2244
|
() => ok({
|
|
1839
|
-
server: "
|
|
2245
|
+
server: "mcp-server-for-ynab",
|
|
1840
2246
|
package: "@oliverames/ynab-mcp-server",
|
|
1841
2247
|
auth: ynabAuthStatus(),
|
|
1842
2248
|
writes_enabled: writesEnabled(),
|
|
2249
|
+
writes_available: writesEnabled() && !!API_TOKEN,
|
|
1843
2250
|
tools: listRegisteredYnabTools(),
|
|
1844
2251
|
execute_with: "ynab_tool_execute",
|
|
1845
|
-
write_execute_with: writesEnabled() ? "ynab_write_tool_execute" : null,
|
|
2252
|
+
write_execute_with: writesEnabled() && !!API_TOKEN ? "ynab_write_tool_execute" : null,
|
|
1846
2253
|
})
|
|
1847
2254
|
);
|
|
1848
2255
|
|
|
@@ -1884,8 +2291,9 @@ registerTool(
|
|
|
1884
2291
|
"ynab_write_tool_execute",
|
|
1885
2292
|
{
|
|
1886
2293
|
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
|
|
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.",
|
|
1888
2295
|
inputSchema: {
|
|
2296
|
+
confirmed: z.literal(true).describe("Required. Pass true only after the user explicitly confirms this write action."),
|
|
1889
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."),
|
|
1890
2298
|
input: z.record(z.string(), z.any()).optional().describe("JSON input for the selected YNAB write tool."),
|
|
1891
2299
|
},
|
|
@@ -1907,7 +2315,10 @@ registerTool(
|
|
|
1907
2315
|
if (!tool) {
|
|
1908
2316
|
return writeDisabledResult(toolName);
|
|
1909
2317
|
}
|
|
1910
|
-
|
|
2318
|
+
const confirmedInput = ["approve_transactions", "reassign_payee_transactions"].includes(toolName) && input.confirmed === undefined
|
|
2319
|
+
? { ...input, confirmed: true }
|
|
2320
|
+
: input;
|
|
2321
|
+
return tool.handler(confirmedInput);
|
|
1911
2322
|
}
|
|
1912
2323
|
);
|
|
1913
2324
|
|