@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/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: {
|
|
@@ -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
|
|
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
|
|
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
|
-
|
|
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");
|