@oliverames/ynab-mcp-server 1.7.1 → 2.1.1

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,6 +1,6 @@
1
1
  {
2
2
  "name": "@oliverames/ynab-mcp-server",
3
- "version": "1.7.1",
3
+ "version": "2.1.1",
4
4
  "description": "YNAB MCP server with full API coverage",
5
5
  "type": "module",
6
6
  "main": "index.js",
@@ -8,12 +8,22 @@
8
8
  "ynab-mcp-server": "index.js"
9
9
  },
10
10
  "files": [
11
- "index.js"
11
+ "index.js",
12
+ "scripts/",
13
+ "assets/icon.png",
14
+ "docs/hosted-oauth-connector.md"
12
15
  ],
13
16
  "scripts": {
14
17
  "start": "node index.js",
15
- "pretest": "[ -d node_modules ] || npm ci --silent --no-audit --no-fund",
16
- "test": "node test.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",
19
+ "test": "node test.js",
20
+ "smoke:list-tools": "node scripts/smoke-list-tools.mjs",
21
+ "smoke:review-unapproved": "node scripts/smoke-review-unapproved.mjs",
22
+ "smoke:batch-verify": "node scripts/smoke-batch-verify.mjs",
23
+ "release:check": "node scripts/check-release-consistency.mjs",
24
+ "release:check:registry": "node scripts/check-release-consistency.mjs --registry",
25
+ "build:mcpb": "node scripts/build-mcpb.mjs",
26
+ "test:safety": "node scripts/test-safety-model.mjs"
17
27
  },
18
28
  "dependencies": {
19
29
  "@modelcontextprotocol/sdk": "^1.29.0",
@@ -0,0 +1,133 @@
1
+ #!/usr/bin/env node
2
+
3
+ import { execFileSync } from "node:child_process";
4
+ import fs from "node:fs";
5
+ import os from "node:os";
6
+ import path from "node:path";
7
+ import { fileURLToPath } from "node:url";
8
+
9
+ const projectRoot = path.dirname(path.dirname(fileURLToPath(import.meta.url)));
10
+ const pkg = JSON.parse(fs.readFileSync(path.join(projectRoot, "package.json"), "utf8"));
11
+ const force = process.argv.includes("--force");
12
+ const distDir = path.join(projectRoot, "dist");
13
+ const outputPath = path.join(distDir, `ynab-mcp-server-${pkg.version}.mcpb`);
14
+
15
+ if (fs.existsSync(outputPath) && !force) {
16
+ console.error(`Refusing to overwrite existing artifact: ${outputPath}`);
17
+ console.error("Pass --force to rebuild this generated artifact.");
18
+ process.exit(1);
19
+ }
20
+
21
+ const stagingDir = fs.mkdtempSync(path.join(os.tmpdir(), "ynab-mcpb-"));
22
+
23
+ function copyFile(relativePath) {
24
+ const source = path.join(projectRoot, relativePath);
25
+ const destination = path.join(stagingDir, relativePath);
26
+ fs.mkdirSync(path.dirname(destination), { recursive: true });
27
+ fs.copyFileSync(source, destination);
28
+ }
29
+
30
+ function writeJson(relativePath, value) {
31
+ const destination = path.join(stagingDir, relativePath);
32
+ fs.mkdirSync(path.dirname(destination), { recursive: true });
33
+ fs.writeFileSync(destination, `${JSON.stringify(value, null, 2)}\n`);
34
+ }
35
+
36
+ const bundlePackage = {
37
+ name: pkg.name,
38
+ version: pkg.version,
39
+ description: pkg.description,
40
+ type: pkg.type,
41
+ main: pkg.main,
42
+ dependencies: pkg.dependencies,
43
+ engines: pkg.engines,
44
+ };
45
+
46
+ writeJson("package.json", bundlePackage);
47
+ copyFile("package-lock.json");
48
+ copyFile("index.js");
49
+ copyFile("README.md");
50
+ copyFile("LICENSE");
51
+ copyFile("assets/icon.png");
52
+
53
+ writeJson("manifest.json", {
54
+ manifest_version: "0.3",
55
+ name: "ynab-mcp-server",
56
+ display_name: "YNAB MCP Server",
57
+ version: pkg.version,
58
+ description: "Complete MCP server for YNAB budget operations.",
59
+ author: {
60
+ name: "Oliver Ames",
61
+ url: "https://github.com/oliverames",
62
+ },
63
+ repository: {
64
+ type: "git",
65
+ url: "https://github.com/oliverames/ynab-mcp-server.git",
66
+ },
67
+ homepage: "https://github.com/oliverames/ynab-mcp-server#readme",
68
+ documentation: "https://github.com/oliverames/ynab-mcp-server#readme",
69
+ support: "https://github.com/oliverames/ynab-mcp-server/issues",
70
+ icon: "assets/icon.png",
71
+ server: {
72
+ type: "node",
73
+ entry_point: "index.js",
74
+ mcp_config: {
75
+ command: "node",
76
+ args: ["${__dirname}/index.js"],
77
+ env: {
78
+ YNAB_API_TOKEN: "${user_config.ynab_api_token}",
79
+ YNAB_BUDGET_ID: "${user_config.ynab_budget_id}",
80
+ YNAB_ALLOW_WRITES: "${user_config.ynab_allow_writes}",
81
+ },
82
+ },
83
+ },
84
+ tools_generated: true,
85
+ keywords: ["mcp", "model-context-protocol", "ynab", "budgeting", "personal-finance"],
86
+ license: "MIT",
87
+ privacy_policies: ["https://www.ynab.com/privacy-policy"],
88
+ compatibility: {
89
+ platforms: ["darwin", "win32", "linux"],
90
+ runtimes: {
91
+ node: ">=18.0.0",
92
+ },
93
+ },
94
+ user_config: {
95
+ ynab_api_token: {
96
+ type: "string",
97
+ title: "YNAB API Token",
98
+ description: "Personal access token from YNAB Developer Settings.",
99
+ required: true,
100
+ sensitive: true,
101
+ },
102
+ ynab_budget_id: {
103
+ type: "string",
104
+ title: "Default Budget ID",
105
+ description: "Optional default budget ID. Leave blank to use YNAB's last-used budget.",
106
+ required: false,
107
+ },
108
+ ynab_allow_writes: {
109
+ type: "string",
110
+ title: "Enable Write Tools",
111
+ description: "Set to 1 to expose tools that create, update, import, or delete YNAB data. Leave blank or set to 0 for read-only mode.",
112
+ required: false,
113
+ default: "0",
114
+ },
115
+ },
116
+ });
117
+
118
+ execFileSync("npm", ["ci", "--omit=dev", "--no-audit", "--no-fund"], {
119
+ cwd: stagingDir,
120
+ stdio: "inherit",
121
+ });
122
+
123
+ fs.mkdirSync(distDir, { recursive: true });
124
+ if (force) {
125
+ fs.rmSync(outputPath, { force: true });
126
+ }
127
+ execFileSync("zip", ["-qr", outputPath, "."], {
128
+ cwd: stagingDir,
129
+ stdio: "inherit",
130
+ });
131
+
132
+ const size = fs.statSync(outputPath).size;
133
+ console.log(`Built ${outputPath} (${size} bytes)`);
@@ -0,0 +1,100 @@
1
+ #!/usr/bin/env node
2
+
3
+ import { execFileSync } from "node:child_process";
4
+ import fs from "node:fs";
5
+ import path from "node:path";
6
+ import { fileURLToPath } from "node:url";
7
+
8
+ const projectRoot = path.dirname(path.dirname(fileURLToPath(import.meta.url)));
9
+ const checkRegistry = process.argv.includes("--registry");
10
+
11
+ function readJson(relativePath) {
12
+ return JSON.parse(fs.readFileSync(path.join(projectRoot, relativePath), "utf8"));
13
+ }
14
+
15
+ function readText(relativePath) {
16
+ return fs.readFileSync(path.join(projectRoot, relativePath), "utf8");
17
+ }
18
+
19
+ const errors = [];
20
+ const checks = [];
21
+
22
+ function pass(message) {
23
+ checks.push(message);
24
+ }
25
+
26
+ function assert(condition, message) {
27
+ if (condition) {
28
+ pass(message);
29
+ } else {
30
+ errors.push(message);
31
+ }
32
+ }
33
+
34
+ const pkg = readJson("package.json");
35
+ const lock = readJson("package-lock.json");
36
+ const indexJs = readText("index.js");
37
+ const readme = readText("README.md");
38
+ const version = pkg.version;
39
+
40
+ assert(lock.version === version, `package-lock root version matches ${version}`);
41
+ assert(lock.packages?.[""]?.version === version, `package-lock package version matches ${version}`);
42
+ assert(indexJs.includes(`version: "${version}"`), `index.js McpServer version matches ${version}`);
43
+
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
+ }
53
+ const readmeToolCounts = [...new Set(
54
+ [...readme.matchAll(/\b(\d+) tools\b/gi)].map((match) => Number(match[1]))
55
+ )];
56
+ assert(
57
+ readmeToolCounts.length > 0 && readmeToolCounts.every((count) => count === registeredToolCount),
58
+ readmeToolCounts.length > 0
59
+ ? `README tool count references match ${registeredToolCount}`
60
+ : "README includes at least one tool count reference"
61
+ );
62
+
63
+ const releaseVersions = [
64
+ ...readme.matchAll(/github\.com\/oliverames\/ynab-mcp-server\/releases\/(?:tag|download)\/v(\d+\.\d+\.\d+)/g),
65
+ ].map((match) => match[1]);
66
+ const staleReleaseVersions = [...new Set(releaseVersions.filter((releaseVersion) => releaseVersion !== version))];
67
+ assert(
68
+ staleReleaseVersions.length === 0,
69
+ staleReleaseVersions.length === 0
70
+ ? `README release links match v${version}`
71
+ : `README has stale release link versions: ${staleReleaseVersions.join(", ")}`
72
+ );
73
+
74
+ const mcpbVersions = [...readme.matchAll(/ynab-mcp-server-(\d+\.\d+\.\d+)\.mcpb/g)].map((match) => match[1]);
75
+ const staleMcpbVersions = [...new Set(mcpbVersions.filter((mcpbVersion) => mcpbVersion !== version))];
76
+ assert(
77
+ staleMcpbVersions.length === 0 && mcpbVersions.length > 0,
78
+ staleMcpbVersions.length === 0
79
+ ? `README MCPB artifact references match ${version}`
80
+ : `README has stale MCPB artifact versions: ${staleMcpbVersions.join(", ")}`
81
+ );
82
+
83
+ if (checkRegistry) {
84
+ const npmVersion = execFileSync("npm", ["view", pkg.name, "version"], {
85
+ cwd: projectRoot,
86
+ encoding: "utf8",
87
+ }).trim();
88
+ assert(npmVersion === version, `npm latest matches ${version}`);
89
+ }
90
+
91
+ if (errors.length > 0) {
92
+ for (const message of errors) {
93
+ console.error(`FAIL: ${message}`);
94
+ }
95
+ process.exit(1);
96
+ }
97
+
98
+ for (const message of checks) {
99
+ console.log(`PASS: ${message}`);
100
+ }
@@ -0,0 +1,133 @@
1
+ import fs from "node:fs";
2
+ import path from "node:path";
3
+ import { fileURLToPath } from "node:url";
4
+ import { Client } from "@modelcontextprotocol/sdk/client/index.js";
5
+ import { StdioClientTransport } from "@modelcontextprotocol/sdk/client/stdio.js";
6
+
7
+ const scriptsDir = path.dirname(path.dirname(fileURLToPath(import.meta.url)));
8
+ export const projectRoot = path.dirname(scriptsDir);
9
+ export const packageJson = JSON.parse(fs.readFileSync(path.join(projectRoot, "package.json"), "utf8"));
10
+
11
+ function envWithStringsOnly(source) {
12
+ return Object.fromEntries(
13
+ Object.entries(source).filter(([, value]) => typeof value === "string")
14
+ );
15
+ }
16
+
17
+ function readArg(args, index, flag) {
18
+ const value = args[index + 1];
19
+ if (!value || value.startsWith("--")) {
20
+ throw new Error(`${flag} requires a value`);
21
+ }
22
+ return value;
23
+ }
24
+
25
+ function usage() {
26
+ return [
27
+ "Usage: npm run smoke:list-tools -- [--published | --package <pkg> | --server-command <command>]",
28
+ " npm run smoke:review-unapproved -- [--published | --package <pkg> | --server-command <command>]",
29
+ " YNAB_ALLOW_WRITES=1 npm run smoke:batch-verify -- [--published | --package <pkg> | --server-command <command>]",
30
+ "",
31
+ "Defaults to the local checkout entrypoint: node index.js",
32
+ "Use --published to test npx -y @oliverames/ynab-mcp-server@latest from /tmp.",
33
+ ].join("\n");
34
+ }
35
+
36
+ export function parseSmokeOptions(args = process.argv.slice(2)) {
37
+ const options = {
38
+ mode: "local",
39
+ packageSpec: `${packageJson.name}@latest`,
40
+ serverCommand: null,
41
+ };
42
+
43
+ for (let i = 0; i < args.length; i += 1) {
44
+ const arg = args[i];
45
+ if (arg === "--help" || arg === "-h") {
46
+ console.log(usage());
47
+ process.exit(0);
48
+ }
49
+ if (arg === "--published") {
50
+ options.mode = "published";
51
+ continue;
52
+ }
53
+ if (arg === "--package") {
54
+ options.mode = "published";
55
+ options.packageSpec = readArg(args, i, arg);
56
+ i += 1;
57
+ continue;
58
+ }
59
+ if (arg === "--server-command") {
60
+ options.mode = "custom";
61
+ options.serverCommand = readArg(args, i, arg);
62
+ i += 1;
63
+ continue;
64
+ }
65
+ throw new Error(`Unknown option: ${arg}\n\n${usage()}`);
66
+ }
67
+
68
+ return options;
69
+ }
70
+
71
+ export function serverParamsForOptions(options) {
72
+ if (options.mode === "published") {
73
+ return {
74
+ label: `npx -y ${options.packageSpec}`,
75
+ command: "npx",
76
+ args: ["-y", options.packageSpec],
77
+ cwd: "/tmp",
78
+ };
79
+ }
80
+
81
+ if (options.mode === "custom") {
82
+ return {
83
+ label: options.serverCommand,
84
+ command: "bash",
85
+ args: ["-lc", options.serverCommand],
86
+ cwd: projectRoot,
87
+ };
88
+ }
89
+
90
+ return {
91
+ label: "node index.js",
92
+ command: "node",
93
+ args: ["index.js"],
94
+ cwd: projectRoot,
95
+ };
96
+ }
97
+
98
+ export async function withSmokeClient(options, callback) {
99
+ const params = serverParamsForOptions(options);
100
+ const transport = new StdioClientTransport({
101
+ command: params.command,
102
+ args: params.args,
103
+ cwd: params.cwd,
104
+ env: envWithStringsOnly(process.env),
105
+ stderr: "pipe",
106
+ });
107
+
108
+ const stderrChunks = [];
109
+ transport.stderr?.on("data", (chunk) => stderrChunks.push(chunk.toString()));
110
+
111
+ const client = new Client({ name: "ynab-mcp-smoke", version: packageJson.version });
112
+ try {
113
+ await client.connect(transport);
114
+ return await callback(client, params);
115
+ } catch (error) {
116
+ const stderr = stderrChunks.join("").trim();
117
+ if (stderr) {
118
+ console.error("\nServer stderr:");
119
+ console.error(stderr);
120
+ }
121
+ throw error;
122
+ } finally {
123
+ await client.close().catch(() => {});
124
+ }
125
+ }
126
+
127
+ export function parseTextToolResult(result) {
128
+ const textItem = result.content?.find((item) => item.type === "text" && item.text);
129
+ if (!textItem) {
130
+ throw new Error("Tool result did not include text content");
131
+ }
132
+ return JSON.parse(textItem.text);
133
+ }
@@ -0,0 +1,81 @@
1
+ #!/usr/bin/env node
2
+
3
+ import { parseSmokeOptions, parseTextToolResult, withSmokeClient } from "./lib/smoke-client.mjs";
4
+
5
+ const options = parseSmokeOptions();
6
+
7
+ if (process.env.YNAB_ALLOW_WRITES !== "1") {
8
+ throw new Error("smoke:batch-verify creates, updates, and deletes a transaction. Re-run with YNAB_ALLOW_WRITES=1.");
9
+ }
10
+
11
+ function today() {
12
+ return new Date().toISOString().slice(0, 10);
13
+ }
14
+
15
+ async function call(client, name, args = {}) {
16
+ const result = await client.callTool({ name, arguments: args });
17
+ if (result.isError) {
18
+ throw new Error(result.content?.[0]?.text || `${name} returned an MCP error`);
19
+ }
20
+ return parseTextToolResult(result);
21
+ }
22
+
23
+ await withSmokeClient(options, async (client, params) => {
24
+ let transactionId;
25
+
26
+ try {
27
+ const review = await call(client, "review_unapproved", { summary: true });
28
+
29
+ const accounts = await call(client, "list_accounts");
30
+ const account = accounts.find((item) => !item.closed && !item.deleted);
31
+ if (!account) throw new Error("No active account found for smoke transaction");
32
+
33
+ const categoryGroups = await call(client, "list_categories");
34
+ const category = categoryGroups
35
+ .filter((group) => !group.hidden && !group.deleted && group.name !== "Internal Master Category")
36
+ .flatMap((group) => group.categories)
37
+ .find((item) => !item.hidden && !item.deleted);
38
+ if (!category) throw new Error("No active category found for smoke transaction");
39
+
40
+ const created = await call(client, "create_transaction", {
41
+ accountId: account.id,
42
+ date: today(),
43
+ amount: -4.56,
44
+ payeeName: "MCP Batch Verify Smoke",
45
+ memo: "MCP smoke test - safe to delete",
46
+ approved: false,
47
+ });
48
+ transactionId = created.id;
49
+
50
+ const updated = await call(client, "update_transactions", {
51
+ transactions: [{
52
+ id: transactionId,
53
+ categoryId: category.id,
54
+ approved: true,
55
+ }],
56
+ });
57
+
58
+ if (updated.verification?.checked !== 1) {
59
+ throw new Error(`Expected one verified update, got ${updated.verification?.checked}`);
60
+ }
61
+ if (updated.verification.failed?.length) {
62
+ throw new Error(`Verification failed: ${JSON.stringify(updated.verification.failed)}`);
63
+ }
64
+
65
+ const refetched = await call(client, "get_transaction", { transactionId });
66
+ if (refetched.approved !== true) throw new Error("Approval did not persist");
67
+ if (refetched.category_id !== category.id) {
68
+ throw new Error(`Category did not persist: ${refetched.category_id}`);
69
+ }
70
+
71
+ console.log(`Connected to ${params.label}`);
72
+ console.log(`review_unapproved summary total: ${review.total}`);
73
+ console.log("Batch category+approval update verified by post-write refetch");
74
+ console.log(`Verification checked: ${updated.verification.checked}`);
75
+ console.log(`Verification retried: ${updated.verification.retried.length}`);
76
+ } finally {
77
+ if (transactionId) {
78
+ await call(client, "delete_transaction", { transactionId });
79
+ }
80
+ }
81
+ });
@@ -0,0 +1,33 @@
1
+ #!/usr/bin/env node
2
+
3
+ import { parseSmokeOptions, withSmokeClient } from "./lib/smoke-client.mjs";
4
+
5
+ const requiredTools = [
6
+ "review_unapproved",
7
+ "get_transactions",
8
+ "search_categories",
9
+ "search_payees",
10
+ "ynab_auth_status",
11
+ "ynab_tool_index",
12
+ "ynab_tool_execute",
13
+ ];
14
+
15
+ const options = parseSmokeOptions();
16
+ const requiredWriteTools = process.env.YNAB_ALLOW_WRITES === "1"
17
+ ? ["update_transactions", "approve_transactions", "ynab_write_tool_execute"]
18
+ : [];
19
+
20
+ await withSmokeClient(options, async (client, params) => {
21
+ const result = await client.listTools();
22
+ const toolNames = result.tools.map((tool) => tool.name).sort();
23
+ const expectedTools = [...requiredTools, ...requiredWriteTools];
24
+ const missing = expectedTools.filter((name) => !toolNames.includes(name));
25
+
26
+ if (missing.length > 0) {
27
+ throw new Error(`Missing expected YNAB tools: ${missing.join(", ")}`);
28
+ }
29
+
30
+ console.log(`Connected to ${params.label}`);
31
+ console.log(`Listed ${toolNames.length} tools`);
32
+ console.log(`Required tools present: ${expectedTools.join(", ")}`);
33
+ });
@@ -0,0 +1,27 @@
1
+ #!/usr/bin/env node
2
+
3
+ import { parseSmokeOptions, parseTextToolResult, withSmokeClient } from "./lib/smoke-client.mjs";
4
+
5
+ const options = parseSmokeOptions();
6
+
7
+ await withSmokeClient(options, async (client, params) => {
8
+ const result = await client.callTool({
9
+ name: "review_unapproved",
10
+ arguments: { summary: true },
11
+ });
12
+
13
+ if (result.isError) {
14
+ throw new Error(result.content?.[0]?.text || "review_unapproved returned an MCP error");
15
+ }
16
+
17
+ const payload = parseTextToolResult(result);
18
+ if (typeof payload.total !== "number") {
19
+ throw new Error("review_unapproved summary did not include a numeric total");
20
+ }
21
+
22
+ console.log(`Connected to ${params.label}`);
23
+ console.log("Called review_unapproved with summary: true");
24
+ console.log(`Total unapproved: ${payload.total}`);
25
+ console.log(`Ready to approve: ${payload.ready_to_approve?.count ?? 0}`);
26
+ console.log(`Needs category first: ${payload.needs_category_first?.count ?? 0}`);
27
+ });
@@ -0,0 +1,140 @@
1
+ import assert from "node:assert/strict";
2
+ import { fileURLToPath } from "node:url";
3
+ import path from "node:path";
4
+ import { Client } from "@modelcontextprotocol/sdk/client/index.js";
5
+ import { StdioClientTransport } from "@modelcontextprotocol/sdk/client/stdio.js";
6
+
7
+ const __filename = fileURLToPath(import.meta.url);
8
+ const projectRoot = path.resolve(path.dirname(__filename), "..");
9
+
10
+ const writeTools = [
11
+ "create_account",
12
+ "update_month_category",
13
+ "update_category",
14
+ "create_category",
15
+ "create_category_group",
16
+ "update_category_group",
17
+ "update_payee",
18
+ "create_payee",
19
+ "create_transaction",
20
+ "create_transactions",
21
+ "update_transaction",
22
+ "delete_transaction",
23
+ "update_transactions",
24
+ "approve_transactions",
25
+ "reassign_payee_transactions",
26
+ "import_transactions",
27
+ "create_scheduled_transaction",
28
+ "update_scheduled_transaction",
29
+ "delete_scheduled_transaction",
30
+ "ynab_write_tool_execute",
31
+ ];
32
+
33
+ const requiredReadTools = [
34
+ "get_user",
35
+ "list_budgets",
36
+ "get_budget",
37
+ "list_accounts",
38
+ "get_transactions",
39
+ "review_unapproved",
40
+ "search_categories",
41
+ "search_payees",
42
+ ];
43
+
44
+ function buildEnv(overrides = {}) {
45
+ const env = Object.fromEntries(
46
+ Object.entries(process.env).filter(([, value]) => typeof value === "string"),
47
+ );
48
+ env.YNAB_API_TOKEN = "test-token-for-list-tools";
49
+ env.YNAB_RATE_LIMIT_PER_HOUR = "0";
50
+
51
+ for (const [key, value] of Object.entries(overrides)) {
52
+ if (value === undefined) {
53
+ delete env[key];
54
+ } else {
55
+ env[key] = value;
56
+ }
57
+ }
58
+
59
+ return env;
60
+ }
61
+
62
+ async function listTools(overrides = {}) {
63
+ const client = new Client({
64
+ name: "ynab-safety-model-test",
65
+ version: "1.0.0",
66
+ });
67
+
68
+ const transport = new StdioClientTransport({
69
+ command: "node",
70
+ args: ["index.js"],
71
+ cwd: projectRoot,
72
+ env: buildEnv(overrides),
73
+ stderr: "pipe",
74
+ });
75
+
76
+ try {
77
+ await client.connect(transport);
78
+ const response = await client.listTools();
79
+ return response.tools;
80
+ } finally {
81
+ await client.close();
82
+ }
83
+ }
84
+
85
+ const readOnlyTools = await listTools({ YNAB_ALLOW_WRITES: undefined });
86
+ const readOnlyNames = new Set(readOnlyTools.map((tool) => tool.name));
87
+
88
+ const discoveryOnlyTools = await listTools({
89
+ YNAB_API_TOKEN: undefined,
90
+ YNAB_API_TOKEN_FILE: undefined,
91
+ YNAB_OP_PATH: undefined,
92
+ YNAB_ALLOW_WRITES: undefined,
93
+ });
94
+ const discoveryOnlyNames = new Set(discoveryOnlyTools.map((tool) => tool.name));
95
+
96
+ for (const name of requiredReadTools) {
97
+ assert.ok(readOnlyNames.has(name), `expected read tool ${name} to be available by default`);
98
+ assert.ok(discoveryOnlyNames.has(name), `expected read tool ${name} to be discoverable without auth`);
99
+ }
100
+
101
+ for (const name of writeTools) {
102
+ assert.ok(!readOnlyNames.has(name), `expected write tool ${name} to be hidden by default`);
103
+ }
104
+
105
+ for (const tool of readOnlyTools) {
106
+ assert.equal(
107
+ tool.annotations?.readOnlyHint,
108
+ true,
109
+ `expected ${tool.name} to be annotated read-only`,
110
+ );
111
+ }
112
+
113
+ const writableTools = await listTools({ YNAB_ALLOW_WRITES: "1" });
114
+ const writableNames = new Set(writableTools.map((tool) => tool.name));
115
+
116
+ for (const name of writeTools) {
117
+ assert.ok(writableNames.has(name), `expected write tool ${name} when writes are enabled`);
118
+ }
119
+
120
+ for (const tool of writableTools.filter((tool) => writeTools.includes(tool.name))) {
121
+ assert.equal(
122
+ tool.annotations?.readOnlyHint,
123
+ false,
124
+ `expected ${tool.name} to be annotated writable`,
125
+ );
126
+ }
127
+
128
+ const destructiveTools = new Map(writableTools.map((tool) => [tool.name, tool]));
129
+ assert.equal(
130
+ destructiveTools.get("delete_transaction")?.annotations?.destructiveHint,
131
+ true,
132
+ "expected delete_transaction to be annotated destructive",
133
+ );
134
+ assert.equal(
135
+ destructiveTools.get("delete_scheduled_transaction")?.annotations?.destructiveHint,
136
+ true,
137
+ "expected delete_scheduled_transaction to be annotated destructive",
138
+ );
139
+
140
+ console.log("Safety model checks passed");