@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/package.json CHANGED
@@ -1,34 +1,41 @@
1
1
  {
2
2
  "name": "@oliverames/ynab-mcp-server",
3
- "version": "2.1.1",
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: {
@@ -71,12 +71,12 @@ assert(
71
71
  : `README has stale release link versions: ${staleReleaseVersions.join(", ")}`
72
72
  );
73
73
 
74
- 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]);
75
75
  const staleMcpbVersions = [...new Set(mcpbVersions.filter((mcpbVersion) => mcpbVersion !== version))];
76
76
  assert(
77
- staleMcpbVersions.length === 0 && mcpbVersions.length > 0,
77
+ staleMcpbVersions.length === 0,
78
78
  staleMcpbVersions.length === 0
79
- ? `README MCPB artifact references match ${version}`
79
+ ? (mcpbVersions.length > 0 ? `README MCPB artifact references match ${version}` : "README has no MCPB artifact references")
80
80
  : `README has stale MCPB artifact versions: ${staleMcpbVersions.join(", ")}`
81
81
  );
82
82
 
@@ -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";
@@ -47,6 +49,7 @@ function buildEnv(overrides = {}) {
47
49
  );
48
50
  env.YNAB_API_TOKEN = "test-token-for-list-tools";
49
51
  env.YNAB_RATE_LIMIT_PER_HOUR = "0";
52
+ env.YNAB_DISABLE_AGENT_CONFIG_FALLBACK = "1";
50
53
 
51
54
  for (const [key, value] of Object.entries(overrides)) {
52
55
  if (value === undefined) {
@@ -59,7 +62,7 @@ function buildEnv(overrides = {}) {
59
62
  return env;
60
63
  }
61
64
 
62
- async function listTools(overrides = {}) {
65
+ async function withTestClient(overrides, callback) {
63
66
  const client = new Client({
64
67
  name: "ynab-safety-model-test",
65
68
  version: "1.0.0",
@@ -75,20 +78,44 @@ async function listTools(overrides = {}) {
75
78
 
76
79
  try {
77
80
  await client.connect(transport);
78
- const response = await client.listTools();
79
- return response.tools;
81
+ return await callback(client);
80
82
  } finally {
81
83
  await client.close();
82
84
  }
83
85
  }
84
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
+
85
110
  const readOnlyTools = await listTools({ YNAB_ALLOW_WRITES: undefined });
86
111
  const readOnlyNames = new Set(readOnlyTools.map((tool) => tool.name));
112
+ const readOnlyToolsByName = new Map(readOnlyTools.map((tool) => [tool.name, tool]));
87
113
 
88
114
  const discoveryOnlyTools = await listTools({
89
115
  YNAB_API_TOKEN: undefined,
90
116
  YNAB_API_TOKEN_FILE: undefined,
91
117
  YNAB_OP_PATH: undefined,
118
+ YNAB_BUDGET_ID: undefined,
92
119
  YNAB_ALLOW_WRITES: undefined,
93
120
  });
94
121
  const discoveryOnlyNames = new Set(discoveryOnlyTools.map((tool) => tool.name));
@@ -110,6 +137,11 @@ for (const tool of readOnlyTools) {
110
137
  );
111
138
  }
112
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
+
113
145
  const writableTools = await listTools({ YNAB_ALLOW_WRITES: "1" });
114
146
  const writableNames = new Set(writableTools.map((tool) => tool.name));
115
147
 
@@ -137,4 +169,89 @@ assert.equal(
137
169
  "expected delete_scheduled_transaction to be annotated destructive",
138
170
  );
139
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
+
140
257
  console.log("Safety model checks passed");