@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/package.json CHANGED
@@ -1,34 +1,41 @@
1
1
  {
2
2
  "name": "@oliverames/ynab-mcp-server",
3
- "version": "2.1.0",
4
- "description": "YNAB MCP server with full API coverage",
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
- "pretest": "node --input-type=module -e \"await import('@modelcontextprotocol/sdk/client/index.js')\" >/dev/null 2>&1 || npm ci --silent --no-audit --no-fund",
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": "^2.5.0",
31
- "zod": "^4.3.6"
37
+ "ynab": "^4.1.0",
38
+ "zod": "^4.4.3"
32
39
  },
33
40
  "engines": {
34
41
  "node": ">=18"
@@ -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, `ynab-mcp-server-${pkg.version}.mcpb`);
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: "ynab-mcp-server",
56
- display_name: "YNAB MCP Server",
55
+ name: "mcp-server-for-ynab",
56
+ display_name: "MCP Server for YNAB",
57
57
  version: pkg.version,
58
- description: "Complete MCP server for YNAB budget operations.",
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://www.ynab.com/privacy-policy"],
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 registeredToolCount = [...indexJs.matchAll(/server\.registerTool\(/g)].length;
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 && mcpbVersions.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" ? ["update_transactions"] : [];
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 listTools(overrides = {}) {
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
- const response = await client.listTools();
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");