@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/package.json
CHANGED
|
@@ -1,34 +1,41 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@oliverames/ynab-mcp-server",
|
|
3
|
-
"version": "
|
|
4
|
-
"description": "
|
|
3
|
+
"version": "3.0.0",
|
|
4
|
+
"description": "Local MCP server for YNAB budget operations",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "index.js",
|
|
7
7
|
"bin": {
|
|
8
|
+
"mcp-server-for-ynab": "index.js",
|
|
8
9
|
"ynab-mcp-server": "index.js"
|
|
9
10
|
},
|
|
10
11
|
"files": [
|
|
11
12
|
"index.js",
|
|
12
13
|
"scripts/",
|
|
13
14
|
"assets/icon.png",
|
|
14
|
-
"docs/hosted-oauth-connector.md"
|
|
15
|
+
"docs/hosted-oauth-connector.md",
|
|
16
|
+
"docs/privacy.md"
|
|
15
17
|
],
|
|
16
18
|
"scripts": {
|
|
17
19
|
"start": "node index.js",
|
|
18
|
-
"
|
|
20
|
+
"deps:ensure": "node --input-type=module -e \"await import('@modelcontextprotocol/sdk/client/index.js')\" >/dev/null 2>&1 || npm ci --silent --no-audit --no-fund",
|
|
21
|
+
"pretest": "npm run deps:ensure",
|
|
19
22
|
"test": "node test.js",
|
|
23
|
+
"presmoke:list-tools": "npm run deps:ensure",
|
|
20
24
|
"smoke:list-tools": "node scripts/smoke-list-tools.mjs",
|
|
25
|
+
"presmoke:review-unapproved": "npm run deps:ensure",
|
|
21
26
|
"smoke:review-unapproved": "node scripts/smoke-review-unapproved.mjs",
|
|
27
|
+
"presmoke:batch-verify": "npm run deps:ensure",
|
|
22
28
|
"smoke:batch-verify": "node scripts/smoke-batch-verify.mjs",
|
|
23
29
|
"release:check": "node scripts/check-release-consistency.mjs",
|
|
24
30
|
"release:check:registry": "node scripts/check-release-consistency.mjs --registry",
|
|
25
31
|
"build:mcpb": "node scripts/build-mcpb.mjs",
|
|
32
|
+
"pretest:safety": "npm run deps:ensure",
|
|
26
33
|
"test:safety": "node scripts/test-safety-model.mjs"
|
|
27
34
|
},
|
|
28
35
|
"dependencies": {
|
|
29
36
|
"@modelcontextprotocol/sdk": "^1.29.0",
|
|
30
|
-
"ynab": "^
|
|
31
|
-
"zod": "^4.3
|
|
37
|
+
"ynab": "^4.1.0",
|
|
38
|
+
"zod": "^4.4.3"
|
|
32
39
|
},
|
|
33
40
|
"engines": {
|
|
34
41
|
"node": ">=18"
|
package/scripts/build-mcpb.mjs
CHANGED
|
@@ -10,7 +10,7 @@ const projectRoot = path.dirname(path.dirname(fileURLToPath(import.meta.url)));
|
|
|
10
10
|
const pkg = JSON.parse(fs.readFileSync(path.join(projectRoot, "package.json"), "utf8"));
|
|
11
11
|
const force = process.argv.includes("--force");
|
|
12
12
|
const distDir = path.join(projectRoot, "dist");
|
|
13
|
-
const outputPath = path.join(distDir, `
|
|
13
|
+
const outputPath = path.join(distDir, `mcp-server-for-ynab-${pkg.version}.mcpb`);
|
|
14
14
|
|
|
15
15
|
if (fs.existsSync(outputPath) && !force) {
|
|
16
16
|
console.error(`Refusing to overwrite existing artifact: ${outputPath}`);
|
|
@@ -52,10 +52,10 @@ copyFile("assets/icon.png");
|
|
|
52
52
|
|
|
53
53
|
writeJson("manifest.json", {
|
|
54
54
|
manifest_version: "0.3",
|
|
55
|
-
name: "
|
|
56
|
-
display_name: "
|
|
55
|
+
name: "mcp-server-for-ynab",
|
|
56
|
+
display_name: "MCP Server for YNAB",
|
|
57
57
|
version: pkg.version,
|
|
58
|
-
description: "
|
|
58
|
+
description: "Local MCP server for YNAB budget operations.",
|
|
59
59
|
author: {
|
|
60
60
|
name: "Oliver Ames",
|
|
61
61
|
url: "https://github.com/oliverames",
|
|
@@ -84,7 +84,7 @@ writeJson("manifest.json", {
|
|
|
84
84
|
tools_generated: true,
|
|
85
85
|
keywords: ["mcp", "model-context-protocol", "ynab", "budgeting", "personal-finance"],
|
|
86
86
|
license: "MIT",
|
|
87
|
-
privacy_policies: ["https://
|
|
87
|
+
privacy_policies: ["https://github.com/oliverames/ynab-mcp-server/blob/main/docs/privacy.md"],
|
|
88
88
|
compatibility: {
|
|
89
89
|
platforms: ["darwin", "win32", "linux"],
|
|
90
90
|
runtimes: {
|
|
@@ -41,7 +41,15 @@ assert(lock.version === version, `package-lock root version matches ${version}`)
|
|
|
41
41
|
assert(lock.packages?.[""]?.version === version, `package-lock package version matches ${version}`);
|
|
42
42
|
assert(indexJs.includes(`version: "${version}"`), `index.js McpServer version matches ${version}`);
|
|
43
43
|
|
|
44
|
-
const
|
|
44
|
+
const registeredToolNames = [...indexJs.matchAll(/^\s*registerTool\(\s*\n\s*"([^"]+)"/gm)]
|
|
45
|
+
.map((match) => match[1]);
|
|
46
|
+
const registeredToolCount = registeredToolNames
|
|
47
|
+
.filter((name) => !name.startsWith("ynab_"))
|
|
48
|
+
.length;
|
|
49
|
+
const discoveryHelpers = ["ynab_auth_status", "ynab_tool_index", "ynab_tool_execute", "ynab_write_tool_execute"];
|
|
50
|
+
for (const helperName of discoveryHelpers) {
|
|
51
|
+
assert(registeredToolNames.includes(helperName), `discovery helper ${helperName} is registered`);
|
|
52
|
+
}
|
|
45
53
|
const readmeToolCounts = [...new Set(
|
|
46
54
|
[...readme.matchAll(/\b(\d+) tools\b/gi)].map((match) => Number(match[1]))
|
|
47
55
|
)];
|
|
@@ -63,12 +71,12 @@ assert(
|
|
|
63
71
|
: `README has stale release link versions: ${staleReleaseVersions.join(", ")}`
|
|
64
72
|
);
|
|
65
73
|
|
|
66
|
-
const mcpbVersions = [...readme.matchAll(/ynab-mcp-server-(\d+\.\d+\.\d+)\.mcpb/g)].map((match) => match[1]);
|
|
74
|
+
const mcpbVersions = [...readme.matchAll(/(?:ynab-mcp-server|mcp-server-for-ynab)-(\d+\.\d+\.\d+)\.mcpb/g)].map((match) => match[1]);
|
|
67
75
|
const staleMcpbVersions = [...new Set(mcpbVersions.filter((mcpbVersion) => mcpbVersion !== version))];
|
|
68
76
|
assert(
|
|
69
|
-
staleMcpbVersions.length === 0
|
|
77
|
+
staleMcpbVersions.length === 0,
|
|
70
78
|
staleMcpbVersions.length === 0
|
|
71
|
-
? `README MCPB artifact references match ${version}`
|
|
79
|
+
? (mcpbVersions.length > 0 ? `README MCPB artifact references match ${version}` : "README has no MCPB artifact references")
|
|
72
80
|
: `README has stale MCPB artifact versions: ${staleMcpbVersions.join(", ")}`
|
|
73
81
|
);
|
|
74
82
|
|
|
@@ -7,10 +7,15 @@ const requiredTools = [
|
|
|
7
7
|
"get_transactions",
|
|
8
8
|
"search_categories",
|
|
9
9
|
"search_payees",
|
|
10
|
+
"ynab_auth_status",
|
|
11
|
+
"ynab_tool_index",
|
|
12
|
+
"ynab_tool_execute",
|
|
10
13
|
];
|
|
11
14
|
|
|
12
15
|
const options = parseSmokeOptions();
|
|
13
|
-
const requiredWriteTools = process.env.YNAB_ALLOW_WRITES === "1"
|
|
16
|
+
const requiredWriteTools = process.env.YNAB_ALLOW_WRITES === "1"
|
|
17
|
+
? ["update_transactions", "approve_transactions", "ynab_write_tool_execute"]
|
|
18
|
+
: [];
|
|
14
19
|
|
|
15
20
|
await withSmokeClient(options, async (client, params) => {
|
|
16
21
|
const result = await client.listTools();
|
|
@@ -1,4 +1,6 @@
|
|
|
1
1
|
import assert from "node:assert/strict";
|
|
2
|
+
import fs from "node:fs";
|
|
3
|
+
import os from "node:os";
|
|
2
4
|
import { fileURLToPath } from "node:url";
|
|
3
5
|
import path from "node:path";
|
|
4
6
|
import { Client } from "@modelcontextprotocol/sdk/client/index.js";
|
|
@@ -21,10 +23,13 @@ const writeTools = [
|
|
|
21
23
|
"update_transaction",
|
|
22
24
|
"delete_transaction",
|
|
23
25
|
"update_transactions",
|
|
26
|
+
"approve_transactions",
|
|
27
|
+
"reassign_payee_transactions",
|
|
24
28
|
"import_transactions",
|
|
25
29
|
"create_scheduled_transaction",
|
|
26
30
|
"update_scheduled_transaction",
|
|
27
31
|
"delete_scheduled_transaction",
|
|
32
|
+
"ynab_write_tool_execute",
|
|
28
33
|
];
|
|
29
34
|
|
|
30
35
|
const requiredReadTools = [
|
|
@@ -44,6 +49,7 @@ function buildEnv(overrides = {}) {
|
|
|
44
49
|
);
|
|
45
50
|
env.YNAB_API_TOKEN = "test-token-for-list-tools";
|
|
46
51
|
env.YNAB_RATE_LIMIT_PER_HOUR = "0";
|
|
52
|
+
env.YNAB_DISABLE_AGENT_CONFIG_FALLBACK = "1";
|
|
47
53
|
|
|
48
54
|
for (const [key, value] of Object.entries(overrides)) {
|
|
49
55
|
if (value === undefined) {
|
|
@@ -56,7 +62,7 @@ function buildEnv(overrides = {}) {
|
|
|
56
62
|
return env;
|
|
57
63
|
}
|
|
58
64
|
|
|
59
|
-
async function
|
|
65
|
+
async function withTestClient(overrides, callback) {
|
|
60
66
|
const client = new Client({
|
|
61
67
|
name: "ynab-safety-model-test",
|
|
62
68
|
version: "1.0.0",
|
|
@@ -72,18 +78,51 @@ async function listTools(overrides = {}) {
|
|
|
72
78
|
|
|
73
79
|
try {
|
|
74
80
|
await client.connect(transport);
|
|
75
|
-
|
|
76
|
-
return response.tools;
|
|
81
|
+
return await callback(client);
|
|
77
82
|
} finally {
|
|
78
83
|
await client.close();
|
|
79
84
|
}
|
|
80
85
|
}
|
|
81
86
|
|
|
87
|
+
async function listTools(overrides = {}) {
|
|
88
|
+
return withTestClient(overrides, async (client) => {
|
|
89
|
+
const response = await client.listTools();
|
|
90
|
+
return response.tools;
|
|
91
|
+
});
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
async function callJsonTool(name, overrides = {}, input = {}) {
|
|
95
|
+
return withTestClient(overrides, async (client) => {
|
|
96
|
+
const response = await client.callTool({ name, arguments: input });
|
|
97
|
+
const textItem = response.content?.find((item) => item.type === "text" && item.text);
|
|
98
|
+
assert.ok(textItem, `${name} returned text content`);
|
|
99
|
+
return {
|
|
100
|
+
isError: !!response.isError,
|
|
101
|
+
payload: JSON.parse(textItem.text),
|
|
102
|
+
};
|
|
103
|
+
});
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
function tempHome() {
|
|
107
|
+
return fs.mkdtempSync(path.join(os.tmpdir(), "ynab-mcp-safety-"));
|
|
108
|
+
}
|
|
109
|
+
|
|
82
110
|
const readOnlyTools = await listTools({ YNAB_ALLOW_WRITES: undefined });
|
|
83
111
|
const readOnlyNames = new Set(readOnlyTools.map((tool) => tool.name));
|
|
112
|
+
const readOnlyToolsByName = new Map(readOnlyTools.map((tool) => [tool.name, tool]));
|
|
113
|
+
|
|
114
|
+
const discoveryOnlyTools = await listTools({
|
|
115
|
+
YNAB_API_TOKEN: undefined,
|
|
116
|
+
YNAB_API_TOKEN_FILE: undefined,
|
|
117
|
+
YNAB_OP_PATH: undefined,
|
|
118
|
+
YNAB_BUDGET_ID: undefined,
|
|
119
|
+
YNAB_ALLOW_WRITES: undefined,
|
|
120
|
+
});
|
|
121
|
+
const discoveryOnlyNames = new Set(discoveryOnlyTools.map((tool) => tool.name));
|
|
84
122
|
|
|
85
123
|
for (const name of requiredReadTools) {
|
|
86
124
|
assert.ok(readOnlyNames.has(name), `expected read tool ${name} to be available by default`);
|
|
125
|
+
assert.ok(discoveryOnlyNames.has(name), `expected read tool ${name} to be discoverable without auth`);
|
|
87
126
|
}
|
|
88
127
|
|
|
89
128
|
for (const name of writeTools) {
|
|
@@ -98,6 +137,11 @@ for (const tool of readOnlyTools) {
|
|
|
98
137
|
);
|
|
99
138
|
}
|
|
100
139
|
|
|
140
|
+
assert.ok(
|
|
141
|
+
readOnlyToolsByName.get("get_transactions")?.inputSchema?.properties?.untilDate,
|
|
142
|
+
"expected get_transactions to expose untilDate for YNAB API v1.85 transaction listings",
|
|
143
|
+
);
|
|
144
|
+
|
|
101
145
|
const writableTools = await listTools({ YNAB_ALLOW_WRITES: "1" });
|
|
102
146
|
const writableNames = new Set(writableTools.map((tool) => tool.name));
|
|
103
147
|
|
|
@@ -125,4 +169,89 @@ assert.equal(
|
|
|
125
169
|
"expected delete_scheduled_transaction to be annotated destructive",
|
|
126
170
|
);
|
|
127
171
|
|
|
172
|
+
function requiresConfirmedTrue(tool) {
|
|
173
|
+
const schema = tool?.inputSchema || {};
|
|
174
|
+
const confirmed = schema.properties?.confirmed;
|
|
175
|
+
return Array.isArray(schema.required)
|
|
176
|
+
&& schema.required.includes("confirmed")
|
|
177
|
+
&& (confirmed?.const === true || confirmed?.enum?.includes(true));
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
for (const name of ["approve_transactions", "reassign_payee_transactions", "ynab_write_tool_execute"]) {
|
|
181
|
+
assert.ok(
|
|
182
|
+
requiresConfirmedTrue(destructiveTools.get(name)),
|
|
183
|
+
`expected ${name} to require confirmed:true in its input schema`,
|
|
184
|
+
);
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
{
|
|
188
|
+
const home = tempHome();
|
|
189
|
+
fs.mkdirSync(path.join(home, ".codex"), { recursive: true });
|
|
190
|
+
fs.writeFileSync(path.join(home, ".codex", "config.toml"), [
|
|
191
|
+
"[shell_environment_policy.set]",
|
|
192
|
+
"YNAB_API_TOKEN = \"codex-config-token\"",
|
|
193
|
+
"YNAB_BUDGET_ID = \"codex-budget-id\"",
|
|
194
|
+
"YNAB_ALLOW_WRITES = \"1\"",
|
|
195
|
+
"",
|
|
196
|
+
].join("\n"));
|
|
197
|
+
|
|
198
|
+
const { payload } = await callJsonTool("ynab_auth_status", {
|
|
199
|
+
HOME: home,
|
|
200
|
+
YNAB_API_TOKEN: undefined,
|
|
201
|
+
YNAB_API_TOKEN_FILE: undefined,
|
|
202
|
+
YNAB_OP_PATH: undefined,
|
|
203
|
+
YNAB_BUDGET_ID: undefined,
|
|
204
|
+
YNAB_ALLOW_WRITES: undefined,
|
|
205
|
+
YNAB_DISABLE_AGENT_CONFIG_FALLBACK: undefined,
|
|
206
|
+
});
|
|
207
|
+
|
|
208
|
+
assert.equal(payload.authenticated, true, "expected Codex config fallback token to authenticate");
|
|
209
|
+
assert.equal(payload.credential_source, "codex_shell_environment");
|
|
210
|
+
assert.equal(payload.default_budget_id_configured, true);
|
|
211
|
+
assert.equal(payload.writes_enabled, true);
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
{
|
|
215
|
+
const home = tempHome();
|
|
216
|
+
fs.mkdirSync(path.join(home, ".claude"), { recursive: true });
|
|
217
|
+
fs.writeFileSync(path.join(home, ".claude", "settings.json"), JSON.stringify({
|
|
218
|
+
env: {
|
|
219
|
+
YNAB_API_TOKEN: "claude-settings-token",
|
|
220
|
+
YNAB_BUDGET_ID: "claude-budget-id",
|
|
221
|
+
},
|
|
222
|
+
}));
|
|
223
|
+
|
|
224
|
+
const { payload } = await callJsonTool("ynab_auth_status", {
|
|
225
|
+
HOME: home,
|
|
226
|
+
YNAB_API_TOKEN: undefined,
|
|
227
|
+
YNAB_API_TOKEN_FILE: undefined,
|
|
228
|
+
YNAB_OP_PATH: undefined,
|
|
229
|
+
YNAB_BUDGET_ID: undefined,
|
|
230
|
+
YNAB_ALLOW_WRITES: undefined,
|
|
231
|
+
YNAB_DISABLE_AGENT_CONFIG_FALLBACK: undefined,
|
|
232
|
+
});
|
|
233
|
+
|
|
234
|
+
assert.equal(payload.authenticated, true, "expected Claude settings fallback token to authenticate");
|
|
235
|
+
assert.equal(payload.credential_source, "claude_settings_env");
|
|
236
|
+
assert.equal(payload.default_budget_id_configured, true);
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
{
|
|
240
|
+
const home = tempHome();
|
|
241
|
+
const { isError, payload } = await callJsonTool("get_user", {
|
|
242
|
+
HOME: home,
|
|
243
|
+
YNAB_API_TOKEN: undefined,
|
|
244
|
+
YNAB_API_TOKEN_FILE: undefined,
|
|
245
|
+
YNAB_OP_PATH: undefined,
|
|
246
|
+
YNAB_BUDGET_ID: undefined,
|
|
247
|
+
YNAB_ALLOW_WRITES: undefined,
|
|
248
|
+
YNAB_DISABLE_AGENT_CONFIG_FALLBACK: undefined,
|
|
249
|
+
});
|
|
250
|
+
|
|
251
|
+
assert.equal(isError, true, "expected missing credentials to fail before YNAB API call");
|
|
252
|
+
assert.equal(payload.error, "missing_credentials");
|
|
253
|
+
assert.equal(payload.auth.authenticated, false);
|
|
254
|
+
assert.ok(payload.auth.setup.prompt_for_agent.includes("password manager"));
|
|
255
|
+
}
|
|
256
|
+
|
|
128
257
|
console.log("Safety model checks passed");
|